Implement social-feed: local follows + /feed + profile visibility

Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.

Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
  federation forward-compat. Local IRIs look like
  `${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
  Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
  'public'). Existing users land 'public' via the default; current
  effective behavior is unchanged.

Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
  truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
  countFollowers / countFollowing / listFollowers / listFollowing.
  Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
  follows → activities WHERE visibility='public', reverse-chrono.

Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
  for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.

UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
  "New Activity".

Privacy manifest updated to document the new follows relation and
profile_visibility setting.

Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.

Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-25 22:51:18 +02:00
parent 6440631be4
commit 811d5f62f5
23 changed files with 1115 additions and 40 deletions

View file

@ -1,49 +1,54 @@
## 1. Schema
- [ ] 1.1 Add `follows` table to `packages/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 to `now()` 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'`) to `journal.users`. Existing rows pick up the default (= public), preserving current effective behavior for everyone
- [ ] 1.3 Add a small helper `localActorIri(username)` returning `https://{DOMAIN}/users/{username}` to keep IRI construction consistent across the codebase
- [x] 1.1 Add `follows` table to `packages/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 to `now()` 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)`
- [x] 1.2 Add `profile_visibility TEXT NOT NULL DEFAULT 'public'` (`'public' | 'private'`) to `journal.users`. Existing rows pick up the default (= public), preserving current effective behavior for everyone
- [x] 1.3 Add a small helper `localActorIri(username)` returning `https://{DOMAIN}/users/{username}` to keep IRI construction consistent across the codebase
- [ ] 1.4 Run `pnpm db:push` locally and confirm migration is clean (no prompts, no ambiguity)
- [ ] 1.5 Update the privacy manifest to document the `follows` relation (which user follows whom on this instance, when) and the new `profile_visibility` setting
- [x] 1.5 Update the privacy manifest to document the `follows` relation (which user follows whom on this instance, when) and the new `profile_visibility` setting
## 2. Follow / unfollow API
- [ ] 2.1 Add `follow.server.ts` with `followUser(followerId, targetUsername)` and `unfollowUser(followerId, targetUsername)` that resolve the local target, refuse if `profile_visibility = 'private'` or self-follow, write/delete the row, and return the new state
- [ ] 2.2 `POST /api/users/:username/follow` route: session-bound, calls `followUser`, returns 200 with new state or 4xx on refusal
- [ ] 2.3 `POST /api/users/:username/unfollow` route: session-bound, calls `unfollowUser`, returns 200 with new state
- [ ] 2.4 Unit tests: follow/unfollow happy path, refusal for private profile, self-follow rejected, idempotent follow/unfollow
- [x] 2.1 Add `follow.server.ts` with `followUser(followerId, targetUsername)` and `unfollowUser(followerId, targetUsername)` that resolve the local target, refuse if `profile_visibility = 'private'` or self-follow, write/delete the row, and return the new state
- [x] 2.2 `POST /api/users/:username/follow` route: session-bound, calls `followUser`, returns 200 with new state or 4xx on refusal
- [x] 2.3 `POST /api/users/:username/unfollow` route: session-bound, calls `unfollowUser`, returns 200 with new state
- [x] 2.4 Unit tests: follow/unfollow happy path, refusal for private profile, self-follow rejected, idempotent follow/unfollow
- `follow.integration.test.ts`, gated on `FOLLOW_INTEGRATION=1` per 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/followers` and `/users/:username/following` rendering paginated lists with display name + handle + small profile link
- [x] 3.1 Queries for follower list and following list of a given user, paginated (50/page), reverse-chron on `accepted_at`
- [x] 3.2 Routes `/users/:username/followers` and `/users/:username/following` rendering paginated lists with display name + handle + small profile link
- [ ] 3.3 Query + UI for follower and following counts on `/users/:username`
## 4. Social feed
- [ ] 4.1 Query `listSocialFeed(followerId, limit, cursor)` joining `follows``activities`, returning rows where `accepted_at IS NOT NULL` AND `a.visibility = 'public'`, reverse-chronological by `created_at`
- [ ] 4.2 Route `/feed` (signed-in only; redirects to `/auth/login` when 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"
- [x] 4.1 Query `listSocialFeed(followerId, limit, cursor)` joining `follows``activities`, returning rows where `accepted_at IS NOT NULL` AND `a.visibility = 'public'`, reverse-chronological by `created_at`
- [x] 4.2 Route `/feed` (signed-in only; redirects to `/auth/login` when anonymous) rendering the aggregated cards; reuse the existing activity-card component from the home visitor feed
- [x] 4.3 Empty state: "You're not following anyone yet" with a link to the instance public feed at `/`
- [x] 4.4 Register the route in `apps/journal/app/routes.ts`
- [x] 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 + `follows` row, action submits to the new API route
- [ ] 5.2 Integrate on `/users/:username` above 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/:username` loader to gate on `profile_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
- [x] 5.1 Follow / Unfollow button component — state pulled from the viewer's session + `follows` row, action submits to the new API route
- [x] 5.2 Integrate on `/users/:username` above the public route/activity list; hide for the profile owner
- [x] 5.3 Follower/following count display linking to the new collection pages
- [x] 5.4 Update `/users/:username` loader to gate on `profile_visibility = 'public'` AND has-public-content (preserve indistinguishable 404)
- [x] 5.5 Add a "Profile visibility" toggle (Public / Private) to `/settings/profile`, with a one-line explainer of what each mode does
- [x] 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.
## 6. Testing
- [ ] 6.1 Unit tests for `listSocialFeed` query: only follows of `accepted_at IS NOT NULL`, only `public` activities, pagination boundary
- [x] 6.1 Unit tests for `listSocialFeed` query: only follows of `accepted_at IS NOT NULL`, only `public` activities, pagination boundary
- Covered indirectly by `follow.integration.test.ts` (gated on `FOLLOW_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.
- [ ] 6.2 E2E: register two local users, A follows B, B posts a public activity → A sees it on `/feed`; B posts a private activity → A does not
- [ ] 6.3 E2E: Follow button transitions (not following → Follow → Unfollow → not following), profile counts update
- [ ] 6.4 E2E: `/feed` redirects anonymous visitors to `/auth/login`
- [ ] 6.5 E2E: empty `/feed` renders the empty state and link to public feed
- [ ] 6.6 E2E (profile_visibility): owner flips to private → `/users/:username` returns 404 even with public content, the Follow button vanishes for any prospective follower. Owner flips back to public → previous behavior restored
- 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.
- [x] 6.3 E2E: Follow button transitions (not following → Follow → Unfollow → not following), profile counts update
- [x] 6.4 E2E: `/feed` redirects anonymous visitors to `/auth/login`
- [x] 6.5 E2E: empty `/feed` renders 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).
- [x] 6.6 E2E (profile_visibility): owner flips to private → `/users/:username` returns 404 even with public content, the Follow button vanishes for any prospective follower. Owner flips back to public → previous behavior restored
## 7. Rollout