/** * T-158 — shared helpers for the ACTIVITY feeds (user + workspace). * * An "activity feed" is a permission-scoped, newest-first, keyset-paginated * PROJECTION over `audit_logs` (decision A-007: audit_logs are the single * source of truth; the feeds never write, they read + filter). The two * surfaces differ only in the `where` (the caller's workspaces vs. ONE * workspace), so the row shape, ordering, cursor + actor-name resolution are * shared here rather than duplicated across the users/workspaces services. */ import { encodeCursor, parsePagination } from "./pagination.js"; import type { PrismaService } from "../common/prisma.service.js"; /** The row shape both feeds return (actorName added in {@link withActorNames}). */ export const ACTIVITY_SELECT = { id: true, actorType: true, actorId: true, action: true, resourceType: true, resourceId: true, workspaceId: true, pageId: true, createdAt: true } as const; export type ActivityRow = Record; /** * Keyset predicate for the `(createdAt DESC, id DESC)` ordering. The cursor * is `encodeCursor(id, createdAtIso)` — `position` carries the ISO timestamp. */ export function activityKeyset(cursor: { id: string; position: string }): Record { return { OR: [ { createdAt: { lt: new Date(cursor.position) } }, { createdAt: new Date(cursor.position), id: { lt: cursor.id } } ] }; } /** * Run a bounded, newest-first activity query and wrap it in the standard * `{ data, nextCursor }` envelope. `where` is the caller's permission scope. */ export async function pageActivity( prisma: PrismaService, where: Record, query: { cursor?: string; limit?: string } ): Promise<{ data: ActivityRow[]; nextCursor: string | null }> { const { cursor, limit } = parsePagination(query); const fullWhere = cursor ? { ...where, AND: activityKeyset(cursor) } : where; const rows = await prisma.auditLog.findMany({ where: fullWhere as never, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limit + 1, select: ACTIVITY_SELECT }); const page = rows.slice(0, limit); const next = rows[limit]; return { data: await withActorNames(prisma, page as unknown as ActivityRow[]), nextCursor: next ? encodeCursor(next.id, next.createdAt.toISOString()) : null }; } /** Batch-resolve USER actor ids → names (the feed's "who" label). */ export async function withActorNames( prisma: PrismaService, rows: ActivityRow[] ): Promise { const ids = [...new Set(rows.filter((r) => r.actorType === "USER").map((r) => r.actorId as string))]; if (!ids.length) return rows; const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true, displayName: true } }); const names = new Map(users.map((u) => [u.id, u.displayName ?? u.name ?? ""])); return rows.map((r) => (r.actorType === "USER" ? { ...r, actorName: names.get(r.actorId as string) ?? null } : r)); }