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

65 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 1. Schema
- [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
- [x] 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.
- [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
- [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
- [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
- [x] 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
- [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
- [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
- [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~~ **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.
- [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
- [x] 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.
- [x] 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.
- [x] ~~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`.
- [x] ~~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.