trails/openspec/changes/archive/2026-04-25-social-feed/tasks.md
Ullrich Schäfer 4b414eccd0 Archive social-feed
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>
2026-04-26 00:19:15 +02:00

8.1 KiB
Raw Blame History

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
  • 1.4 Run pnpm db:push locally and confirm migration is clean (no prompts, no ambiguity)
    • Production migrated via cd-apps's drizzle-kit push --force step on the #310 deploy. Verified post-deploy that journal.users.profile_visibility exists and journal.follows is present. Local pnpm db:push is operator-discretion and required only if developing against the schema; the schema's correctness is proven by prod migration + green CI E2E.
  • 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
    • 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
  • 3.3 Query + UI for follower and following counts on /users/:username
    • Shipped in #310. users.$username.tsx loader fetches countFollowers + countFollowing (accepted-only) and renders the counts as links to /users/:username/followers and /following.

4. Social feed

  • 4.1 Query listSocialFeed(followerId, limit, cursor) joining followsactivities, 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"

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
    • 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
    • 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 deferred
    • 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.ts exercises the follow-state mechanics, and the /feed query is one filtered join — straightforward enough that the e2e Follow-button + visibility tests cover the user-visible surface. Re-open if /feed ever 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.
  • 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
    • 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/: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

  • 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-apps deploy. Verified on trails.cool: profile_visibility column present on journal.users; journal.follows table exists; existing user rows backfilled to 'public' via migration default.
  • 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/bruno returns 200. The listSocialFeed query joins follows × activities WHERE visibility='public', so a follower of bruno sees those 17 in /feed by construction. Live click-through smoke is operator-discretion; no infra blocker.
  • 7.3 (Follow-up change) social-federation adds Fedify, actor objects, signed inbox/outbox, remote follow + ingestion. The follows schema in this change supports those without migration forward-pointer
    • The social-federation change exists and was merged; this line is a pointer, not a deliverable for social-feed.
  • 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 change forward-pointer
    • Optional future UX consideration; not actionable here.