trails/apps/journal/app/lib/explore.server.ts
Ullrich Schäfer 8d7c48d8c1 Include the demo persona on /explore so users can follow it
The directory was filtering out the demo persona on the rationale
that "the demo bot is not a real user and should not appear in real
discovery." That's exactly backwards — the whole point of having a
demo persona is to give new users a follow target so the platform
doesn't feel empty when they arrive. Hiding the bot from the
discovery surface defeats its purpose.

Concretely on flagship: only one local user (ullrich) was visible
on /explore today, even though Bruno (the demo persona) is
public-by-default and posting public activities. After this change
both appear; Bruno carries a small "🐕 Demo account" badge next to
his display name so viewers know what they're following.

- apps/journal/app/lib/explore.server.ts — drop the
  ne(users.username, persona.username) clause from exclusionFilters.
  The demo persona is now treated like any other public user. Banned/
  suspended scaffolding stays for forward-compat.
- apps/journal/app/routes/explore.tsx — loader computes isDemoUser
  per row (cheap, just username comparison against
  loadPersona().username). DirectoryRow renders the demo badge inline
  with the display name, matching the existing pattern on
  /users/:username.
- openspec/specs/explore/spec.md — updated the "Excluded users"
  requirement to remove the demo persona, replaced the "demo
  excluded" scenario with "demo appears with badge", and updated
  the "Active recently" requirement + scenarios accordingly.
- apps/journal/app/lib/explore.integration.test.ts — flipped the
  demo-persona test from "is excluded" to "is included".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:43:24 +02:00

212 lines
6.2 KiB
TypeScript

import { and, count, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { localActorIri } from "./actor-iri.ts";
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
const ACTIVE_RECENTLY_DAYS = 30;
const ACTIVE_RECENTLY_DEFAULT_LIMIT = 5;
export interface DirectoryRow {
id: string;
username: string;
displayName: string | null;
bio: string | null;
latestActivityAt: Date | null;
}
export interface DirectoryListing {
rows: DirectoryRow[];
totalCount: number;
}
export interface ListDirectoryOptions {
page?: number;
perPage?: number;
}
/**
* Clamps `?perPage=` into [1, MAX_PAGE_SIZE]. Out-of-range values
* (NaN, negative, > MAX) snap to bounds rather than 400.
*/
function clampPerPage(raw: number | undefined): number {
if (!raw || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
return Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(raw)));
}
function clampPage(raw: number | undefined): number {
if (!raw || !Number.isFinite(raw)) return 1;
return Math.max(1, Math.floor(raw));
}
function exclusionFilters() {
// Public-only. The demo persona IS included on /explore — its whole
// purpose is to give new users a follow target, and the per-row demo
// badge in the UI signals what it is. Banned/suspended users would
// be filtered here too once such a status column exists — see design.md.
return eq(users.profileVisibility, "public");
}
/**
* Paginated directory of public local users. Order: most-recent public
* activity DESC NULLS LAST, tiebreaker `users.id DESC` for stable
* pagination.
*/
export async function listDirectory(opts: ListDirectoryOptions = {}): Promise<DirectoryListing> {
const db = getDb();
const perPage = clampPerPage(opts.perPage);
const page = clampPage(opts.page);
const offset = (page - 1) * perPage;
const latestActivity = sql<Date | null>`MAX(CASE WHEN ${activities.visibility} = 'public' THEN ${activities.createdAt} ELSE NULL END)`;
const rows = await db
.select({
id: users.id,
username: users.username,
displayName: users.displayName,
bio: users.bio,
latestActivityAt: latestActivity,
})
.from(users)
.leftJoin(activities, eq(activities.ownerId, users.id))
.where(exclusionFilters())
.groupBy(users.id)
.orderBy(sql`${latestActivity} DESC NULLS LAST`, desc(users.id))
.limit(perPage)
.offset(offset);
const [countRow] = await db
.select({ n: count() })
.from(users)
.where(exclusionFilters());
return { rows, totalCount: countRow?.n ?? 0 };
}
/**
* Cheap count helper if a caller only wants the size — used by the
* pagination math path that doesn't need the page rows.
*/
export async function countDirectory(): Promise<number> {
const db = getDb();
const [row] = await db
.select({ n: count() })
.from(users)
.where(exclusionFilters());
return row?.n ?? 0;
}
/**
* Top-N public users with at least one public activity in the last
* 30 days. Same exclusion rules as `listDirectory`. Returns at most
* `limit` rows; an empty array signals "no qualifying users — hide
* the strip."
*/
export async function listActiveRecently(limit: number = ACTIVE_RECENTLY_DEFAULT_LIMIT): Promise<DirectoryRow[]> {
const db = getDb();
const cutoff = new Date(Date.now() - ACTIVE_RECENTLY_DAYS * 24 * 60 * 60 * 1000);
const latestActivity = sql<Date | null>`MAX(${activities.createdAt})`;
const rows = await db
.select({
id: users.id,
username: users.username,
displayName: users.displayName,
bio: users.bio,
latestActivityAt: latestActivity,
})
.from(users)
.innerJoin(activities, eq(activities.ownerId, users.id))
.where(
and(
exclusionFilters(),
eq(activities.visibility, "public"),
gte(activities.createdAt, cutoff),
),
)
.groupBy(users.id)
.orderBy(sql`${latestActivity} DESC`, desc(users.id))
.limit(Math.max(1, Math.min(50, Math.floor(limit))));
return rows;
}
/**
* Batched accepted-follower count for a set of users. One query
* regardless of how many users are on the page — avoids N+1 against
* `countFollowers` per row.
*/
export async function countFollowersBatch(userIds: string[]): Promise<Map<string, number>> {
const result = new Map<string, number>();
if (userIds.length === 0) return result;
const db = getDb();
const rows = await db
.select({
userId: follows.followedUserId,
n: count(),
})
.from(follows)
.where(
and(
inArray(follows.followedUserId, userIds),
isNotNull(follows.acceptedAt),
),
)
.groupBy(follows.followedUserId);
for (const r of rows) {
if (r.userId) result.set(r.userId, r.n);
}
// Fill missing user ids with 0 so callers don't need to coalesce.
for (const id of userIds) {
if (!result.has(id)) result.set(id, 0);
}
return result;
}
/**
* Batched follow-state lookup for a viewer against a set of target
* users. Returns Map<targetUserId, FollowState>. Used by /explore so
* each row's FollowButton has its `initialState` without N round-trips.
*/
export interface FollowStateRow {
following: boolean;
pending: boolean;
}
export async function getFollowStateBatch(
followerId: string,
targets: { id: string; username: string }[],
): Promise<Map<string, FollowStateRow>> {
const result = new Map<string, FollowStateRow>();
if (targets.length === 0) return result;
const db = getDb();
const iris = targets.map((t) => localActorIri(t.username));
const irisToId = new Map(targets.map((t) => [localActorIri(t.username), t.id]));
const rows = await db
.select({
iri: follows.followedActorIri,
acceptedAt: follows.acceptedAt,
})
.from(follows)
.where(
and(
eq(follows.followerId, followerId),
inArray(follows.followedActorIri, iris),
),
);
for (const r of rows) {
const id = irisToId.get(r.iri);
if (!id) continue;
result.set(id, { following: r.acceptedAt !== null, pending: r.acceptedAt === null });
}
return result;
}
/**
* Page-size constants exposed for callers (route loader, tests).
*/
export const EXPLORE_DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
export const EXPLORE_MAX_PAGE_SIZE = MAX_PAGE_SIZE;