import { randomUUID } from "node:crypto"; import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; import { localActorIri } from "./actor-iri.ts"; import { createNotification } from "./notifications.server.ts"; import { logger } from "./logger.server.ts"; export class FollowError extends Error { readonly code: | "self_follow" | "user_not_found" | "not_found" | "forbidden" // outbound federation codes (federation-outbound.server.ts) | "invalid_handle" | "local_handle" | "remote_resolve_failed" | "not_trails"; 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 Requests tab on /notifications 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"); } 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) { // Idempotent re-follow: do NOT emit a notification (the recipient // was notified when the row was first created). return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; } const acceptedAt = target.profileVisibility === "public" ? new Date() : null; const followId = randomUUID(); await db.insert(follows).values({ id: followId, followerId, followedActorIri, followedUserId: target.id, acceptedAt, }); // Notify the target. follow_received for auto-accepted public follows; // follow_request_received for Pending follows against private profiles. // Errors are logged but don't fail the follow — the user-visible // follow already succeeded. try { const [follower] = await db .select({ username: users.username, displayName: users.displayName }) .from(users) .where(eq(users.id, followerId)); if (follower) { const payload = { followerUsername: follower.username, followerDisplayName: follower.displayName, }; await createNotification( acceptedAt !== null ? { type: "follow_received", recipientUserId: target.id, actorUserId: followerId, subjectId: followId, payload, } : { type: "follow_request_received", recipientUserId: target.id, actorUserId: followerId, subjectId: followId, payload, }, ); } } catch (err) { logger.warn({ err, followId }, "followUser: notification emit failed"); } 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 { 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 { 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 { 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 { 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 { 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`. Drives the Requests tab * on /notifications. * 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, followerId: follows.followerId }); if (result.length === 0) return false; // Notify the requester that their request landed. Lookup target's // display info for the payload. Errors logged but don't undo the // approve — the follow row is already accepted. try { const followerId = result[0]!.followerId; const [target] = await db .select({ username: users.username, displayName: users.displayName }) .from(users) .where(eq(users.id, ownerId)); // followerId is NULL for inbound federated follows — those are // auto-accepted and never appear in the Requests tab, but guard // anyway: remote followers can't receive local notifications. if (target && followerId !== null) { await createNotification({ type: "follow_request_approved", recipientUserId: followerId, actorUserId: ownerId, subjectId: followId, payload: { targetUsername: target.username, targetDisplayName: target.displayName, }, }); } } catch (err) { logger.warn({ err, followId }, "approveFollowRequest: notification emit failed"); } return true; } /** * 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; domain: string; /** Where the entry's profile lives: local path or remote actor URL. */ profileUrl: string; remote: boolean; } const COLLECTION_PAGE_SIZE = 50; /** * Best-effort identity for a remote actor we may or may not have * cached: prefer the remote_actors row, fall back to parsing the IRI * (canonical fediverse shape `https://host/users/name`). */ function remoteEntry( actorIri: string, cached: { username: string | null; displayName: string | null; domain: string | null }, ): CollectionEntry { let host = ""; let lastSegment: string; try { const url = new URL(actorIri); host = url.host; lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? ""; } catch { // Malformed IRI in the DB — display it raw rather than hide the row. lastSegment = actorIri; } return { username: cached.username ?? lastSegment, displayName: cached.displayName, domain: cached.domain ?? host, profileUrl: actorIri, remote: true, }; } /** * Paginated list of accepted followers of `userId`. Newest acceptance first. * Includes remote (federated) followers — `follower_actor_iri` rows — so the * list matches countFollowers; display data comes from the remote_actors * cache when present, IRI parsing otherwise. Pending requests are excluded — * they live in the Requests tab on /notifications. */ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE; const rows = await db .select({ localUsername: users.username, localDisplayName: users.displayName, localDomain: users.domain, followerActorIri: follows.followerActorIri, remoteUsername: remoteActors.username, remoteDisplayName: remoteActors.displayName, remoteDomain: remoteActors.domain, }) .from(follows) .leftJoin(users, eq(follows.followerId, users.id)) .leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri)) .where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt))) .orderBy(desc(follows.acceptedAt)) .limit(COLLECTION_PAGE_SIZE) .offset(offset); return rows.map((r) => r.localUsername !== null ? { username: r.localUsername, displayName: r.localDisplayName, domain: r.localDomain ?? "", profileUrl: `/users/${r.localUsername}`, remote: false, } : remoteEntry(r.followerActorIri ?? "", { username: r.remoteUsername, displayName: r.remoteDisplayName, domain: r.remoteDomain, }), ); } /** * Paginated list of accepted follows from `userId`. Newest acceptance first. * Includes remote (trails-to-trails) targets — rows whose followed side is * only an actor IRI — for the same count/list consistency as listFollowers. * Pending outgoing follows (against private/locked targets) are excluded. */ export async function listFollowing(userId: string, page: number = 1): Promise { const db = getDb(); const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE; const rows = await db .select({ localUsername: users.username, localDisplayName: users.displayName, localDomain: users.domain, followedActorIri: follows.followedActorIri, remoteUsername: remoteActors.username, remoteDisplayName: remoteActors.displayName, remoteDomain: remoteActors.domain, }) .from(follows) .leftJoin(users, eq(follows.followedUserId, users.id)) .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) .where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt))) .orderBy(desc(follows.acceptedAt)) .limit(COLLECTION_PAGE_SIZE) .offset(offset); return rows.map((r) => r.localUsername !== null ? { username: r.localUsername, displayName: r.localDisplayName, domain: r.localDomain ?? "", profileUrl: `/users/${r.localUsername}`, remote: false, } : remoteEntry(r.followedActorIri, { username: r.remoteUsername, displayName: r.remoteDisplayName, domain: r.remoteDomain, }), ); }