diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 6802ec0a..db9edfd5 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -73,6 +73,8 @@ import {IS_LOCAL} from "common/hosting/constants"; import {editMessage} from "api/edit-message"; import {reactToMessage} from "api/react-to-message"; import {deleteMessage} from "api/delete-message"; +import {updateOptions} from "api/update-options"; +import {getOptions} from "api/get-options"; // const corsOptions: CorsOptions = { // origin: ['*'], // Only allow requests from this domain @@ -366,6 +368,8 @@ const handlers: { [k in APIPath]: APIHandler } = { 'delete-message': deleteMessage, 'edit-message': editMessage, 'react-to-message': reactToMessage, + 'update-options': updateOptions, + 'get-options': getOptions, // 'auth-google': authGoogle, } diff --git a/backend/api/src/get-options.ts b/backend/api/src/get-options.ts new file mode 100644 index 00000000..fff028de --- /dev/null +++ b/backend/api/src/get-options.ts @@ -0,0 +1,28 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {log} from 'shared/utils' +import {tryCatch} from 'common/util/try-catch' +import {OPTION_TABLES} from "common/profiles/constants"; + +export const getOptions: APIHandler<'get-options'> = async ( + {table}, + _auth +) => { + if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table') + + const pg = createSupabaseDirectClient() + + const result = await tryCatch( + pg.manyOrNone<{ name: string }>(`SELECT interests.name + FROM interests`) + ) + + if (result.error) { + log('Error getting profile options', result.error) + throw new APIError(500, 'Error getting profile options') + } + + const names = result.data.map(row => row.name) + return {names} +} + diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index 30c1a837..cedaee3c 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -4,6 +4,7 @@ import {createSupabaseDirectClient, pgp} from 'shared/supabase/init' import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder' import {MIN_BIO_LENGTH} from "common/constants"; import {compact} from "lodash"; +import {OptionTableKey} from "common/profiles/constants"; export type profileQueryType = { limit?: number | undefined, @@ -37,6 +38,8 @@ export type profileQueryType = { skipId?: string | undefined, orderBy?: string | undefined, lastModificationWithin?: string | undefined, +} & { + [K in OptionTableKey]?: string[] | undefined } // const userActivityColumns = ['last_online_time'] @@ -44,7 +47,7 @@ export type profileQueryType = { export const loadProfiles = async (props: profileQueryType) => { const pg = createSupabaseDirectClient() - console.debug(props) + console.debug('loadProfiles', props) const { limit: limitParam, after, @@ -66,6 +69,9 @@ export const loadProfiles = async (props: profileQueryType) => { religion, wants_kids_strength, has_kids, + interests, + causes, + work, is_smoker, shortBio, geodbCityIds, @@ -95,24 +101,55 @@ export const loadProfiles = async (props: profileQueryType) => { : 'profiles' const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id' + + // Pre-aggregated interests per profile + function getManyToManyJoin(label: OptionTableKey) { + return `( + SELECT + profile_${label}.profile_id, + ARRAY_AGG(${label}.name ORDER BY ${label}.name) AS ${label} + FROM profile_${label} + JOIN ${label} ON ${label}.id = profile_${label}.option_id + GROUP BY profile_${label}.profile_id + ) i ON i.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' && leftJoin(userActivityJoin), + orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin), + interests && leftJoin(interestsJoin), + causes && leftJoin(causesJoin), + work && leftJoin(workJoin), + ] + 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), + ...joins, where('profiles.id = $(after)', {after}), ) const tableSelection = compact([ from('profiles'), join('users on users.id = profiles.user_id'), - orderByParam === 'last_online_time' && leftJoin(userActivityJoin), - orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin), + ...joins, ]) + function getManyToManyClause(label: OptionTableKey) { + return `EXISTS ( + SELECT 1 FROM profile_${label} pi2 + JOIN ${label} ii2 ON ii2.id = pi2.option_id + WHERE pi2.profile_id = profiles.id + AND ii2.name = ANY (ARRAY[$(values)]) + )` + } + const filters = [ where('looking_for_matches = true'), where(`profiles.disabled != true`), @@ -190,6 +227,12 @@ export const loadProfiles = async (props: profileQueryType) => { {religion} ), + interests?.length && where(getManyToManyClause('interests'), {values: interests}), + + causes?.length && where(getManyToManyClause('causes'), {values: causes}), + + work?.length && where(getManyToManyClause('work'), {values: work}), + !!wants_kids_strength && wants_kids_strength !== -1 && where( @@ -229,12 +272,15 @@ export const loadProfiles = async (props: profileQueryType) => { lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}), ] - let selectCols = 'profiles.*, name, username, users.data as user' + 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') { selectCols += ', user_activity.last_online_time' } + if (interests) selectCols += `, COALESCE(i.interests, '{}') AS interests` + if (causes) selectCols += `, COALESCE(i.causes, '{}') AS causes` + if (work) selectCols += `, COALESCE(i.work, '{}') AS work` const query = renderSql( select(selectCols), @@ -267,7 +313,8 @@ export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => { if (!props.skipId) props.skipId = auth.uid const {profiles, count} = await loadProfiles(props) return {status: 'success', profiles: profiles, count: count} - } catch { + } catch (error) { + console.log(error) return {status: 'fail', profiles: [], count: 0} } } diff --git a/backend/api/src/update-options.ts b/backend/api/src/update-options.ts new file mode 100644 index 00000000..1d8dc416 --- /dev/null +++ b/backend/api/src/update-options.ts @@ -0,0 +1,63 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {log} from 'shared/utils' +import {tryCatch} from 'common/util/try-catch' +import {OPTION_TABLES} from "common/profiles/constants"; + +export const updateOptions: APIHandler<'update-options'> = async ( + {table, names}, + auth +) => { + if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table') + if (!names || !Array.isArray(names) || names.length === 0) { + throw new APIError(400, 'No names provided') + } + + log('Updating profile options', {table, names}) + + const pg = createSupabaseDirectClient() + + const profileIdResult = await pg.oneOrNone<{ id: number }>( + 'SELECT id FROM profiles WHERE user_id = $1', + [auth.uid] + ) + if (!profileIdResult) throw new APIError(404, 'Profile not found') + const profileId = profileIdResult.id + + const result = await tryCatch(pg.tx(async (t) => { + const ids: number[] = [] + for (const name of names) { + const row = await t.one<{ id: number }>( + `INSERT INTO ${table} (name, creator_id) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE + SET name = ${table}.name + RETURNING id`, + [name, auth.uid] + ) + ids.push(row.id) + } + + // Delete old options for this profile + await t.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId]) + + // Insert new option_ids + if (ids.length > 0) { + const values = ids.map((id, i) => `($1, $${i + 2})`).join(', ') + await t.none( + `INSERT INTO profile_${table} (profile_id, option_id) VALUES ${values}`, + [profileId, ...ids] + ) + } + + return ids + })) + + if (result.error) { + log('Error updating profile options', result.error) + throw new APIError(500, 'Error updating profile options') + } + + return {updatedIds: result.data} +} + diff --git a/backend/api/src/update-profile.ts b/backend/api/src/update-profile.ts index 079df02e..0e2d5d47 100644 --- a/backend/api/src/update-profile.ts +++ b/backend/api/src/update-profile.ts @@ -11,7 +11,7 @@ export const updateProfile: APIHandler<'update-profile'> = async ( parsedBody, auth ) => { - log('parsedBody', parsedBody) + log('Updating profile', parsedBody) const pg = createSupabaseDirectClient() const { data: existingProfile } = await tryCatch( diff --git a/backend/supabase/causes.sql b/backend/supabase/causes.sql new file mode 100644 index 00000000..506c1d17 --- /dev/null +++ b/backend/supabase/causes.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS causes +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + creator_id text REFERENCES users (id) ON DELETE set null, + name TEXT NOT NULL, + CONSTRAINT causes_name_unique UNIQUE (name) +); + +-- Row Level Security +ALTER TABLE causes + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON causes; +CREATE POLICY "public read" ON causes + FOR SELECT USING (true); + +CREATE UNIQUE INDEX idx_causes_name_ci + ON causes (name); diff --git a/backend/supabase/interests.sql b/backend/supabase/interests.sql new file mode 100644 index 00000000..4a3e5dcf --- /dev/null +++ b/backend/supabase/interests.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS interests +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + creator_id text REFERENCES users (id) ON DELETE set null, + name TEXT NOT NULL, + CONSTRAINT interests_name_unique UNIQUE (name) +); + +-- Row Level Security +ALTER TABLE interests + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON interests; +CREATE POLICY "public read" ON interests + FOR SELECT USING (true); + +CREATE UNIQUE INDEX idx_interests_name_ci + ON interests (name); diff --git a/backend/supabase/profile_causes.sql b/backend/supabase/profile_causes.sql new file mode 100644 index 00000000..ff2c48be --- /dev/null +++ b/backend/supabase/profile_causes.sql @@ -0,0 +1,23 @@ +CREATE TABLE profile_causes +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + profile_id BIGINT NOT NULL REFERENCES profiles (id) ON DELETE CASCADE, + option_id BIGINT NOT NULL REFERENCES causes (id) ON DELETE CASCADE +); + +-- Row Level Security +ALTER TABLE profile_causes + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON profile_causes; +CREATE POLICY "public read" ON profile_causes + FOR SELECT USING (true); + +ALTER TABLE profile_causes + ADD CONSTRAINT profile_causes_option_unique UNIQUE (profile_id, option_id); + +CREATE INDEX idx_profile_causes_profile + ON profile_causes (profile_id); + +CREATE INDEX idx_profile_causes_interest + ON profile_causes (option_id); diff --git a/backend/supabase/profile_interests.sql b/backend/supabase/profile_interests.sql new file mode 100644 index 00000000..f371b818 --- /dev/null +++ b/backend/supabase/profile_interests.sql @@ -0,0 +1,23 @@ +CREATE TABLE profile_interests +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + profile_id BIGINT NOT NULL REFERENCES profiles (id) ON DELETE CASCADE, + option_id BIGINT NOT NULL REFERENCES interests (id) ON DELETE CASCADE +); + +-- Row Level Security +ALTER TABLE profile_interests + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON profile_interests; +CREATE POLICY "public read" ON profile_interests + FOR SELECT USING (true); + +ALTER TABLE profile_interests + ADD CONSTRAINT profile_interests_option_unique UNIQUE (profile_id, option_id); + +CREATE INDEX idx_profile_interests_profile + ON profile_interests (profile_id); + +CREATE INDEX idx_profile_interests_interest + ON profile_interests (option_id); diff --git a/backend/supabase/profile_work.sql b/backend/supabase/profile_work.sql new file mode 100644 index 00000000..e3a27c4e --- /dev/null +++ b/backend/supabase/profile_work.sql @@ -0,0 +1,23 @@ +CREATE TABLE profile_work +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + profile_id BIGINT NOT NULL REFERENCES profiles (id) ON DELETE CASCADE, + option_id BIGINT NOT NULL REFERENCES work (id) ON DELETE CASCADE +); + +-- Row Level Security +ALTER TABLE profile_work + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON profile_work; +CREATE POLICY "public read" ON profile_work + FOR SELECT USING (true); + +ALTER TABLE profile_work + ADD CONSTRAINT profile_work_option_unique UNIQUE (profile_id, option_id); + +CREATE INDEX idx_profile_work_profile + ON profile_work (profile_id); + +CREATE INDEX idx_profile_work_interest + ON profile_work (option_id); diff --git a/backend/supabase/work.sql b/backend/supabase/work.sql new file mode 100644 index 00000000..21701f05 --- /dev/null +++ b/backend/supabase/work.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS work +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + creator_id text REFERENCES users (id) ON DELETE set null, + name TEXT NOT NULL, + CONSTRAINT work_name_unique UNIQUE (name) +); + +-- Row Level Security +ALTER TABLE work + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON work; +CREATE POLICY "public read" ON work + FOR SELECT USING (true); + +CREATE UNIQUE INDEX idx_work_name_ci + ON work (name); diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index c634a4f2..e91b392f 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1,7 +1,7 @@ import {arraybeSchema, baseProfilesSchema, combinedProfileSchema, contentSchema, zBoolean,} from 'common/api/zod-types' import {PrivateChatMessage} from 'common/chat-message' import {CompatibilityScore} from 'common/profiles/compatibility-score' -import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/profiles/constants' +import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants' import {Profile, ProfileRow} from 'common/profiles/profile' import {Row} from 'common/supabase/utils' import {PrivateUser, User} from 'common/user' @@ -460,6 +460,9 @@ export const API = (_apiTypeCheck = { diet: arraybeSchema.optional(), political_beliefs: arraybeSchema.optional(), mbti: arraybeSchema.optional(), + interests: arraybeSchema.optional(), + causes: arraybeSchema.optional(), + work: arraybeSchema.optional(), relationship_status: arraybeSchema.optional(), languages: arraybeSchema.optional(), wants_kids_strength: z.coerce.number().optional(), @@ -486,6 +489,33 @@ export const API = (_apiTypeCheck = { summary: 'List profiles with filters, pagination and ordering', tag: 'Profiles', }, + 'get-options': { + method: 'GET', + authed: true, + rateLimited: true, + returns: {}, + props: z + .object({ + table: z.enum(OPTION_TABLES), + }) + .strict(), + summary: 'Get profile options like interests', + tag: 'Profiles', + }, + 'update-options': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {}, + props: z + .object({ + table: z.enum(OPTION_TABLES), + names: arraybeSchema.optional(), + }) + .strict(), + summary: 'Update profile options like interests', + tag: 'Profiles', + }, 'create-comment': { method: 'POST', authed: true, diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index ef1fc092..9b6dc716 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -91,6 +91,9 @@ const optionalProfilesSchema = z.object({ occupation: z.string().optional().nullable(), occupation_title: z.string().optional().nullable(), political_beliefs: z.array(z.string()).optional().nullable(), + interests: z.array(z.string()).optional().nullable(), + work: z.array(z.string()).optional().nullable(), + causes: z.array(z.string()).optional().nullable(), relationship_status: z.array(z.string()).optional().nullable(), political_details: z.string().optional().nullable(), pref_romantic_styles: z.array(z.string()).nullable(), diff --git a/common/src/filters.ts b/common/src/filters.ts index 6d3c48b6..c75c037e 100644 --- a/common/src/filters.ts +++ b/common/src/filters.ts @@ -1,6 +1,7 @@ import {Profile, ProfileRow} from "common/profiles/profile"; import {cloneDeep} from "lodash"; import {filterDefined} from "common/util/array"; +import {OptionTableKey} from "common/profiles/constants"; // export type TargetArea = { // lat: number @@ -21,7 +22,10 @@ export type FilterFields = { shortBio: boolean | undefined drinks_min: number | undefined drinks_max: number | undefined -} & Pick< +} & { + [K in OptionTableKey]: string[] +} + & Pick< ProfileRow, | 'wants_kids_strength' | 'pref_relation_styles' @@ -74,6 +78,9 @@ export const initialFilters: Partial = { pref_romantic_styles: undefined, diet: undefined, political_beliefs: undefined, + interests: undefined, + causes: undefined, + work: undefined, relationship_status: undefined, languages: undefined, religion: undefined, diff --git a/common/src/profiles/constants.ts b/common/src/profiles/constants.ts index 60e645cb..4e08e259 100644 --- a/common/src/profiles/constants.ts +++ b/common/src/profiles/constants.ts @@ -1,7 +1,10 @@ -import { isProd } from 'common/envs/is-prod' +import {isProd} from 'common/envs/is-prod' export const compassUserId = isProd() ? 'tRZZ6ihugZQLXPf6aPRneGpWLmz1' : 'RlXR2xa4EFfAzdCbSe45wkcdarh1' export const MAX_COMPATIBILITY_QUESTION_LENGTH = 240 + +export const OPTION_TABLES = ['interests', 'causes', 'work'] as const +export type OptionTableKey = typeof OPTION_TABLES[number] diff --git a/common/src/profiles/profile.ts b/common/src/profiles/profile.ts index 9d0d05e0..b1213706 100644 --- a/common/src/profiles/profile.ts +++ b/common/src/profiles/profile.ts @@ -1,10 +1,55 @@ -import { Row, run, SupabaseClient } from 'common/supabase/utils' -import { User } from 'common/user' +import {Row, run, SupabaseClient} from 'common/supabase/utils' +import {User} from 'common/user' +import {OptionTableKey} from "common/profiles/constants"; export type ProfileRow = Row<'profiles'> -export type Profile = ProfileRow & { user: User } -export const getProfileRow = async (userId: string, db: SupabaseClient) => { - // console.debug('getProfileRow', userId) - const res = await run(db.from('profiles').select('*').eq('user_id', userId)) - return res.data[0] +export type ProfileWithoutUser = ProfileRow & {[K in OptionTableKey]?: string[]} +export type Profile = ProfileWithoutUser & { user: User } + +export const getProfileRow = async (userId: string, db: SupabaseClient): Promise => { + // Fetch profile + const profileRes = await run( + db + .from('profiles') + .select('*') + .eq('user_id', userId) + ) + const profile = profileRes.data?.[0] + if (!profile) return null + + // Fetch interests + const interestsRes = await run( + db + .from('profile_interests') + .select('interests(name)') + .eq('profile_id', profile.id) + ) + const interests = interestsRes.data?.map((row: any) => row.interests.name) || [] + + // Fetch causes + const causesRes = await run( + db + .from('profile_causes') + .select('causes(name)') + .eq('profile_id', profile.id) + ) + const causes = causesRes.data?.map((row: any) => row.causes.name) || [] + + // Fetch causes + const workRes = await run( + db + .from('profile_work') + .select('work(name)') + .eq('profile_id', profile.id) + ) + const work = workRes.data?.map((row: any) => row.work.name) || [] + + // console.debug('work', work) + + return { + ...profile, + interests, + causes, + work, + } } diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 329c9281..c9ffbbf3 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -52,6 +52,32 @@ export type Database = { }, ] } + causes: { + Row: { + creator_id: string | null + id: number + name: string + } + Insert: { + creator_id?: string | null + id?: never + name: string + } + Update: { + creator_id?: string | null + id?: never + name?: string + } + Relationships: [ + { + foreignKeyName: 'causes_creator_id_fkey' + columns: ['creator_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } compatibility_answers: { Row: { created_time: string @@ -243,6 +269,32 @@ export type Database = { }, ] } + interests: { + Row: { + creator_id: string | null + id: number + name: string + } + Insert: { + creator_id?: string | null + id?: never + name: string + } + Update: { + creator_id?: string | null + id?: never + name?: string + } + Relationships: [ + { + foreignKeyName: 'interests_creator_id_fkey' + columns: ['creator_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } private_user_message_channel_members: { Row: { channel_id: number @@ -431,6 +483,39 @@ export type Database = { }, ] } + profile_causes: { + Row: { + id: number + option_id: number + profile_id: number + } + Insert: { + id?: never + option_id: number + profile_id: number + } + Update: { + id?: never + option_id?: number + profile_id?: number + } + Relationships: [ + { + foreignKeyName: 'profile_causes_option_id_fkey' + columns: ['option_id'] + isOneToOne: false + referencedRelation: 'causes' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'profile_causes_profile_id_fkey' + columns: ['profile_id'] + isOneToOne: false + referencedRelation: 'profiles' + referencedColumns: ['id'] + }, + ] + } profile_comments: { Row: { content: Json @@ -485,6 +570,39 @@ export type Database = { }, ] } + profile_interests: { + Row: { + id: number + option_id: number + profile_id: number + } + Insert: { + id?: never + option_id: number + profile_id: number + } + Update: { + id?: never + option_id?: number + profile_id?: number + } + Relationships: [ + { + foreignKeyName: 'profile_interests_option_id_fkey' + columns: ['option_id'] + isOneToOne: false + referencedRelation: 'interests' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'profile_interests_profile_id_fkey' + columns: ['profile_id'] + isOneToOne: false + referencedRelation: 'profiles' + referencedColumns: ['id'] + }, + ] + } profile_likes: { Row: { created_time: string @@ -582,6 +700,39 @@ export type Database = { }, ] } + profile_work: { + Row: { + id: number + option_id: number + profile_id: number + } + Insert: { + id?: never + option_id: number + profile_id: number + } + Update: { + id?: never + option_id?: number + profile_id?: number + } + Relationships: [ + { + foreignKeyName: 'profile_work_option_id_fkey' + columns: ['option_id'] + isOneToOne: false + referencedRelation: 'work' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'profile_work_profile_id_fkey' + columns: ['profile_id'] + isOneToOne: false + referencedRelation: 'profiles' + referencedColumns: ['id'] + }, + ] + } profiles: { Row: { age: number | null @@ -1077,6 +1228,32 @@ export type Database = { }, ] } + work: { + Row: { + creator_id: string | null + id: number + name: string + } + Insert: { + creator_id?: string | null + id?: never + name: string + } + Update: { + creator_id?: string | null + id?: never + name?: string + } + Relationships: [ + { + foreignKeyName: 'work_creator_id_fkey' + columns: ['creator_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } } Views: { [_ in never]: never diff --git a/web/components/add-option-entry.tsx b/web/components/add-option-entry.tsx new file mode 100644 index 00000000..216e05a0 --- /dev/null +++ b/web/components/add-option-entry.tsx @@ -0,0 +1,31 @@ +import {ProfileWithoutUser} from "common/profiles/profile"; +import {OptionTableKey} from "common/profiles/constants"; +import {Col} from "web/components/layout/col"; +import clsx from "clsx"; +import {colClassName, labelClassName} from "web/pages/signup"; +import {MultiCheckbox} from "web/components/multi-checkbox"; +import {capitalize} from "lodash"; + +export function AddOptionEntry(props: { + choices: {}, + setChoices: (choices: {}) => void + profile: ProfileWithoutUser, + setProfile: (key: K, value: ProfileWithoutUser[K]) => void + label: OptionTableKey, +}) { + const {profile, setProfile, label, choices, setChoices} = props + return + + setProfile(label, selected)} + addOption={(v: string) => { + console.log(`Adding ${label}:`, v) + setChoices((prev: string[]) => ({...prev, [v]: v})) + setProfile(label, [...(profile[label] ?? []), v]) + return {key: v, value: v} + }} + /> + +} \ No newline at end of file diff --git a/web/components/filters/desktop-filters.tsx b/web/components/filters/desktop-filters.tsx index ef9d0b2b..90f84e2a 100644 --- a/web/components/filters/desktop-filters.tsx +++ b/web/components/filters/desktop-filters.tsx @@ -20,7 +20,7 @@ import {KidsLabel, wantsKidsLabelsWithIcon} from "web/components/filters/wants-k import {hasKidsLabels} from "common/has-kids"; import {HasKidsLabel} from "web/components/filters/has-kids-filter"; import {RomanticFilter, RomanticFilterText} from "web/components/filters/romantic-filter"; -import {FaHeart} from "react-icons/fa"; +import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa"; import {DietFilter, DietFilterText} from "web/components/filters/diet-filter"; import {PoliticalFilter, PoliticalFilterText} from "web/components/filters/political-filter"; import {GiFruitBowl} from "react-icons/gi"; @@ -40,6 +40,8 @@ import { RelationshipStatusFilterText } from "web/components/filters/relationship-status-filter"; import {BsPersonHeart} from "react-icons/bs"; +import {InterestFilter, InterestFilterText} from "web/components/filters/interest-filter"; +import {OptionTableKey} from "common/profiles/constants"; export function DesktopFilters(props: { filters: Partial @@ -50,6 +52,7 @@ export function DesktopFilters(props: { isYourFilters: boolean locationFilterProps: LocationFilterProps includeRelationshipFilters: boolean | undefined + choices: Record> }) { const { filters, @@ -60,6 +63,7 @@ export function DesktopFilters(props: { isYourFilters, locationFilterProps, includeRelationshipFilters, + choices, } = props return ( @@ -418,6 +422,105 @@ export function DesktopFilters(props: { menuWidth="w-50" /> + {/* Interests */} + ( + + + + + } + /> + )} + dropdownMenuContent={ + + } + popoverClassName="bg-canvas-50" + menuWidth="w-50 max-h-[400px] overflow-y-auto" + /> + + {/* Causes */} + ( + + + + + } + /> + )} + dropdownMenuContent={ + + } + popoverClassName="bg-canvas-50" + menuWidth="w-50 max-h-[400px] overflow-y-auto" + /> + + {/* Work */} + ( + + + + + } + /> + )} + dropdownMenuContent={ + + } + popoverClassName="bg-canvas-50" + menuWidth="w-50 max-h-[400px] overflow-y-auto" + /> + {/* POLITICS */} ( diff --git a/web/components/filters/interest-filter.tsx b/web/components/filters/interest-filter.tsx new file mode 100644 index 00000000..8895d9dc --- /dev/null +++ b/web/components/filters/interest-filter.tsx @@ -0,0 +1,59 @@ +import clsx from 'clsx' +import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text' +import {MultiCheckbox} from 'web/components/multi-checkbox' +import {FilterFields} from "common/filters"; +import {OptionTableKey} from "common/profiles/constants"; + +export function InterestFilterText(props: { + options: string[] | undefined + highlightedClass?: string + label: string +}) { + const {options, highlightedClass, label} = props + const length = (options ?? []).length + + if (!options || length < 1) { + return ( + Any {label} + ) + } + + if (length > 2) { + return ( + + + Multiple + + + ) + } + + return ( +
+ + {stringOrStringArrayToText({ + text: options, + capitalizeFirstLetterOption: true, + })}{' '} + +
+ ) +} + +export function InterestFilter(props: { + filters: Partial + updateFilter: (newState: Partial) => void + choices: Record + label: OptionTableKey +}) { + const {filters, updateFilter, choices, label} = props + return ( + { + updateFilter({[label]: c}) + }} + /> + ) +} diff --git a/web/components/filters/mobile-filters.tsx b/web/components/filters/mobile-filters.tsx index b6e5bac7..d4032777 100644 --- a/web/components/filters/mobile-filters.tsx +++ b/web/components/filters/mobile-filters.tsx @@ -31,6 +31,8 @@ import { RelationshipStatusFilterText } from "web/components/filters/relationship-status-filter"; import {MbtiFilter, MbtiFilterText} from "web/components/filters/mbti-filter"; +import {InterestFilter, InterestFilterText} from "web/components/filters/interest-filter"; +import {OptionTableKey} from "common/profiles/constants"; function MobileFilters(props: { filters: Partial @@ -41,6 +43,7 @@ function MobileFilters(props: { isYourFilters: boolean locationFilterProps: LocationFilterProps includeRelationshipFilters: boolean | undefined + choices: Record> }) { const { filters, @@ -51,6 +54,7 @@ function MobileFilters(props: { isYourFilters, locationFilterProps, includeRelationshipFilters, + choices, } = props const [openFilter, setOpenFilter] = useState(undefined) @@ -364,6 +368,84 @@ function MobileFilters(props: { + {/* INTERESTS */} + + } + > + + + + {/* CAUSES */} + + } + > + + + + {/* WORK */} + + } + > + + + {/* POLITICS */} { if (isHolding) return; @@ -221,6 +230,7 @@ export const Search = (props: { isYourFilters={isYourFilters} locationFilterProps={locationFilterProps} includeRelationshipFilters={youSeekingRelationship} + choices={choices} /> diff --git a/web/components/filters/use-filters.ts b/web/components/filters/use-filters.ts index 81b5fb34..f015db7e 100644 --- a/web/components/filters/use-filters.ts +++ b/web/components/filters/use-filters.ts @@ -74,6 +74,9 @@ export const useFilters = (you: Profile | undefined) => { pref_romantic_styles: you?.pref_romantic_styles?.length ? you.pref_romantic_styles : undefined, diet: you?.diet?.length ? you.diet : undefined, political_beliefs: you?.political_beliefs?.length ? you.political_beliefs : undefined, + interests: you?.interests?.length ? you.interests : undefined, + work: you?.work?.length ? you.work : undefined, + causes: you?.causes?.length ? you.causes : undefined, mbti: you?.mbti ? [you.mbti] : undefined, relationship_status: you?.relationship_status?.length ? you.relationship_status : undefined, languages: you?.languages?.length ? you.languages : undefined, @@ -99,6 +102,9 @@ export const useFilters = (you: Profile | undefined) => { && isEqual(new Set(filters.pref_relation_styles), new Set(you.pref_relation_styles)) && isEqual(new Set(filters.diet), new Set(you.diet)) && isEqual(new Set(filters.political_beliefs), new Set(you.political_beliefs)) + && isEqual(new Set(filters.interests), new Set(you.interests)) + && isEqual(new Set(filters.causes), new Set(you.causes)) + && isEqual(new Set(filters.work), new Set(you.work)) && isEqual(new Set(filters.relationship_status), new Set(you.relationship_status)) && isEqual(new Set(filters.languages), new Set(you.languages)) && isEqual(new Set(filters.religion), new Set(you.religion)) diff --git a/web/components/multi-checkbox.tsx b/web/components/multi-checkbox.tsx index abea7441..743acdf9 100644 --- a/web/components/multi-checkbox.tsx +++ b/web/components/multi-checkbox.tsx @@ -1,30 +1,134 @@ import { Row } from 'web/components/layout/row' import { Checkbox } from 'web/components/widgets/checkbox' -import clsx from "clsx"; - +import { Input } from 'web/components/widgets/input' +import { Button } from 'web/components/buttons/button' +import clsx from 'clsx' +import { useEffect, useMemo, useState } from 'react' + export const MultiCheckbox = (props: { + // Map of label -> value choices: { [key: string]: string } + // Selected values (should match the "value" side of choices) selected: string[] onChange: (selected: string[]) => void className?: string + // If provided, enables adding a new option and should persist it (e.g. to DB) + // Return value can be: + // - string: the stored value for the new option; label will be the input text + // - { key, value }: explicit label (key) and stored value + // - null/undefined to indicate failure/cancellation + addOption?: (label: string) => string | { key: string; value: string } | null | undefined + addPlaceholder?: string }) => { - const { choices, selected, onChange, className } = props + const { choices, selected, onChange, className, addOption, addPlaceholder } = props + + // Keep a local merged copy to allow optimistic adds while remaining in sync with props + const [localChoices, setLocalChoices] = useState<{ [key: string]: string }>(choices) + useEffect(() => { + setLocalChoices((prev) => { + // If incoming choices changed, merge them with any locally added that still don't collide + // Props should be source of truth on conflicts + return { ...prev, ...choices } + }) + }, [choices]) + + const entries = useMemo(() => Object.entries(localChoices), [localChoices]) + + // Add-new option state + const [newLabel, setNewLabel] = useState('') + const [adding, setAdding] = useState(false) + const [error, setError] = useState(null) + + // Filter visible options while typing a new option (case-insensitive label match) + const filteredEntries = useMemo(() => { + if (!addOption) return entries + const q = newLabel.trim().toLowerCase() + if (!q) return entries + return entries.filter(([key]) => key.toLowerCase().includes(q)) + }, [addOption, entries, newLabel]) + + const submitAdd = async () => { + if (!addOption) return + const label = newLabel.trim() + setError(null) + if (!label) { + setError('Please enter a value.') + return + } + // prevent duplicate by label or by value already selected + const lowerCaseChoices = Object.keys(localChoices).map((k: string) => k.toLowerCase()) + if (lowerCaseChoices.includes(label.toLowerCase())) { + setError('That option already exists.') + // const key = Object.keys(lowerCaseChoices).find((k) => k.toLowerCase() === label.toLowerCase()) + // if (!key) return + // setProfile('interests', [...(profile['interests'] ?? []), key]) + return + } + setAdding(true) + try { + const result = addOption(label) + if (!result) { + setError('Could not add option.') + setAdding(false) + return + } + const { key, value } = typeof result === 'string' ? { key: label, value: result } : result + setLocalChoices((prev) => ({ ...prev, [key]: value })) + // auto-select newly added option if not already selected + if (!selected.includes(value)) onChange([...selected, value]) + setNewLabel('') + } catch (e) { + setError('Failed to add option.') + } finally { + setAdding(false) + } + } + return ( - - {Object.entries(choices).map(([key, value]) => ( - { - if (checked) { - onChange([...selected, value]) - } else { - onChange(selected.filter((s) => s !== value)) - } - }} - /> - ))} - +
+ {addOption && ( + + { + setNewLabel(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submitAdd() + } + }} + className="h-10" + /> + + {error && {error}} + + )} + + + {filteredEntries.map(([key, value]) => ( + { + if (checked) { + onChange([...selected, value]) + } else { + onChange(selected.filter((s) => s !== value)) + } + }} + /> + ))} + + {addOption && newLabel.trim() && filteredEntries.length === 0 && ( +
No matching options, feel free to add it.
+ )} +
) -} +} \ No newline at end of file diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index 8c251c46..15b3bcad 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -1,4 +1,4 @@ -import {Fragment, useRef, useState} from 'react' +import {Fragment, useEffect, useRef, useState} from 'react' import {Title} from 'web/components/widgets/title' import {Col} from 'web/components/layout/col' import clsx from 'clsx' @@ -9,13 +9,12 @@ import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group' import {Button, IconButton} from 'web/components/buttons/button' import {colClassName, labelClassName} from 'web/pages/signup' import {useRouter} from 'next/router' -import {updateProfile, updateUser} from 'web/lib/api' -import {Column} from 'common/supabase/utils' +import {api, updateProfile, updateUser} from 'web/lib/api' import {User} from 'common/user' import {track} from 'web/lib/service/analytics' import {Carousel} from 'web/components/widgets/carousel' import {tryCatch} from 'common/util/try-catch' -import {ProfileRow} from 'common/profiles/profile' +import {ProfileWithoutUser} from 'common/profiles/profile' import {removeUndefinedProps} from 'common/util/object' import {isEqual, range} from 'lodash' import {PlatformSelect} from 'web/components/widgets/platform-select' @@ -32,7 +31,7 @@ import { EDUCATION_CHOICES, LANGUAGE_CHOICES, MBTI_CHOICES, - POLITICAL_CHOICES, + POLITICAL_CHOICES, RACE_CHOICES, RELATIONSHIP_CHOICES, RELATIONSHIP_STATUS_CHOICES, @@ -40,10 +39,14 @@ import { ROMANTIC_CHOICES } from "web/components/filters/choices"; import toast from "react-hot-toast"; +import {db} from "web/lib/supabase/db"; +import {fetchChoices} from "web/hooks/use-choices"; +import {AddOptionEntry} from "web/components/add-option-entry"; + export const OptionalProfileUserForm = (props: { - profile: ProfileRow - setProfile: >(key: K, value: ProfileRow[K]) => void + profile: ProfileWithoutUser + setProfile: (key: K, value: ProfileWithoutUser[K]) => void user: User buttonLabel?: string fromSignup?: boolean @@ -71,15 +74,44 @@ export const OptionalProfileUserForm = (props: { const [newLinkPlatform, setNewLinkPlatform] = useState('') const [newLinkValue, setNewLinkValue] = useState('') + const [interestChoices, setInterestChoices] = useState({}) + const [causeChoices, setCauseChoices] = useState({}) + const [workChoices, setWorkChoices] = useState({}) + + useEffect(() => { + fetchChoices('interests').then(setInterestChoices) + fetchChoices('causes').then(setCauseChoices) + fetchChoices('work').then(setWorkChoices) + }, [db]) const handleSubmit = async () => { setIsSubmitting(true) - const {bio: _bio, bio_text: _bio_text, bio_tsv: _bio_tsv, bio_length: _bio_length, ...otherProfileProps} = profile + const { + bio: _bio, + bio_text: _bio_text, + bio_tsv: _bio_tsv, + bio_length: _bio_length, + interests, + causes, + work, + ...otherProfileProps + } = profile console.debug('otherProfileProps', removeUndefinedProps(otherProfileProps)) - const {error} = await tryCatch( - updateProfile(removeUndefinedProps(otherProfileProps) as any) - ) - if (error) { + const promises: Promise[] = [ + tryCatch(updateProfile(removeUndefinedProps(otherProfileProps) as any)) + ] + if (interests?.length) { + promises.push(api('update-options', {table: 'interests', names: interests})) + } + if (causes?.length) { + promises.push(api('update-options', {table: 'causes', names: causes})) + } + if (work?.length) { + promises.push(api('update-options', {table: 'work', names: work})) + } + try { + await Promise.all(promises) + } catch (error) { console.error(error) toast.error( `We ran into an issue saving your profile. Please try again or contact us if the issue persists.` @@ -94,7 +126,6 @@ export const OptionalProfileUserForm = (props: { return } } - onSubmit && (await onSubmit()) setIsSubmitting(false) track('submit optional profile') @@ -441,6 +472,22 @@ export const OptionalProfileUserForm = (props: { /> + + + + + + + + + setProfile('company', e.target.value)} + className={'w-52'} + value={profile['company'] ?? undefined} + /> + + + + + setProfile('occupation_title', e.target.value)} + className={'w-52'} + value={profile['occupation_title'] ?? undefined} + /> + + + + + + setProfile('education_level', c)} + /> + + + + + + setProfile('university', e.target.value)} + className={'w-52'} + value={profile['university'] ?? undefined} + /> + + - - - - setProfile('education_level', c)} - /> - - - - - setProfile('university', e.target.value)} - className={'w-52'} - value={profile['university'] ?? undefined} - /> - - - - setProfile('company', e.target.value)} - className={'w-52'} - value={profile['company'] ?? undefined} - /> - - - - - setProfile('occupation_title', e.target.value)} - className={'w-52'} - value={profile['occupation_title'] ?? undefined} - /> - - {/**/} {/* */} {/* } - text={profile.languages?.map(v => INVERTED_LANGUAGE_CHOICES[v])} + icon={} + text={profile.work} /> } @@ -98,6 +99,14 @@ export default function ProfileAbout(props: { text={profile.religion?.map(belief => INVERTED_RELIGION_CHOICES[belief])} suffix={profile.religious_beliefs} /> + } + text={profile.interests} + /> + } + text={profile.causes} + /> } text={profile.mbti ? INVERTED_MBTI_CHOICES[profile.mbti] : null} @@ -114,6 +123,10 @@ export default function ProfileAbout(props: { icon={} text={profile.diet?.map(e => INVERTED_DIET_CHOICES[e])} /> + } + text={profile.languages?.map(v => INVERTED_LANGUAGE_CHOICES[v])} + /> {!isCurrentUser && } diff --git a/web/components/required-profile-form.tsx b/web/components/required-profile-form.tsx index b3376b0d..02d19df9 100644 --- a/web/components/required-profile-form.tsx +++ b/web/components/required-profile-form.tsx @@ -9,8 +9,7 @@ import {labelClassName} from 'web/pages/signup' import {User} from 'common/user' import {useEditableUserInfo} from 'web/hooks/use-editable-user-info' import {LoadingIndicator} from 'web/components/widgets/loading-indicator' -import {Column} from 'common/supabase/utils' -import {ProfileRow} from 'common/profiles/profile' +import {ProfileRow, ProfileWithoutUser} from 'common/profiles/profile' import {SignupBio} from "web/components/bio/editable-bio"; import {Editor} from "@tiptap/core"; @@ -42,7 +41,7 @@ export const RequiredProfileUserForm = (props: { setEditUsername?: (name: string) => unknown setEditDisplayName?: (name: string) => unknown profile: ProfileRow - setProfile: >(key: K, value: ProfileRow[K] | undefined) => void + setProfile: (key: K, value: ProfileWithoutUser[K] | undefined) => void isSubmitting: boolean onSubmit?: () => void profileCreatedAlready?: boolean diff --git a/web/hooks/use-choices.ts b/web/hooks/use-choices.ts new file mode 100644 index 00000000..aed3392c --- /dev/null +++ b/web/hooks/use-choices.ts @@ -0,0 +1,32 @@ +import {useEffect} from 'react' +import {run} from 'common/supabase/utils' +import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' +import {db} from 'web/lib/supabase/db' + +export async function fetchChoices(label: string) { + const {data} = await run(db.from(label).select('name').order('name')) + console.log('Fetched choices:', data) + const results = Object.fromEntries(data.map((row: { name: string }) => [row.name, row.name])) + return results; +} + +export const useChoices = (label: string) => { + const [choices, setChoices] = usePersistentInMemoryState({}, `${label}-choices`) + + const refreshChoices = async () => { + try { + const results = await fetchChoices(label) + setChoices(results) + } catch (err) { + console.error('Error fetching choices:', err) + return {} + } + } + + useEffect(() => { + console.log('Fetching choices in use effect...') + refreshChoices() + }, []) + + return {choices, refreshChoices} +} diff --git a/web/hooks/use-profile.ts b/web/hooks/use-profile.ts index 9909ad8c..933ed266 100644 --- a/web/hooks/use-profile.ts +++ b/web/hooks/use-profile.ts @@ -3,10 +3,9 @@ import {useEffect} from 'react' import {Row} from 'common/supabase/utils' import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' import {User} from 'common/user' -import {getProfileRow, Profile, ProfileRow} from 'common/profiles/profile' +import {getProfileRow, Profile, ProfileWithoutUser} from 'common/profiles/profile' import {db} from 'web/lib/supabase/db' import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state' -import {logger} from "common/logging"; export const useProfile = () => { const user = useUser() @@ -63,7 +62,7 @@ export const useProfileByUser = (user: User | undefined) => { export const useProfileByUserId = (userId: string | undefined) => { const [profile, setProfile] = usePersistentInMemoryState< - ProfileRow | undefined | null + ProfileWithoutUser | undefined | null >(undefined, `profile-${userId}`) useEffect(() => { diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index b7a6cdcf..79b98efd 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,5 +1,4 @@ -import {Profile, ProfileRow} from 'common/profiles/profile' -import {Column} from 'common/supabase/utils' +import {Profile, ProfileWithoutUser} from 'common/profiles/profile' import {User} from 'common/user' import {OptionalProfileUserForm} from 'web/components/optional-profile-form' import {RequiredProfileUserForm} from 'web/components/required-profile-form' @@ -33,7 +32,7 @@ function ProfilePageInner(props: { user: User; profile: Profile }) { user, }) - const setProfileState = >(key: K, value: ProfileRow[K] | undefined) => { + const setProfileState = (key: K, value: ProfileWithoutUser[K] | undefined) => { setProfile((prevState) => ({...prevState, [key]: value})) } diff --git a/web/pages/signup.tsx b/web/pages/signup.tsx index 4dacbf30..5d4eaa14 100644 --- a/web/pages/signup.tsx +++ b/web/pages/signup.tsx @@ -12,7 +12,7 @@ import {track} from 'web/lib/service/analytics' import {safeLocalStorage} from 'web/lib/util/local' import {removeNullOrUndefinedProps} from 'common/util/object' import {useProfileByUserId} from 'web/hooks/use-profile' -import {ProfileRow} from 'common/profiles/profile' +import {ProfileWithoutUser} from 'common/profiles/profile' import {PageBase} from "web/components/page-base"; import {SEO} from "web/components/SEO"; @@ -54,10 +54,10 @@ export default function SignupPage() { }, [user, holdLoading]) // Omit the id, created_time? - const [profileForm, setProfileForm] = useState({ + const [profileForm, setProfileForm] = useState({ ...initialRequiredState, } as any) - const setProfileState = (key: keyof ProfileRow, value: any) => { + const setProfileState = (key: keyof ProfileWithoutUser, value: any) => { setProfileForm((prevState) => ({...prevState, [key]: value})) }