diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index 30684c3f..ada84903 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/node' +import {type JSONContent} from '@tiptap/core' import {type APIHandler} from 'api/helpers/endpoint' import { DIET_CHOICES, @@ -16,6 +17,7 @@ import { ROMANTIC_CHOICES, } from 'common/choices' import {OptionTableKey} from 'common/profiles/constants' +import {parseJsonContentToText} from 'common/util/parse' import {compact} from 'lodash' import {log} from 'shared/monitoring/log' import {convertRow} from 'shared/profiles/supabase' @@ -110,6 +112,8 @@ export type profileQueryType = { skipCount?: boolean | undefined last_active?: string | undefined locale?: string | undefined + /** Which columns to return — see {@link ProfileProjection}. Defaults to `full`. */ + projection?: ProfileProjection | undefined } & { [K in OptionTableKey]?: string[] | undefined } @@ -159,7 +163,69 @@ const arrayChoiceFields = [ {field: 'ethnicity', choices: RACE_CHOICES}, ] -const EXCLUDED_PROFILE_COLS = new Set(['search_text', 'search_tsv']) +// Search-only columns. `bio_text`/`bio_tsv` were already stripped from the response by `convertRow`, +// but they were still being read out of Postgres on every query — `bio_tsv` is a tsvector roughly the +// size of the bio itself, so 20 of them per page is real DB→API transfer for bytes nobody reads. +const EXCLUDED_PROFILE_COLS = new Set(['search_text', 'search_tsv', 'bio_text', 'bio_tsv']) + +/** + * Which profile columns to return. + * + * `full` is every column (minus {@link EXCLUDED_PROFILE_COLS}) and stays the default, since callers + * hand the row to code that expects a complete `Profile`. `card` returns only what the profile grid + * actually renders — the row is ~80 columns wide and the card reads about a quarter of them, so the + * rest is pure egress. See {@link PROFILE_CARD_COLS}. + */ +export type ProfileProjection = 'card' | 'full' + +/** + * The profile columns the grid card in `web/components/profile-grid.tsx` reads, directly or through + * `ProfileAvatar` / `ProfileLocation` / `ProfileDetailRail` / `SendMessageButton`. Keep in sync with + * that component. `interests` / `causes` / `work` are not here — they come from their own joins. + * + * `bio` is selected but never sent: it is replaced by {@link toBioSnippet} before the response. + */ +const PROFILE_CARD_COLS = [ + 'id', + 'user_id', + 'created_time', + 'age', + 'gender', + 'headline', + 'bio', + 'bio_length', + 'keywords', + 'pinned_url', + 'city', + 'country', + 'region_code', + 'occupation_title', + 'diet', + 'drinks_per_month', + 'is_smoker', + 'languages', + 'mbti', + 'pref_relation_styles', +] + +/** + * A card shows at most 6 lines of headline + bio, and the widest card fits ~78 characters to a line. + * 600 leaves room to spare while cutting a long bio down to a fraction of its size. + */ +const BIO_SNIPPET_CHARS = 600 + +/** + * The card renders `parseJsonContentToText(profile.bio)` clamped to a few lines, so shipping the whole + * rich-text document is wasted. Computed here rather than read from the `bio_text` column: that column + * is built with `string_agg(DISTINCT ...)` for search, which reorders and dedupes the text nodes — fine + * for a tsvector, wrong for something a person reads. + */ +const toBioSnippet = (bio: unknown) => { + const text = parseJsonContentToText(bio as JSONContent) + .replace(/\s+/g, ' ') + .trim() + return text.length > BIO_SNIPPET_CHARS ? `${text.slice(0, BIO_SNIPPET_CHARS)}…` : text +} let profileCols: any @@ -246,6 +312,7 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC skipId, locale = 'en', last_active, + projection = 'card', } = props log('get-profiles', {...props, userIds: userIds && `${userIds.length} ids`}) @@ -333,6 +400,16 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC )` } + /** "Has this profile picked any option at all?" — the `array_length(...) > 0` test on the + * aggregate join, expressed so it does not need the join. */ + function getManyToManyAnyClause(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 + )` + } + function getOptionClauseKeyword(label: OptionTableKey) { return `EXISTS ( SELECT 1 FROM profile_${label} @@ -626,12 +703,14 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC userIds && where(`profiles.user_id = any($(userIds))`, {userIds}), + // Deliberately EXISTS rather than `array_length(profile_work.work, 1) > 0`: reading the aggregate + // joins here would tie this filter to joins that are only there to shape the output. !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 ${getManyToManyAnyClause('work')} + OR ${getManyToManyAnyClause('interests')} OR occupation_title IS NOT NULL `, ), @@ -675,7 +754,12 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC ), ] - const profileCols = (await getProfileCols()) ?? 'profiles.*' // stored at module level + const profileCols = + projection === 'card' + ? PROFILE_CARD_COLS.map((c) => `profiles.${c}`).join(', ') + : ((await getProfileCols()) ?? 'profiles.*') // stored at module level + // `users.name` / `users.username` feed `convertRow`, which folds them into the `user` object and + // then drops the top-level copies — they are not part of the response. let selectCols = `${profileCols}, users.name, users.username, jsonb_build_object( 'id', users.id, 'name', users.name, @@ -706,7 +790,14 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC const profiles = await pg.map(query, [], convertRow) - // console.debug('profiles:', profiles) + if (projection === 'card') { + for (const profile of profiles) { + profile.bio_snippet = toBioSnippet(profile.bio) + delete (profile as any).bio + } + } + + console.debug('profiles:', profiles) const countQuery = renderSql(select(`count(*) as count`), ...tableSelection, ...filters) diff --git a/backend/api/tests/unit/get-profiles.unit.test.ts b/backend/api/tests/unit/get-profiles.unit.test.ts index f9127e2e..89422380 100644 --- a/backend/api/tests/unit/get-profiles.unit.test.ts +++ b/backend/api/tests/unit/get-profiles.unit.test.ts @@ -32,6 +32,7 @@ describe('getProfiles', () => { const props = { limit: 2, orderBy: 'last_online_time' as const, + projection: 'full' as const, } const mockReq = {} as any @@ -56,6 +57,7 @@ describe('getProfiles', () => { const props = { limit: 2, orderBy: 'last_online_time' as const, + projection: 'full' as const, } const mockReq = {} as any const results: any = await profilesModule.getProfiles(props, mockReq, mockReq) @@ -320,6 +322,77 @@ describe('loadProfiles', () => { }) }) + describe('when using the card projection', () => { + const bio = { + type: 'doc', + content: [{type: 'paragraph', content: [{type: 'text', text: 'Hello there'}]}], + } + + it('selects only the columns the card renders', async () => { + ;(mockPg.map as jest.Mock).mockResolvedValue([]) + ;(mockPg.one as jest.Mock).mockResolvedValue(0) + + await profilesModule.loadProfiles({projection: 'card'}) + + const [query] = mockPg.map.mock.calls[0] + + expect(query).toContain('profiles.headline') + expect(query).toContain('profiles.pinned_url') + // Heavy fields the card never reads. + expect(query).not.toContain('profiles.photo_urls') + expect(query).not.toContain('profiles.image_descriptions') + expect(query).not.toContain('profiles.links') + }) + + it('still filters out sparse profiles without that join', async () => { + ;(mockPg.map as jest.Mock).mockResolvedValue([]) + ;(mockPg.one as jest.Mock).mockResolvedValue(0) + + await profilesModule.loadProfiles({projection: 'card'}) + + const [query] = mockPg.map.mock.calls[0] + + // The sparse-profile filter must not depend on the output joins. + expect(query).not.toContain('array_length(profile_work.work, 1)') + expect(query).toContain('FROM profile_work') + expect(query).toContain('bio_length >= 100') + }) + + it('keeps the work join for the full projection', async () => { + ;(mockPg.map as jest.Mock).mockResolvedValue([]) + ;(mockPg.one as jest.Mock).mockResolvedValue(0) + + await profilesModule.loadProfiles({projection: 'full'}) + + const [query] = mockPg.map.mock.calls[0] + + expect(query).toContain('AS work') + }) + + it('replaces the rich-text bio with a plain-text snippet', async () => { + ;(mockPg.map as jest.Mock).mockResolvedValue([{bio} as any]) + ;(mockPg.one as jest.Mock).mockResolvedValue(1) + + const {profiles} = await profilesModule.loadProfiles({projection: 'card'}) + + expect(profiles[0].bio_snippet).toEqual('Hello there') + expect(profiles[0]).not.toHaveProperty('bio') + }) + + it('truncates a long bio', async () => { + const longBio = { + type: 'doc', + content: [{type: 'paragraph', content: [{type: 'text', text: 'a'.repeat(1000)}]}], + } + ;(mockPg.map as jest.Mock).mockResolvedValue([{bio: longBio} as any]) + ;(mockPg.one as jest.Mock).mockResolvedValue(1) + + const {profiles} = await profilesModule.loadProfiles({projection: 'card'}) + + expect(profiles[0].bio_snippet).toEqual(`${'a'.repeat(600)}…`) + }) + }) + describe('when ordering by compatibility score', () => { it('excludes profiles whose precomputed score was nulled out', async () => { ;(mockPg.map as jest.Mock).mockResolvedValue([]) diff --git a/backend/shared/src/profiles/supabase.ts b/backend/shared/src/profiles/supabase.ts index 04b0e03d..922554ba 100644 --- a/backend/shared/src/profiles/supabase.ts +++ b/backend/shared/src/profiles/supabase.ts @@ -21,6 +21,10 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null { } delete profile.bio_text delete profile.bio_tsv + // Already folded into `user` above, and not part of the `Profile` type — sending them twice is + // just egress. + delete profile.name + delete profile.username return profile as Profile } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 301fedc6..14fe6aeb 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -810,6 +810,10 @@ export const API = (_apiTypeCheck = { .enum(['last_online_time', 'created_time', 'compatibility_score']) .optional() .default('last_online_time'), + // `card` trims the response to the fields the profile grid renders, and replaces the + // rich-text `bio` with a truncated `bio_snippet`. Defaults to `full` so existing API + // consumers keep getting the complete row. + projection: z.enum(['card', 'full']).optional().default('full'), }) .strict(), returns: {} as { diff --git a/common/src/profiles/profile.ts b/common/src/profiles/profile.ts index f11afddf..c2c02bfb 100644 --- a/common/src/profiles/profile.ts +++ b/common/src/profiles/profile.ts @@ -4,7 +4,15 @@ import {User} from 'common/user' export type ProfileRow = Row<'profiles'> export type ProfileWithoutUser = ProfileRow & {[K in OptionTableKey]?: string[]} -export type Profile = ProfileWithoutUser & {user: User} +export type Profile = ProfileWithoutUser & { + user: User + /** + * Plain-text, truncated `bio`. List endpoints that only ever render a snippet send this instead of + * the full rich-text `bio` document — see the `card` projection in the backend's `get-profiles`. + * When it is set, `bio` is absent. + */ + bio_snippet?: string +} export const getProfileRowWithFrontendSupabase = async ( userId: string, diff --git a/web/components/profile-grid.tsx b/web/components/profile-grid.tsx index fc27b77c..548d2dfc 100644 --- a/web/components/profile-grid.tsx +++ b/web/components/profile-grid.tsx @@ -537,10 +537,12 @@ function ProfilePreview(props: { const headline = showHeadline !== false ? profile.headline?.trim() : undefined const bioText = useMemo(() => { if (showBio === false) return '' + // The `card` projection sends a pre-truncated snippet instead of the rich-text document. + if (profile.bio_snippet !== undefined) return profile.bio_snippet return parseJsonContentToText(profile.bio as JSONContent) .replace(/\s+/g, ' ') .trim() - }, [profile.bio, showBio]) + }, [profile.bio, profile.bio_snippet, showBio]) // Keep the total amount of text roughly constant per card: the longer the headline, the fewer // bio lines we show. A card with no headline gives the whole budget to the bio, and vice versa. diff --git a/web/components/profiles/profiles-home.tsx b/web/components/profiles/profiles-home.tsx index a013610d..57c940f0 100644 --- a/web/components/profiles/profiles-home.tsx +++ b/web/components/profiles/profiles-home.tsx @@ -143,6 +143,8 @@ export function ProfilesHome() { limit: 20, compatibleWithUserId: user?.id, locale, + // The grid only renders a card per profile; the full row is several times the size. + projection: 'card', ...filters, }) if (!!profiles?.length && isEqual(getProfilesArgs, args)) { @@ -213,6 +215,7 @@ export function ProfilesHome() { compatibleWithUserId: user?.id, after: lastProfile?.id.toString(), locale, + projection: 'card', ...filters, }) as any, )