Implement social-feed: local follows + /feed + profile visibility
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>
This commit is contained in:
parent
6440631be4
commit
811d5f62f5
23 changed files with 1115 additions and 40 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq, desc, and, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db.ts";
|
||||
import { activities, routes, syncImports, users } from "@trails-cool/db/schema/journal";
|
||||
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
|
||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||
import { parseGpxAsync } from "@trails-cool/gpx";
|
||||
import { setGeomFromGpx } from "./routes.server.ts";
|
||||
|
|
@ -134,6 +134,43 @@ export async function listPublicActivitiesForOwner(ownerId: string) {
|
|||
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Social feed: aggregated public activities from users that `followerId`
|
||||
* follows (accepted only). Reverse-chronological. Joins users for owner
|
||||
* attribution. Unlisted/private activities never appear, regardless of
|
||||
* follow state.
|
||||
*/
|
||||
export async function listSocialFeed(followerId: string, limit: number = 50) {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: activities.id,
|
||||
name: activities.name,
|
||||
distance: activities.distance,
|
||||
elevationGain: activities.elevationGain,
|
||||
duration: activities.duration,
|
||||
startedAt: activities.startedAt,
|
||||
createdAt: activities.createdAt,
|
||||
ownerUsername: users.username,
|
||||
ownerDisplayName: users.displayName,
|
||||
})
|
||||
.from(activities)
|
||||
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
|
||||
.innerJoin(users, eq(activities.ownerId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(follows.followerId, followerId),
|
||||
eq(activities.visibility, "public"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activities.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
|
||||
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-wide public activity feed. Joins users so the caller can
|
||||
* render "by <displayName>" without a second round-trip. Used by the
|
||||
|
|
|
|||
8
apps/journal/app/lib/actor-iri.ts
Normal file
8
apps/journal/app/lib/actor-iri.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Canonical ActivityPub actor IRI for a local user. Used as the key in
|
||||
// `follows.followed_actor_iri` so the column shape is identical for local
|
||||
// and (future) federated follows. Reading from `process.env.ORIGIN` keeps
|
||||
// us aligned with the rest of the auth/federation stack.
|
||||
export function localActorIri(username: string): string {
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
return `${origin}/users/${username}`;
|
||||
}
|
||||
103
apps/journal/app/lib/follow.integration.test.ts
Normal file
103
apps/journal/app/lib/follow.integration.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
172
apps/journal/app/lib/follow.server.ts
Normal file
172
apps/journal/app/lib/follow.server.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq, and, count, desc } 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";
|
||||
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`. Auto-accepted because the target is local + public.
|
||||
* Idempotent: re-following an already-followed user returns the same state
|
||||
* without creating a duplicate row.
|
||||
*/
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
await db.insert(follows).values({
|
||||
id: randomUUID(),
|
||||
followerId,
|
||||
followedActorIri,
|
||||
followedUserId: target.id,
|
||||
acceptedAt: new Date(),
|
||||
});
|
||||
|
||||
return { following: true, pending: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
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));
|
||||
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(eq(follows.followerId, userId));
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
export interface CollectionEntry {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
const COLLECTION_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Paginated list of users who follow `userId`. Newest acceptance first.
|
||||
*/
|
||||
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(eq(follows.followedUserId, userId))
|
||||
.orderBy(desc(follows.acceptedAt))
|
||||
.limit(COLLECTION_PAGE_SIZE)
|
||||
.offset(offset);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(eq(follows.followerId, userId))
|
||||
.orderBy(desc(follows.acceptedAt))
|
||||
.limit(COLLECTION_PAGE_SIZE)
|
||||
.offset(offset);
|
||||
return rows;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue