diff --git a/apps/web/app/(authed)/projects/[key]/activity/page.tsx b/apps/web/app/(authed)/projects/[key]/activity/page.tsx new file mode 100644 index 0000000..ba43ba0 --- /dev/null +++ b/apps/web/app/(authed)/projects/[key]/activity/page.tsx @@ -0,0 +1,321 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { and, eq, inArray } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db, pg } from "@/lib/db/client"; +import { projects, users } from "@/lib/db/schema"; +import { getProjectAccess, getUserGroupNames, readableProjectIds } from "@/lib/access"; +import { Container, PageHeader } from "@/app/_components/ui/container"; +import { Card, CardBody } from "@/app/_components/ui/card"; +import { Badge } from "@/app/_components/ui/badge"; +import { Button } from "@/app/_components/ui/button"; +import { EmptyState } from "@/app/_components/ui/empty-state"; + +export const dynamic = "force-dynamic"; + +const ROW_LIMIT = 150; + +interface ActivityRow { + id: string; + action: string; + actor: "mcp" | "web" | "system"; + entityType: "memory" | "snippet" | "project" | string; + entityId: string | null; + userId: string | null; + payload: Record | null; + createdAt: Date; +} + +/** + * Per-project activity feed. Surfaces every audit_log row that pertains + * to this project — memory writes/updates/deletes, snippet puts/deletes, + * share grants/revocations/changes, identify-collision warnings. + * + * Query strategy: three UNION ALL legs joined to a single audit_log + * source, ordered + limited at the end. Avoids relying on the audit + * payload's projectKey field, which isn't populated for every action + * shape today. + */ +export default async function ProjectActivityPage({ + params, +}: { + params: Promise<{ key: string }>; +}) { + const session = await auth(); + const userId = session!.user.id; + const { key: rawKey } = await params; + const key = decodeURIComponent(rawKey); + const groupNames = await getUserGroupNames(userId); + + // Resolve the project: prefer owned, fall back to shared. + const ownedRow = await db + .select() + .from(projects) + .where(and(eq(projects.userId, userId), eq(projects.key, key))) + .limit(1); + let project = ownedRow[0]; + if (!project) { + if (groupNames.length === 0) notFound(); + const readableIds = await readableProjectIds(userId, groupNames); + if (readableIds.length === 0) notFound(); + const sharedRow = await db + .select() + .from(projects) + .where(and(eq(projects.key, key), inArray(projects.id, readableIds))) + .limit(1); + if (!sharedRow[0]) notFound(); + project = sharedRow[0]; + } + + const access = await getProjectAccess(userId, groupNames, project.id); + if (access === null) notFound(); + + // Pull every audit_log row that concerns this project. Three legs: + // - memory rows joined by entity_id → memories.id where the memory's + // current project_id matches + // - snippet rows joined likewise + // - project rows where entity_id is the project itself (share grants + // and project.identify.collision) + // postgres-js's tagged template returns parsed JSON for jsonb columns. + const rows = await pg` + SELECT al.id, al.action, al.actor, al.entity_type AS "entityType", + al.entity_id AS "entityId", al.user_id AS "userId", + al.payload, al.created_at AS "createdAt" + FROM audit_log al + JOIN memories m ON al.entity_id = m.id + WHERE al.entity_type = 'memory' + AND m.project_id = ${project.id} + UNION ALL + SELECT al.id, al.action, al.actor, al.entity_type, + al.entity_id, al.user_id, al.payload, al.created_at + FROM audit_log al + JOIN snippets s ON al.entity_id = s.id + WHERE al.entity_type = 'snippet' + AND s.project_id = ${project.id} + UNION ALL + SELECT al.id, al.action, al.actor, al.entity_type, + al.entity_id, al.user_id, al.payload, al.created_at + FROM audit_log al + WHERE al.entity_type = 'project' + AND al.entity_id = ${project.id} + ORDER BY "createdAt" DESC + LIMIT ${ROW_LIMIT} + `; + + // Bulk-resolve user display names. `userId` can be null for system + // entries (project.identify.collision); skip those. + const userIds = [...new Set(rows.map((r) => r.userId).filter((id): id is string => Boolean(id)))]; + const userById = new Map(); + if (userIds.length > 0) { + const userRows = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(inArray(users.id, userIds)); + for (const u of userRows) { + userById.set(u.id, { name: u.name, email: u.email }); + } + } + + function actorLabel(row: ActivityRow): string { + if (row.actor === "system") return "system"; + if (!row.userId) return "(unknown user)"; + const u = userById.get(row.userId); + if (!u) return "(unknown user)"; + return u.name ?? u.email ?? row.userId; + } + + return ( + + + {project.key} + {" · "} + {rows.length} event{rows.length === 1 ? "" : "s"} + {rows.length === ROW_LIMIT ? (most recent first) : null} + + } + actions={ + + + + } + /> + + {rows.length === 0 ? ( + + ) : ( + +
    + {rows.map((row) => ( +
  1. +
    +
    + {actorLabel(row)}{" "} + {describeAction(row)} +
    + {renderPayloadSummary(row)} +
    +
    + {formatRelative(row.createdAt)} +
    +
  2. + ))} +
+
+ )} +
+ ); +} + +/** + * Human-friendly verb phrase per action. Includes an entity link when + * the entity is still resolvable (memory/snippet id), plain text + * otherwise. Keeps deletes phrased in the past tense so the feed reads + * as a log. + */ +function describeAction(row: ActivityRow): React.ReactNode { + switch (row.action) { + case "memory.write": + return ( + <> + wrote{" "} + {row.entityId ? ( + + a memory + + ) : ( + "a memory" + )} + + ); + case "memory.update": { + const fields = (row.payload?.fields as string[] | undefined) ?? []; + const fieldList = fields.length > 0 ? ` (${fields.join(", ")})` : ""; + return ( + <> + edited{" "} + {row.entityId ? ( + + a memory + + ) : ( + "a memory" + )} + {fieldList} + + ); + } + case "memory.delete": + return <>deleted a memory; + case "snippet.put": + case "snippet.update": { + const name = (row.payload?.name as string | undefined) ?? null; + const verb = row.action === "snippet.put" ? "saved" : "edited"; + return ( + <> + {verb} snippet{" "} + {name ? {name} : (unnamed)} + + ); + } + case "snippet.delete": { + const name = (row.payload?.name as string | undefined) ?? null; + return ( + <> + deleted snippet{" "} + {name ? {name} : (unnamed)} + + ); + } + case "project.share.add": { + const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)"; + const access = (row.payload?.access as string | undefined) ?? "?"; + return ( + <> + shared with {groupName}{" "} + {access} + + ); + } + case "project.share.update": { + const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)"; + const access = (row.payload?.access as string | undefined) ?? "?"; + return ( + <> + changed {groupName}{"'s "}access to{" "} + {access} + + ); + } + case "project.share.remove": { + const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)"; + return ( + <> + stopped sharing with {groupName} + + ); + } + case "project.identify.collision": + return ( + <> + project key collided with a shared project of the same name (owned won) + + ); + default: + return <>{row.action}; + } +} + +/** + * Optional second line for richer payloads — scope transitions and tag + * changes on memory.update, mainly. Kept terse so the feed scans well. + */ +function renderPayloadSummary(row: ActivityRow): React.ReactNode { + if (row.action !== "memory.update") return null; + const p = row.payload ?? {}; + const scope = p.scope as { from: string; to: string } | undefined; + const projectKey = p.projectKey as { from: string | null; to: string | null } | undefined; + if (!scope && !projectKey) return null; + return ( +
+ {scope ? ( + + scope: {scope.from} → {scope.to} + + ) : null} + {scope && projectKey ? · : null} + {projectKey ? ( + + project: {projectKey.from ?? "—"} → {projectKey.to ?? "—"} + + ) : null} +
+ ); +} + +/** + * Tiny relative-time formatter — no third-party dep needed for a few + * grain buckets. Anything older than a week falls back to a date. + */ +function formatRelative(d: Date): string { + const now = Date.now(); + const t = d.getTime(); + const diff = Math.max(0, now - t); + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + if (day < 7) return `${day}d ago`; + return d.toLocaleDateString(); +} diff --git a/apps/web/app/(authed)/projects/[key]/page.tsx b/apps/web/app/(authed)/projects/[key]/page.tsx index 98b32ed..0405056 100644 --- a/apps/web/app/(authed)/projects/[key]/page.tsx +++ b/apps/web/app/(authed)/projects/[key]/page.tsx @@ -183,6 +183,12 @@ export default async function ProjectDetailPage({ + + + {canWrite ? (