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>
This commit is contained in:
parent
ede68712a3
commit
5da7ffa037
17 changed files with 656 additions and 181 deletions
|
|
@ -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<FollowState> {
|
||||
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<number> {
|
||||
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<number> {
|
|||
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<number> {
|
||||
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<FollowRequest[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<CollectionEntry[]> {
|
||||
const db = getDb();
|
||||
|
|
@ -141,7 +229,7 @@ export async function listFollowers(userId: string, page: number = 1): Promise<C
|
|||
})
|
||||
.from(follows)
|
||||
.innerJoin(users, eq(follows.followerId, users.id))
|
||||
.where(eq(follows.followedUserId, userId))
|
||||
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
|
||||
.orderBy(desc(follows.acceptedAt))
|
||||
.limit(COLLECTION_PAGE_SIZE)
|
||||
.offset(offset);
|
||||
|
|
@ -149,9 +237,8 @@ export async function listFollowers(userId: string, page: number = 1): Promise<C
|
|||
}
|
||||
|
||||
/**
|
||||
* Paginated list of users that `userId` follows. Newest acceptance first.
|
||||
* Limited to local follows in this change (`followedUserId IS NOT NULL`);
|
||||
* federation will surface remote actors here too.
|
||||
* Paginated list of accepted follows from `userId`. Newest acceptance first.
|
||||
* Pending outgoing follows (against private/locked targets) are excluded.
|
||||
*/
|
||||
export async function listFollowing(userId: string, page: number = 1): Promise<CollectionEntry[]> {
|
||||
const db = getDb();
|
||||
|
|
@ -164,7 +251,7 @@ export async function listFollowing(userId: string, page: number = 1): Promise<C
|
|||
})
|
||||
.from(follows)
|
||||
.innerJoin(users, eq(follows.followedUserId, users.id))
|
||||
.where(eq(follows.followerId, userId))
|
||||
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)))
|
||||
.orderBy(desc(follows.acceptedAt))
|
||||
.limit(COLLECTION_PAGE_SIZE)
|
||||
.offset(offset);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue