trails/apps/journal/app/routes.ts
Ullrich Schäfer 5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00

57 lines
3.3 KiB
TypeScript

import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route(".well-known/trails-cool", "routes/api.well-known.trails-cool.ts"),
route("oauth/authorize", "routes/oauth.authorize.tsx"),
route("oauth/token", "routes/oauth.token.ts"),
route("auth/register", "routes/auth.register.tsx"),
route("auth/login", "routes/auth.login.tsx"),
route("auth/verify", "routes/auth.verify.tsx"),
route("auth/logout", "routes/auth.logout.tsx"),
route("auth/accept-terms", "routes/auth.accept-terms.tsx"),
route("api/auth/register", "routes/api.auth.register.ts"),
route("api/auth/login", "routes/api.auth.login.ts"),
route("routes", "routes/routes._index.tsx"),
route("routes/new", "routes/routes.new.tsx"),
route("routes/:id", "routes/routes.$id.tsx"),
route("routes/:id/edit", "routes/routes.$id.edit.tsx"),
route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"),
route("api/routes/:id/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"),
route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"),
route("activities", "routes/activities._index.tsx"),
route("activities/new", "routes/activities.new.tsx"),
route("activities/:id", "routes/activities.$id.tsx"),
route("users/:username", "routes/users.$username.tsx"),
route("users/:username/followers", "routes/users.$username.followers.tsx"),
route("users/:username/following", "routes/users.$username.following.tsx"),
route("api/users/:username/follow", "routes/api.users.$username.follow.ts"),
route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"),
route("follows/requests", "routes/follows.requests.tsx"),
route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"),
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
route("feed", "routes/feed.tsx"),
route("settings", "routes/settings.tsx"),
route("api/settings/profile", "routes/api.settings.profile.ts"),
route("api/settings/email", "routes/api.settings.email.ts"),
route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"),
route("api/settings/delete-account", "routes/api.settings.delete-account.ts"),
route("sync/import/:provider", "routes/sync.import.$provider.tsx"),
route("api/sync/connect/:provider", "routes/api.sync.connect.$provider.ts"),
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
route("privacy", "routes/privacy.tsx"),
route("legal/imprint", "routes/legal.imprint.tsx"),
route("legal/terms", "routes/legal.terms.tsx"),
route("legal/privacy", "routes/legal.privacy.tsx"),
// REST API v1
route("api/v1/routes", "routes/api.v1.routes._index.ts"),
route("api/v1/routes/compute", "routes/api.v1.routes.compute.ts"),
route("api/v1/routes/:id", "routes/api.v1.routes.$id.ts"),
route("api/v1/activities", "routes/api.v1.activities._index.ts"),
route("api/v1/activities/:id", "routes/api.v1.activities.$id.ts"),
route("api/v1/auth/devices", "routes/api.v1.auth.devices.ts"),
route("api/v1/auth/devices/:id", "routes/api.v1.auth.devices.$id.ts"),
route("api/v1/uploads", "routes/api.v1.uploads.ts"),
] satisfies RouteConfig;