From 5da7ffa03705ff569fc5c8d3bad9e25193dcc8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 25 Apr 2026 23:38:26 +0200 Subject: [PATCH 1/3] Locked-account profiles: private = stub + Pending follow flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/journal/app/components/FollowButton.tsx | 49 +++++-- .../app/lib/follow.integration.test.ts | 55 +++++++- apps/journal/app/lib/follow.server.ts | 123 +++++++++++++++--- apps/journal/app/root.tsx | 39 +++++- apps/journal/app/routes.ts | 3 + .../app/routes/api.follows.$id.approve.ts | 19 +++ .../app/routes/api.follows.$id.reject.ts | 19 +++ apps/journal/app/routes/follows.requests.tsx | 99 ++++++++++++++ apps/journal/app/routes/users.$username.tsx | 83 ++++++++---- e2e/social.test.ts | 123 ++++++++++-------- openspec/changes/social-feed/design.md | 25 ++-- openspec/changes/social-feed/proposal.md | 13 +- .../social-feed/specs/public-profiles/spec.md | 53 ++++---- .../social-feed/specs/social-follows/spec.md | 73 ++++++++--- packages/db/src/schema/journal.ts | 13 +- packages/i18n/src/locales/de.ts | 24 +++- packages/i18n/src/locales/en.ts | 24 +++- 17 files changed, 656 insertions(+), 181 deletions(-) create mode 100644 apps/journal/app/routes/api.follows.$id.approve.ts create mode 100644 apps/journal/app/routes/api.follows.$id.reject.ts create mode 100644 apps/journal/app/routes/follows.requests.tsx diff --git a/apps/journal/app/components/FollowButton.tsx b/apps/journal/app/components/FollowButton.tsx index 1a0d466..78b40be 100644 --- a/apps/journal/app/components/FollowButton.tsx +++ b/apps/journal/app/components/FollowButton.tsx @@ -8,21 +8,36 @@ interface FollowState { interface Props { username: string; + // Whether the followed profile is private/locked. Drives the "Request to + // follow" label vs. plain "Follow" before any click happens. + isPrivateTarget: boolean; initialState: FollowState | null; } -export function FollowButton({ username, initialState }: Props) { +type Display = "follow" | "request" | "pending" | "unfollow"; + +function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display { + if (state?.following) return "unfollow"; + if (state?.pending) return "pending"; + return isPrivateTarget ? "request" : "follow"; +} + +export function FollowButton({ username, isPrivateTarget, initialState }: Props) { const { t } = useTranslation("journal"); const [state, setState] = useState( initialState ?? { following: false, pending: false }, ); - const [isPending, startTransition] = useTransition(); + const [isInFlight, startTransition] = useTransition(); const [error, setError] = useState(null); + const display = displayFor(state, isPrivateTarget); + const onClick = () => { setError(null); startTransition(async () => { - const path = state.following + // For "pending" we treat the click as cancel-request: same /unfollow + // endpoint deletes the row whether it's accepted or pending. + const path = state.following || state.pending ? `/api/users/${username}/unfollow` : `/api/users/${username}/follow`; try { @@ -40,21 +55,33 @@ export function FollowButton({ username, initialState }: Props) { }); }; - const label = state.following ? t("social.unfollow") : t("social.follow"); + const label = (() => { + switch (display) { + case "unfollow": + return t("social.unfollow"); + case "pending": + return t("social.pendingCancel"); + case "request": + return t("social.requestToFollow"); + case "follow": + default: + return t("social.follow"); + } + })(); + + const baseClass = display === "follow" || display === "request" + ? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" + : "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"; return (
{error &&

{error}

}
diff --git a/apps/journal/app/lib/follow.integration.test.ts b/apps/journal/app/lib/follow.integration.test.ts index 9ae2c11..2030eba 100644 --- a/apps/journal/app/lib/follow.integration.test.ts +++ b/apps/journal/app/lib/follow.integration.test.ts @@ -9,7 +9,10 @@ import { getFollowState, countFollowers, countFollowing, - FollowError, + countPendingFollowRequests, + listPendingFollowRequests, + approveFollowRequest, + rejectFollowRequest, } from "./follow.server.ts"; // Opt-in: these talk to real Postgres. Gated by an env flag so laptop @@ -74,12 +77,14 @@ describe.skipIf(!runIntegration)("follow.server integration", () => { expect(aRow.username.startsWith("f_a_")).toBe(true); }); - it("refuses to follow a private profile", async () => { + it("creates a Pending follow against a private profile (not a refusal)", async () => { const a = await makeUser({ username: `f_pa_${Date.now()}` }); const b = await makeUser({ username: `f_pb_${Date.now()}`, profileVisibility: "private" }); const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; - await expect(followUser(a, bRow.username)).rejects.toBeInstanceOf(FollowError); - await expect(followUser(a, bRow.username)).rejects.toMatchObject({ code: "private_profile" }); + const s = await followUser(a, bRow.username); + expect(s).toEqual({ following: false, pending: true }); + // Pending is excluded from accepted-only counts. + expect(await countFollowers(b)).toBe(0); expect(await countFollowing(a)).toBe(0); }); @@ -89,6 +94,48 @@ describe.skipIf(!runIntegration)("follow.server integration", () => { await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" }); }); + it("approve flips Pending → Accepted; reject deletes the request", async () => { + const a = await makeUser({ username: `f_apr_${Date.now()}` }); + const b = await makeUser({ username: `f_apb_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + await followUser(a, bRow.username); + expect(await countPendingFollowRequests(b)).toBe(1); + + const reqs = await listPendingFollowRequests(b); + expect(reqs.length).toBe(1); + const reqId = reqs[0]!.id; + + const approved = await approveFollowRequest(b, reqId); + expect(approved).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await countFollowers(b)).toBe(1); + expect(await getFollowState(a, bRow.username)).toEqual({ following: true, pending: false }); + + // Idempotent: approving again is a no-op. + expect(await approveFollowRequest(b, reqId)).toBe(false); + + // Reject path: a fresh request from a 3rd user, B rejects. + const c = await makeUser({ username: `f_apc_${Date.now()}` }); + await followUser(c, bRow.username); + const reqs2 = await listPendingFollowRequests(b); + expect(reqs2.length).toBe(1); + expect(await rejectFollowRequest(b, reqs2[0]!.id)).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await getFollowState(c, bRow.username)).toBeNull(); + }); + + it("approve/reject is owner-bound (other users can't approve someone else's request)", async () => { + const a = await makeUser({ username: `f_obA_${Date.now()}` }); + const b = await makeUser({ username: `f_obB_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + const c = await makeUser({ username: `f_obC_${Date.now()}` }); + await followUser(a, bRow.username); + const reqs = await listPendingFollowRequests(b); + // C tries to approve B's incoming request — should be a no-op. + expect(await approveFollowRequest(c, reqs[0]!.id)).toBe(false); + expect(await countPendingFollowRequests(b)).toBe(1); + }); + it("404s on unknown username", async () => { const a = await makeUser({ username: `f_404_${Date.now()}` }); await expect(followUser(a, "no_such_user_xyz")).rejects.toMatchObject({ code: "user_not_found" }); diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index cc198b0..5165e2c 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; -import { eq, and, count, desc } from "drizzle-orm"; +import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { users, follows } from "@trails-cool/db/schema/journal"; import { localActorIri } from "./actor-iri.ts"; export class FollowError extends Error { - readonly code: "self_follow" | "private_profile" | "user_not_found" | "not_found"; + readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden"; constructor(code: FollowError["code"], message: string) { super(message); this.name = "FollowError"; @@ -34,18 +34,16 @@ async function loadFollowableTarget(targetUsername: string) { /** * Create a follow row from `followerId` to the local user with username - * `targetUsername`. Auto-accepted because the target is local + public. - * Idempotent: re-following an already-followed user returns the same state - * without creating a duplicate row. + * `targetUsername`. Public targets auto-accept (`accepted_at = now()`), + * private (locked) targets land Pending (`accepted_at = NULL`) and + * appear in the target's /follows/requests list for manual approval. + * Idempotent: re-following keeps the existing row's state. */ export async function followUser(followerId: string, targetUsername: string): Promise { const target = await loadFollowableTarget(targetUsername); if (target.id === followerId) { throw new FollowError("self_follow", "Users cannot follow themselves"); } - if (target.profileVisibility !== "public") { - throw new FollowError("private_profile", "This profile is not followable"); - } const db = getDb(); const followedActorIri = localActorIri(target.username); @@ -57,15 +55,19 @@ export async function followUser(followerId: string, targetUsername: string): Pr return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; } + const acceptedAt = target.profileVisibility === "public" ? new Date() : null; await db.insert(follows).values({ id: randomUUID(), followerId, followedActorIri, followedUserId: target.id, - acceptedAt: new Date(), + acceptedAt, }); - return { following: true, pending: false }; + return { + following: acceptedAt !== null, + pending: acceptedAt === null, + }; } /** @@ -101,12 +103,16 @@ export async function getFollowState( return { following: row.acceptedAt !== null, pending: row.acceptedAt === null }; } +// Counts include only accepted relations — Pending requests don't count +// toward the public follower/following tallies (a request not yet +// approved isn't a real follow). + export async function countFollowers(userId: string): Promise { const db = getDb(); const [row] = await db .select({ n: count() }) .from(follows) - .where(eq(follows.followedUserId, userId)); + .where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt))); return row?.n ?? 0; } @@ -115,10 +121,91 @@ export async function countFollowing(userId: string): Promise { const [row] = await db .select({ n: count() }) .from(follows) - .where(eq(follows.followerId, userId)); + .where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt))); return row?.n ?? 0; } +/** + * Count of incoming Pending follow requests for `userId`. Drives the + * navbar badge. Distinct from countFollowers (which is accepted-only). + */ +export async function countPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))); + return row?.n ?? 0; +} + +export interface FollowRequest { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: Date; +} + +/** + * Pending incoming follow requests for `userId`. Used by /follows/requests. + * Reverse-chronological by request creation time. + */ +export async function listPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + id: follows.id, + followerUsername: users.username, + followerDisplayName: users.displayName, + followerDomain: users.domain, + createdAt: follows.createdAt, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))) + .orderBy(desc(follows.createdAt)); + return rows; +} + +/** + * Approve a Pending follow request. Owner-bound: `ownerId` must equal + * `follows.followedUserId` for the row, otherwise the call is a no-op. + */ +export async function approveFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .update(follows) + .set({ acceptedAt: new Date() }) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + +/** + * Reject a Pending follow request. Deletes the row entirely so the + * follower can re-request later if they want. + */ +export async function rejectFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .delete(follows) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + export interface CollectionEntry { username: string; displayName: string | null; @@ -128,7 +215,8 @@ export interface CollectionEntry { const COLLECTION_PAGE_SIZE = 50; /** - * Paginated list of users who follow `userId`. Newest acceptance first. + * Paginated list of accepted followers of `userId`. Newest acceptance first. + * Pending requests are excluded — they live in /follows/requests. */ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); @@ -141,7 +229,7 @@ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); @@ -164,7 +251,7 @@ export async function listFollowing(userId: string, page: number = 1): Promise