trails/apps/journal/app/lib/follow.server.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

259 lines
8.2 KiB
TypeScript

import { randomUUID } from "node:crypto";
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" | "user_not_found" | "not_found" | "forbidden";
constructor(code: FollowError["code"], message: string) {
super(message);
this.name = "FollowError";
this.code = code;
}
}
export interface FollowState {
following: boolean;
pending: boolean;
}
async function loadFollowableTarget(targetUsername: string) {
const db = getDb();
const [target] = await db
.select({
id: users.id,
username: users.username,
profileVisibility: users.profileVisibility,
})
.from(users)
.where(eq(users.username, targetUsername));
if (!target) throw new FollowError("user_not_found", "User not found");
return target;
}
/**
* Create a follow row from `followerId` to the local user with username
* `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");
}
const db = getDb();
const followedActorIri = localActorIri(target.username);
const [existing] = await db
.select({ id: follows.id, acceptedAt: follows.acceptedAt })
.from(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri)));
if (existing) {
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,
});
return {
following: acceptedAt !== null,
pending: acceptedAt === null,
};
}
/**
* Delete the follow row from `followerId` against the local user with
* username `targetUsername`. Idempotent.
*/
export async function unfollowUser(followerId: string, targetUsername: string): Promise<FollowState> {
const target = await loadFollowableTarget(targetUsername);
const db = getDb();
const followedActorIri = localActorIri(target.username);
await db
.delete(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri)));
return { following: false, pending: false };
}
/**
* Read-side helper: is `followerId` currently following `targetUsername`?
* Returns `null` when no row exists (so callers can distinguish "no follow"
* from "row exists but unaccepted" once federation's Pending state lands).
*/
export async function getFollowState(
followerId: string,
targetUsername: string,
): Promise<FollowState | null> {
const db = getDb();
const followedActorIri = localActorIri(targetUsername);
const [row] = await db
.select({ acceptedAt: follows.acceptedAt })
.from(follows)
.where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri)));
if (!row) return null;
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(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)));
return row?.n ?? 0;
}
export async function countFollowing(userId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ n: count() })
.from(follows)
.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;
domain: string;
}
const COLLECTION_PAGE_SIZE = 50;
/**
* 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();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
username: users.username,
displayName: users.displayName,
domain: users.domain,
})
.from(follows)
.innerJoin(users, eq(follows.followerId, users.id))
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows;
}
/**
* 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();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
username: users.username,
displayName: users.displayName,
domain: users.domain,
})
.from(follows)
.innerJoin(users, eq(follows.followedUserId, users.id))
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows;
}