Skip to content

Commit c3c8ee3

Browse files
committed
Add feed visibility settings to profiles: enable per-profile syndication control and implement public feed projection logic.
1 parent ecf2647 commit c3c8ee3

22 files changed

Lines changed: 761 additions & 7 deletions

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ Frontend: [`docs/next-js.md`](docs/next-js.md), [`docs/react.md`](docs/react.md)
111111
Database: [`docs/database-schema.md`](docs/database-schema.md),
112112
[`docs/database-connection-pooling.md`](docs/database-connection-pooling.md),
113113
[`docs/performance-optimization.md`](docs/performance-optimization.md).
114-
Cross-cutting: [`docs/internationalization.md`](docs/internationalization.md),
114+
Cross-cutting: [`docs/feed.md`](docs/feed.md),
115+
[`docs/internationalization.md`](docs/internationalization.md),
115116
[`docs/profile-fields.md`](docs/profile-fields.md), [`docs/testing.md`](docs/testing.md),
116117
[`docs/logging-monitoring.md`](docs/logging-monitoring.md),
117118
[`docs/troubleshooting.md`](docs/troubleshooting.md).

backend/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@compass/api",
3-
"version": "1.60.3",
3+
"version": "1.61.0",
44
"private": true,
55
"description": "Backend API endpoints",
66
"main": "src/serve.ts",

backend/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {getMyReferrals} from './get-my-referrals'
7474
import {getNotifications} from './get-notifications'
7575
import {getOutreachQueue} from './get-outreach-queue'
7676
import {getProfileAnswers} from './get-profile-answers'
77+
import {getProfileFeed} from './get-profile-feed'
7778
import {getProfiles} from './get-profiles'
7879
import {getSearchAlert} from './get-search-alert'
7980
import {getSupabaseToken} from './get-supabase-token'
@@ -640,6 +641,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
640641
'create-testimonial': createTestimonial,
641642
'update-testimonial-status': updateTestimonialStatus,
642643
'get-profile-answers': getProfileAnswers,
644+
'get-profile-feed': getProfileFeed,
643645
'get-profiles': getProfiles,
644646
'get-supabase-token': getSupabaseToken,
645647
'get-user-journeys': getUserJourneys,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {APIHandler} from 'api/helpers/endpoint'
2+
import {
3+
DEFAULT_FEED_LIMIT,
4+
FeedItem,
5+
FeedVisibility,
6+
MAX_FEED_BIO_CHARS,
7+
truncateAtWord,
8+
} from 'common/feed/feed'
9+
import {compact} from 'lodash'
10+
import {createSupabaseDirectClient} from 'shared/supabase/init'
11+
12+
type FeedQueryRow = {
13+
username: string
14+
name: string
15+
created_time: string
16+
headline: string | null
17+
city: string | null
18+
country: string | null
19+
gender: string | null
20+
keywords: string[] | null
21+
bio_text: string | null
22+
feed_visibility: FeedVisibility
23+
}
24+
25+
/**
26+
* Newest public profiles, for the RSS feed at /feed.xml (and, later, an ActivityPub outbox built on the
27+
* same rows).
28+
*
29+
* The projection happens here rather than in the renderer on purpose: the response for a `basic` member
30+
* simply does not contain their bio or keywords, so no downstream consumer can leak a field by
31+
* forgetting to check the level.
32+
*/
33+
export const getProfileFeed: APIHandler<'get-profile-feed'> = async ({country, limit}) => {
34+
const pg = createSupabaseDirectClient()
35+
36+
const rows = await pg.any<FeedQueryRow>(
37+
`select users.username,
38+
users.name,
39+
profiles.created_time,
40+
profiles.headline,
41+
profiles.city,
42+
profiles.country,
43+
profiles.gender,
44+
profiles.keywords,
45+
profiles.bio_text,
46+
profiles.feed_visibility
47+
from profiles
48+
join users on users.id = profiles.user_id
49+
where profiles.visibility = 'public'
50+
and profiles.feed_visibility <> 'none'
51+
and profiles.disabled != true
52+
and profiles.looking_for_matches = true
53+
and not users.is_banned_from_posting
54+
and (users.data ->> 'userDeleted' is null or users.data ->> 'userDeleted' != 'true')
55+
and ($(country) is null or lower(profiles.country) = lower($(country)))
56+
order by profiles.created_time desc
57+
limit $(limit)`,
58+
{country: country ?? null, limit: limit ?? DEFAULT_FEED_LIMIT},
59+
)
60+
61+
return {items: rows.map(toFeedItem)}
62+
}
63+
64+
function toFeedItem(row: FeedQueryRow): FeedItem {
65+
const location = compact([row.city, row.country]).join(', ')
66+
67+
const item: FeedItem = {
68+
username: row.username,
69+
name: row.name,
70+
createdTime: new Date(row.created_time).toISOString(),
71+
headline: row.headline ?? undefined,
72+
location: location || undefined,
73+
keywords: row.keywords?.length ? row.keywords : undefined,
74+
}
75+
76+
if (row.feed_visibility !== 'full') return item
77+
78+
return {
79+
...item,
80+
gender: row.gender ?? undefined,
81+
// `bio_text` is the plain-text projection of the rich-text `bio`, maintained by
82+
// trg_profiles_rebuild_search — so the excerpt never carries markup into the feed.
83+
bioExcerpt: row.bio_text ? truncateAtWord(row.bio_text, MAX_FEED_BIO_CHARS) : undefined,
84+
}
85+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
jest.mock('shared/supabase/init')
2+
3+
import {getProfileFeed} from 'api/get-profile-feed'
4+
import {FeedItem, MAX_FEED_BIO_CHARS} from 'common/feed/feed'
5+
import * as supabaseInit from 'shared/supabase/init'
6+
7+
const row = (overrides: Record<string, any> = {}) => ({
8+
username: 'martin',
9+
name: 'Martin',
10+
created_time: '2026-08-05T09:54:00.000Z',
11+
headline: 'Lazy Hacker',
12+
city: 'Rome',
13+
country: 'Italy',
14+
gender: 'male',
15+
keywords: ['photography', 'poetry'],
16+
bio_text: 'Describing is always a tedious task.',
17+
feed_visibility: 'basic',
18+
...overrides,
19+
})
20+
21+
describe('getProfileFeed', () => {
22+
let mockPg: any
23+
24+
beforeEach(() => {
25+
jest.resetAllMocks()
26+
mockPg = {any: jest.fn().mockResolvedValue([])}
27+
;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
28+
})
29+
30+
// Handlers are typed as "the response, or a continuation" — this one never continues.
31+
const call = async (props: any = {}) =>
32+
(await getProfileFeed(props, undefined as any, {} as any)) as {items: FeedItem[]}
33+
34+
describe('the query', () => {
35+
it('only ever reads public, syndicatable, listable profiles', async () => {
36+
await call()
37+
const [query] = mockPg.any.mock.calls[0]
38+
39+
expect(query).toContain("profiles.visibility = 'public'")
40+
expect(query).toContain("profiles.feed_visibility <> 'none'")
41+
expect(query).toContain('profiles.disabled != true')
42+
expect(query).toContain('profiles.looking_for_matches = true')
43+
expect(query).toContain('not users.is_banned_from_posting')
44+
expect(query).toContain("users.data ->> 'userDeleted'")
45+
})
46+
47+
it('never selects photos, whatever the level', async () => {
48+
await call()
49+
const [query] = mockPg.any.mock.calls[0]
50+
expect(query).not.toContain('photo_urls')
51+
expect(query).not.toContain('pinned_url')
52+
})
53+
54+
it('filters by country case-insensitively, and not at all when none is given', async () => {
55+
await call({country: 'italy'})
56+
expect(mockPg.any.mock.calls[0][0]).toContain('lower(profiles.country) = lower($(country))')
57+
expect(mockPg.any.mock.calls[0][1].country).toBe('italy')
58+
59+
await call()
60+
expect(mockPg.any.mock.calls[1][1].country).toBeNull()
61+
})
62+
63+
it('parameterises the country instead of concatenating it', async () => {
64+
await call({country: "Italy'; drop table profiles; --"})
65+
const [query, params] = mockPg.any.mock.calls[0]
66+
expect(query).not.toContain('drop table')
67+
expect(params.country).toBe("Italy'; drop table profiles; --")
68+
})
69+
})
70+
71+
describe('the projection', () => {
72+
it('sends only name, location, headline, keywords and link for a basic member', async () => {
73+
mockPg.any.mockResolvedValue([row()])
74+
const {items} = await call()
75+
76+
expect(items[0]).toEqual({
77+
username: 'martin',
78+
name: 'Martin',
79+
createdTime: '2026-08-05T09:54:00.000Z',
80+
headline: 'Lazy Hacker',
81+
location: 'Rome, Italy',
82+
keywords: ['photography', 'poetry'],
83+
})
84+
expect(items[0].bioExcerpt).toBeUndefined()
85+
expect(items[0].gender).toBeUndefined()
86+
})
87+
88+
it('omits keywords rather than sending an empty list', async () => {
89+
mockPg.any.mockResolvedValue([row({keywords: []})])
90+
const {items} = await call()
91+
expect(items[0].keywords).toBeUndefined()
92+
})
93+
94+
it('adds gender and a bio excerpt for a full member', async () => {
95+
mockPg.any.mockResolvedValue([row({feed_visibility: 'full'})])
96+
const {items} = await call()
97+
98+
expect(items[0].gender).toBe('male')
99+
expect(items[0].keywords).toEqual(['photography', 'poetry'])
100+
expect(items[0].bioExcerpt).toBe('Describing is always a tedious task.')
101+
})
102+
103+
it('truncates a long bio rather than republishing the whole thing', async () => {
104+
mockPg.any.mockResolvedValue([row({feed_visibility: 'full', bio_text: 'word '.repeat(500)})])
105+
const {items} = await call()
106+
107+
expect(items[0].bioExcerpt!.length).toBeLessThanOrEqual(MAX_FEED_BIO_CHARS + 1)
108+
expect(items[0].bioExcerpt!.endsWith('…')).toBe(true)
109+
})
110+
111+
it('falls back to the country alone when no city is set', async () => {
112+
mockPg.any.mockResolvedValue([row({city: null})])
113+
const {items} = await call()
114+
expect(items[0].location).toBe('Italy')
115+
})
116+
117+
it('omits location entirely when neither city nor country is set', async () => {
118+
mockPg.any.mockResolvedValue([row({city: null, country: null})])
119+
const {items} = await call()
120+
expect(items[0].location).toBeUndefined()
121+
})
122+
})
123+
})

backend/supabase/migration.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,5 @@ BEGIN;
6666
\i backend/supabase/migrations/20260803_add_testimonials.sql
6767
\i backend/supabase/migrations/20260803_add_outreach_sends.sql
6868
\i backend/supabase/migrations/20260804_add_search_alert_sends.sql
69+
\i backend/supabase/migrations/20260806_add_feed_visibility_to_profiles.sql
6970
COMMIT;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
-- Add per-profile control over how much goes into the public feed
2+
-- Created: 2026-08-06
3+
--
4+
-- `visibility` already answers "who can see my profile". This answers the separate question "how much of
5+
-- it may be republished off-site" — today the RSS feed at /feed.xml, later an ActivityPub actor built on
6+
-- the same projection. The two are deliberately distinct settings: syndication is un-retractable in a way
7+
-- that a web page is not (a fediverse post that has been federated cannot be recalled), so opting into a
8+
-- public profile must not silently opt you into being broadcast.
9+
--
10+
-- 'none' — never appears in the feed
11+
-- 'basic' — name, city, headline, keywords, link (the default)
12+
-- 'full' — the above plus gender and a short bio excerpt
13+
--
14+
-- Default 'basic' rather than 'none': the fields it carries are exactly what a public profile already
15+
-- shows to any crawler, so the default changes distribution, not exposure. `feed_visibility` only ever
16+
-- narrows — a members-only profile is excluded from the feed whatever this column says.
17+
18+
DO
19+
$$
20+
BEGIN
21+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'profile_feed_visibility') THEN
22+
CREATE TYPE profile_feed_visibility AS ENUM ('none', 'basic', 'full');
23+
END IF;
24+
END
25+
$$;
26+
27+
ALTER TABLE profiles
28+
ADD COLUMN IF NOT EXISTS feed_visibility profile_feed_visibility
29+
DEFAULT 'basic'::profile_feed_visibility NOT NULL;
30+
31+
-- Backfill by the same rule the app applies when someone switches to members-only
32+
-- (feedVisibilityForMembersOnly in common/src/feed/feed.ts): a members-only profile is already a
33+
-- request not to be on the open web, so the column default must not opt those members into
34+
-- syndication behind their backs. Guarded on 'basic' so re-running never overwrites a level someone
35+
-- has since chosen deliberately.
36+
UPDATE profiles
37+
SET feed_visibility = 'none'::profile_feed_visibility
38+
WHERE visibility = 'member'
39+
AND feed_visibility = 'basic'::profile_feed_visibility;
40+
41+
-- The feed query is "newest public, syndicatable, listable profiles first", so index exactly that.
42+
CREATE INDEX IF NOT EXISTS profiles_feed_idx
43+
ON profiles (created_time DESC)
44+
WHERE visibility = 'public'
45+
AND feed_visibility <> 'none'
46+
AND disabled = false
47+
AND looking_for_matches = true;

common/messages/de.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,9 @@
939939
"profile.education.short_name": "Bildung",
940940
"profile.education.some-college": "Hochschulbildung",
941941
"profile.ethnicity": "Ethnische Herkunft",
942+
"profile.feed_visibility.none": "Gar nichts",
943+
"profile.feed_visibility.basic": "Name, Stadt, Überschrift und Schlüsselwörter",
944+
"profile.feed_visibility.full": "Alles",
942945
"profile.gallery.show_photo": "Foto {number} anzeigen",
943946
"profile.gender": "Geschlecht",
944947
"profile.gender.agender": "Agender",
@@ -1214,6 +1217,8 @@
12141217
"profile.optional.error.invalid_fields": "Einige Felder sind nicht korrekt...",
12151218
"profile.optional.ethnicity": "Ethnische Herkunft",
12161219
"profile.optional.feet": "Fuß",
1220+
"profile.optional.feed_visibility_hint": "Compass veröffentlicht einen Feed neuer öffentlicher Profile, dem jede und jeder aus einem Feed-Reader oder dem Fediverse folgen kann. Wähle, wie viel von deinem Profil hineingeht. Fotos enthält der Feed in keinem Fall.",
1221+
"profile.optional.feed_visibility_members_only": "Der Feed enthält nur öffentliche Profile — solange dein Profil nur für Mitglieder sichtbar ist, wird dort nichts von dir veröffentlicht.",
12171222
"profile.optional.gender": "Geschlecht",
12181223
"profile.optional.headline": "Überschrift",
12191224
"profile.optional.headline_description": "Was auf deiner Profilkarte erscheint, wenn andere sie ansehen.",

common/messages/fr.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,9 @@
938938
"profile.education.short_name": "Education",
939939
"profile.education.some-college": "Supérieur",
940940
"profile.ethnicity": "Origine ethnique",
941+
"profile.feed_visibility.none": "Rien du tout",
942+
"profile.feed_visibility.basic": "Nom, ville, titre et mots-clés",
943+
"profile.feed_visibility.full": "Tout",
941944
"profile.gallery.show_photo": "Afficher la photo {number}",
942945
"profile.gender": "Genre",
943946
"profile.gender.agender": "Agenre",
@@ -1213,6 +1216,8 @@
12131216
"profile.optional.error.invalid_fields": "Certains champs sont incorrects...",
12141217
"profile.optional.ethnicity": "Origine ethnique",
12151218
"profile.optional.feet": "Pieds",
1219+
"profile.optional.feed_visibility_hint": "Compass publie un flux des nouveaux profils publics, que chacun peut suivre depuis un lecteur de flux ou le fédiverse. Choisissez la quantité d'informations qui y figure. Quel que soit votre choix, le flux ne contient jamais vos photos.",
1220+
"profile.optional.feed_visibility_members_only": "Le flux ne contient que les profils publics : tant que votre profil est réservé aux membres, rien n'y est publié.",
12161221
"profile.optional.gender": "Genre",
12171222
"profile.optional.headline": "Titre",
12181223
"profile.optional.headline_description": "Ce qui apparaîtra sur votre carte de profil lorsque d'autres personnes la consulteront.",

common/src/api/schema.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
zBoolean,
88
} from 'common/api/zod-types'
99
import {ChatMessage} from 'common/chat-message'
10+
import {FeedItem, MAX_FEED_LIMIT} from 'common/feed/feed'
1011
import {BAN_REASONS} from 'common/moderation/ban'
1112
import {Notification} from 'common/notifications'
1213
import {MAX_NEXT_ACTION_LENGTH, OUTREACH_STAGES, OutreachRow} from 'common/outreach/outreach'
@@ -1455,6 +1456,27 @@ export const API = (_apiTypeCheck = {
14551456
'Save a search on a member’s behalf, built from the preferences already on their profile. Admin only.',
14561457
tag: 'Admin',
14571458
},
1459+
'get-profile-feed': {
1460+
method: 'GET',
1461+
authed: false,
1462+
rateLimited: true,
1463+
props: z
1464+
.object({
1465+
// Country *name* as stored on `profiles.country` ("Italy"), matched case-insensitively — there
1466+
// is no country-code column. Per-country feeds matter more than one global one here: the
1467+
// bottleneck is local density, and a scattered worldwide firehose reads as growth while every
1468+
// city stays as empty as it was.
1469+
country: z.string().min(1).optional(),
1470+
limit: z.coerce.number().int().min(1).max(MAX_FEED_LIMIT).optional(),
1471+
})
1472+
.strict(),
1473+
returns: {} as {items: FeedItem[]},
1474+
// Rendered into RSS by web's /feed.xml, which no one polls more than a few times an hour.
1475+
cache: 'public, max-age=600, stale-while-revalidate=3600',
1476+
summary:
1477+
'Newest public profiles that allow syndication, projected down to each member’s feed_visibility level.',
1478+
tag: 'Profiles',
1479+
},
14581480
'get-testimonials': {
14591481
method: 'GET',
14601482
authed: false,

0 commit comments

Comments
 (0)