feat(web): per-project activity feed at /projects/[key]/activity
Closes Phase 4f from the deferred backlog. Renders every audit_log row that pertains to a project — memory writes/updates/deletes, snippet puts/deletes, share grants/changes/revocations, project.identify collisions — newest first, capped at 150 rows. Query is a three-leg UNION ALL joining audit_log against memories, snippets, and the project itself. Avoids relying on payload->>projectKey which isn't populated consistently across all action shapes. Each row renders as "<actor> <verb-phrase>" with entity links where applicable, plus a compact relative timestamp. memory.update entries also show scope/project transitions inline when those changed. Share events surface the group name and access level with the existing tone-coded Badge. Auth: viewable by anyone with read access to the project (owner + ro/rw group members). Same guard as the project detail page. Linked from the project page header so it's discoverable without typing the URL. No nav-bar link — it's a per-project artifact, not a global view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> | 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<ActivityRow[]>`
|
||||
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<string, { name: string | null; email: string | null }>();
|
||||
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 (
|
||||
<Container className="pt-6 max-w-4xl">
|
||||
<PageHeader
|
||||
title="Activity"
|
||||
description={
|
||||
<>
|
||||
<span className="font-mono">{project.key}</span>
|
||||
{" · "}
|
||||
<span>{rows.length} event{rows.length === 1 ? "" : "s"}</span>
|
||||
{rows.length === ROW_LIMIT ? <span> (most recent first)</span> : null}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button type="button" variant="secondary">Back to project</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No activity yet"
|
||||
description="Memory writes, snippet edits, and share changes will show up here as they happen."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<ol className="divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="px-4 py-3 flex items-baseline gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-fg">
|
||||
<strong className="font-medium">{actorLabel(row)}</strong>{" "}
|
||||
<span className="text-fg-muted">{describeAction(row)}</span>
|
||||
</div>
|
||||
{renderPayloadSummary(row)}
|
||||
</div>
|
||||
<div className="text-xs text-fg-subtle whitespace-nowrap" title={row.createdAt.toString()}>
|
||||
{formatRelative(row.createdAt)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? (
|
||||
<Link href={`/memories/${row.entityId}`} className="no-underline">
|
||||
a memory
|
||||
</Link>
|
||||
) : (
|
||||
"a memory"
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "memory.update": {
|
||||
const fields = (row.payload?.fields as string[] | undefined) ?? [];
|
||||
const fieldList = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
||||
return (
|
||||
<>
|
||||
edited{" "}
|
||||
{row.entityId ? (
|
||||
<Link href={`/memories/${row.entityId}`} className="no-underline">
|
||||
a memory
|
||||
</Link>
|
||||
) : (
|
||||
"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 ? <code className="text-fg">{name}</code> : <em>(unnamed)</em>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "snippet.delete": {
|
||||
const name = (row.payload?.name as string | undefined) ?? null;
|
||||
return (
|
||||
<>
|
||||
deleted snippet{" "}
|
||||
{name ? <code className="text-fg">{name}</code> : <em>(unnamed)</em>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 <strong className="font-medium">{groupName}</strong>{" "}
|
||||
<Badge tone={access === "rw" ? "success" : "neutral"}>{access}</Badge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "project.share.update": {
|
||||
const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)";
|
||||
const access = (row.payload?.access as string | undefined) ?? "?";
|
||||
return (
|
||||
<>
|
||||
changed <strong className="font-medium">{groupName}</strong>{"'s "}access to{" "}
|
||||
<Badge tone={access === "rw" ? "success" : "neutral"}>{access}</Badge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "project.share.remove": {
|
||||
const groupName = (row.payload?.groupName as string | undefined) ?? "(unknown group)";
|
||||
return (
|
||||
<>
|
||||
stopped sharing with <strong className="font-medium">{groupName}</strong>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="text-xs text-fg-subtle mt-1">
|
||||
{scope ? (
|
||||
<span>
|
||||
scope: {scope.from} → {scope.to}
|
||||
</span>
|
||||
) : null}
|
||||
{scope && projectKey ? <span> · </span> : null}
|
||||
{projectKey ? (
|
||||
<span>
|
||||
project: {projectKey.from ?? "—"} → {projectKey.to ?? "—"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -183,6 +183,12 @@ export default async function ProjectDetailPage({
|
||||
<Link href="/projects" className="no-underline">
|
||||
<Button type="button" variant="secondary">All projects</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(project.key)}/activity`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button type="button" variant="secondary">Activity</Button>
|
||||
</Link>
|
||||
{canWrite ? (
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
|
||||
Reference in New Issue
Block a user