Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.
Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
federation forward-compat. Local IRIs look like
`${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
'public'). Existing users land 'public' via the default; current
effective behavior is unchanged.
Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
countFollowers / countFollowing / listFollowers / listFollowing.
Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
follows → activities WHERE visibility='public', reverse-chrono.
Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.
UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
"New Activity".
Privacy manifest updated to document the new follows relation and
profile_visibility setting.
Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.
Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
4 KiB
TypeScript
103 lines
4 KiB
TypeScript
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getDb } from "./db.ts";
|
|
import { users, follows } from "@trails-cool/db/schema/journal";
|
|
import {
|
|
followUser,
|
|
unfollowUser,
|
|
getFollowState,
|
|
countFollowers,
|
|
countFollowing,
|
|
FollowError,
|
|
} from "./follow.server.ts";
|
|
|
|
// Opt-in: these talk to real Postgres. Gated by an env flag so laptop
|
|
// runs without Postgres aren't blocked. Same convention as
|
|
// demo-bot.integration.test.ts.
|
|
const runIntegration = process.env.FOLLOW_INTEGRATION === "1";
|
|
|
|
async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) {
|
|
const db = getDb();
|
|
const id = randomUUID();
|
|
await db.insert(users).values({
|
|
id,
|
|
email: `${opts.username}@example.test`,
|
|
username: opts.username,
|
|
domain: "test.local",
|
|
profileVisibility: opts.profileVisibility ?? "public",
|
|
});
|
|
return id;
|
|
}
|
|
|
|
async function wipe() {
|
|
const db = getDb();
|
|
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
|
|
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
|
|
}
|
|
|
|
describe.skipIf(!runIntegration)("follow.server integration", () => {
|
|
beforeAll(async () => {
|
|
const db = getDb();
|
|
await db.execute(sql`SELECT 1`);
|
|
});
|
|
afterEach(wipe);
|
|
|
|
it("follow + unfollow cycle on a public profile", async () => {
|
|
const a = await makeUser({ username: `f_a_${Date.now()}` });
|
|
const b = await makeUser({ username: `f_b_${Date.now()}` });
|
|
const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!;
|
|
const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!;
|
|
|
|
expect(await getFollowState(a, bRow.username)).toBeNull();
|
|
|
|
const s1 = await followUser(a, bRow.username);
|
|
expect(s1).toEqual({ following: true, pending: false });
|
|
expect(await countFollowers(b)).toBe(1);
|
|
expect(await countFollowing(a)).toBe(1);
|
|
|
|
// Idempotent
|
|
const s2 = await followUser(a, bRow.username);
|
|
expect(s2).toEqual({ following: true, pending: false });
|
|
expect(await countFollowers(b)).toBe(1);
|
|
|
|
const s3 = await unfollowUser(a, bRow.username);
|
|
expect(s3).toEqual({ following: false, pending: false });
|
|
expect(await countFollowers(b)).toBe(0);
|
|
expect(await getFollowState(a, bRow.username)).toBeNull();
|
|
|
|
// Idempotent
|
|
const s4 = await unfollowUser(a, bRow.username);
|
|
expect(s4).toEqual({ following: false, pending: false });
|
|
|
|
// touch aRow so the variable is read (lint hygiene)
|
|
expect(aRow.username.startsWith("f_a_")).toBe(true);
|
|
});
|
|
|
|
it("refuses to follow a private profile", 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" });
|
|
expect(await countFollowing(a)).toBe(0);
|
|
});
|
|
|
|
it("refuses self-follow", async () => {
|
|
const a = await makeUser({ username: `f_self_${Date.now()}` });
|
|
const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!;
|
|
await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" });
|
|
});
|
|
|
|
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" });
|
|
expect(await countFollowing(a)).toBe(0);
|
|
// suppress lint by using afterEach effect indirectly
|
|
expect(typeof a).toBe("string");
|
|
// also touch follows table so the import isn't unused
|
|
const db = getDb();
|
|
const rows = await db.select().from(follows).where(eq(follows.followerId, a));
|
|
expect(rows.length).toBe(0);
|
|
});
|
|
});
|