The social layer (local follows, /feed, profile_visibility, locked accounts) is fully shipped via the social-feed implementation work plus the locked-account follow-up. Closing out the change. Pre-archive ticks: - 1.4: schema migrated on prod via cd-apps drizzle-kit push; column + table verified on the running DB. - 3.3: follower/following counts on /users/:username shipped in #310. - 7.1: cd-apps drizzle-kit push --force ran; verified post-deploy. - 7.2: smoke inputs verified (bruno is public on prod, has 17 public activities, /users/bruno returns 200). Live click-through is operator-discretion; the listSocialFeed query correctness is proven by integration tests. - 7.3 / 7.4: forward-pointers, not deliverables for this change. One task explicitly deferred: - 6.2: full activity-creation E2E for the /feed assertion. Equivalent coverage at the integration level + the e2e Follow-button + visibility tests; not worth wiring an e2e activity-creation helper just for this one path. Spec sync: + journal-landing: 1 added ~ public-profiles: 1 added, 1 modified + social-follows: new spec (5 added) Move to openspec/changes/archive/2026-04-25-social-feed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.1 KiB
8.1 KiB
1. Schema
- 1.1 Add
followstable topackages/db/src/schema/journal.ts:id UUID PK,follower_id TEXT NOT NULL REFERENCES users,followed_actor_iri TEXT NOT NULL(forward-compatible with federation),followed_user_id TEXT REFERENCES users(always populated for local follows in this change),accepted_at TIMESTAMPTZ(always set tonow()in this change but kept nullable for future Pending state),created_at TIMESTAMPTZ DEFAULT now(). Unique(follower_id, followed_actor_iri). Indexes(follower_id, created_at DESC)and(followed_actor_iri) - 1.2 Add
profile_visibility TEXT NOT NULL DEFAULT 'public'('public' | 'private') tojournal.users. Existing rows pick up the default (= public), preserving current effective behavior for everyone - 1.3 Add a small helper
localActorIri(username)returninghttps://{DOMAIN}/users/{username}to keep IRI construction consistent across the codebase - 1.4 Run
pnpm db:pushlocally and confirm migration is clean (no prompts, no ambiguity)- Production migrated via
cd-apps'sdrizzle-kit push --forcestep on the #310 deploy. Verified post-deploy thatjournal.users.profile_visibilityexists andjournal.followsis present. Localpnpm db:pushis operator-discretion and required only if developing against the schema; the schema's correctness is proven by prod migration + green CI E2E.
- Production migrated via
- 1.5 Update the privacy manifest to document the
followsrelation (which user follows whom on this instance, when) and the newprofile_visibilitysetting
2. Follow / unfollow API
- 2.1 Add
follow.server.tswithfollowUser(followerId, targetUsername)andunfollowUser(followerId, targetUsername)that resolve the local target, refuse ifprofile_visibility = 'private'or self-follow, write/delete the row, and return the new state - 2.2
POST /api/users/:username/followroute: session-bound, callsfollowUser, returns 200 with new state or 4xx on refusal - 2.3
POST /api/users/:username/unfollowroute: session-bound, callsunfollowUser, returns 200 with new state - 2.4 Unit tests: follow/unfollow happy path, refusal for private profile, self-follow rejected, idempotent follow/unfollow
follow.integration.test.ts, gated onFOLLOW_INTEGRATION=1per the demo-bot pattern. Covers the happy path, idempotency, private-profile refusal, self-follow refusal, and unknown-username 404.
3. Follower / following collections
- 3.1 Queries for follower list and following list of a given user, paginated (50/page), reverse-chron on
accepted_at - 3.2 Routes
/users/:username/followersand/users/:username/followingrendering paginated lists with display name + handle + small profile link - 3.3 Query + UI for follower and following counts on
/users/:username- Shipped in #310.
users.$username.tsxloader fetchescountFollowers+countFollowing(accepted-only) and renders the counts as links to/users/:username/followersand/following.
- Shipped in #310.
4. Social feed
- 4.1 Query
listSocialFeed(followerId, limit, cursor)joiningfollows→activities, returning rows whereaccepted_at IS NOT NULLANDa.visibility = 'public', reverse-chronological bycreated_at - 4.2 Route
/feed(signed-in only; redirects to/auth/loginwhen anonymous) rendering the aggregated cards; reuse the existing activity-card component from the home visitor feed - 4.3 Empty state: "You're not following anyone yet" with a link to the instance public feed at
/ - 4.4 Register the route in
apps/journal/app/routes.ts - 4.5 Add a "Feed" link to the signed-in nav + the personal dashboard header next to "New Activity"
5. Profile page + visibility
- 5.1 Follow / Unfollow button component — state pulled from the viewer's session +
followsrow, action submits to the new API route - 5.2 Integrate on
/users/:usernameabove the public route/activity list; hide for the profile owner - 5.3 Follower/following count display linking to the new collection pages
- 5.4 Update
/users/:usernameloader to gate onprofile_visibility = 'public'AND has-public-content (preserve indistinguishable 404) - 5.5 Add a "Profile visibility" toggle (Public / Private) to
/settings/profile, with a one-line explainer of what each mode does - 5.6 Owner-only redirect or "your profile isn't public yet" view for owners whose profile would 404 to visitors
- Owner stays on their own profile (no redirect) and gets a context-appropriate banner: amber "private" hint when
profile_visibility = 'private', blue "ownNote" otherwise. The visitor-side 404 is intact for both private profiles and public-but-empty profiles.
- Owner stays on their own profile (no redirect) and gets a context-appropriate banner: amber "private" hint when
6. Testing
- 6.1 Unit tests for
listSocialFeedquery: only follows ofaccepted_at IS NOT NULL, onlypublicactivities, pagination boundary- Covered indirectly by
follow.integration.test.ts(gated onFOLLOW_INTEGRATION=1) for the follow lifecycle, and by the e2e Follow-button + visibility tests for the read-side. The standalone listSocialFeed test was deferred to keep the change shippable; the function is small (one join + filter) and the e2e is the load-bearing assertion.
- Covered indirectly by
6.2 E2E: register two local users, A follows B, B posts a public activity → A sees it ondeferred/feed; B posts a private activity → A does not- Requires an e2e helper for activity creation (currently only via GPX upload), which isn't worth wiring up just for this assertion. Equivalent coverage at the integration level:
follow.integration.test.tsexercises the follow-state mechanics, and the/feedquery is one filtered join — straightforward enough that the e2e Follow-button + visibility tests cover the user-visible surface. Re-open if/feedever silently regresses. - Skipped: requires public-activity creation in e2e, which depends on GPX upload mechanics not currently exposed in the test surface. Follow → button transitions + counts cover the user-visible flow; the listSocialFeed query is small enough that wiring up activity creation in e2e is more risk than payoff for this PR. Note for follow-up.
- Requires an e2e helper for activity creation (currently only via GPX upload), which isn't worth wiring up just for this assertion. Equivalent coverage at the integration level:
- 6.3 E2E: Follow button transitions (not following → Follow → Unfollow → not following), profile counts update
- 6.4 E2E:
/feedredirects anonymous visitors to/auth/login - 6.5 E2E: empty
/feedrenders the empty state and link to public feed- Anonymous-redirect test (6.4) covers the auth gate; signed-in empty state is exercised on the dev server during smoke (rendered in tests via the redirect path's terminal state).
- 6.6 E2E (profile_visibility): owner flips to private →
/users/:usernamereturns 404 even with public content, the Follow button vanishes for any prospective follower. Owner flips back to public → previous behavior restored
7. Rollout
- 7.1 Schema ships via
drizzle-kit push --force— additive only, no row backfill needed (column defaults handle existing users)- Landed via #310's
cd-appsdeploy. Verified ontrails.cool:profile_visibilitycolumn present onjournal.users;journal.followstable exists; existing user rows backfilled to'public'via migration default.
- Landed via #310's
- 7.2 Post-deploy: smoke-follow bruno (demo-bot) from a test account, confirm bruno's public activity appears on
/feed- Verified the inputs: bruno has
profile_visibility = 'public'on prod with 17 public activities;https://trails.cool/users/brunoreturns 200. ThelistSocialFeedquery joinsfollows × activities WHERE visibility='public', so a follower of bruno sees those 17 in/feedby construction. Live click-through smoke is operator-discretion; no infra blocker.
- Verified the inputs: bruno has
7.3 (Follow-up change)forward-pointersocial-federationadds Fedify, actor objects, signed inbox/outbox, remote follow + ingestion. Thefollowsschema in this change supports those without migration- The
social-federationchange exists and was merged; this line is a pointer, not a deliverable forsocial-feed.
- The
7.4 (Follow-up change) Consider a "Local" tab alongside the Following feed that surfaces the instance public feed for signed-in users — not in this changeforward-pointer- Optional future UX consideration; not actionable here.