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 { const db = getDb(); const perPage = clampPerPage(opts.perPage); const page = clampPage(opts.page); const offset = (page - 1) * perPage; const latestActivity = sql`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 { 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 { const db = getDb(); const cutoff = new Date(Date.now() - ACTIVE_RECENTLY_DAYS * 24 * 60 * 60 * 1000); const latestActivity = sql`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> { const result = new Map(); 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. 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> { const result = new Map(); 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;