diff --git a/backend/migrations/20260728_audit_events.sql b/backend/migrations/20260728_audit_events.sql new file mode 100644 index 000000000..21395f47d --- /dev/null +++ b/backend/migrations/20260728_audit_events.sql @@ -0,0 +1,20 @@ +-- Audit history of user actions (Clue custom; queried via the service-role +-- backend only — no RLS policies needed, like other app tables). +create table if not exists public.audit_events ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + user_id uuid not null, + user_email text, + action text not null, -- chat.message | document.uploaded | document.generated | document.edited | workflow.applied | tabular.created | tabular.generated | export.chats | export.account | export.tabular + status text not null default 'completed', -- completed | cancelled | failed + title text, + surface text, -- assistant | project | tabular | workflows | account + project_id uuid, + chat_id uuid, + document_id uuid, + review_id uuid, + model text, + detail jsonb +); +create index if not exists audit_events_user_created on public.audit_events (user_id, created_at desc); +create index if not exists audit_events_project_created on public.audit_events (project_id, created_at desc); diff --git a/backend/schema.sql b/backend/schema.sql index 6819473b9..25f31c9d7 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -896,3 +896,23 @@ grant select, insert, update, delete grant usage, select on all sequences in schema public to service_role; + +-- Audit history of user actions (queried via the service-role backend only). +create table if not exists public.audit_events ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + user_id uuid not null, + user_email text, + action text not null, + status text not null default 'completed', + title text, + surface text, + project_id uuid, + chat_id uuid, + document_id uuid, + review_id uuid, + model text, + detail jsonb +); +create index if not exists audit_events_user_created on public.audit_events (user_id, created_at desc); +create index if not exists audit_events_project_created on public.audit_events (project_id, created_at desc); diff --git a/backend/src/app.ts b/backend/src/app.ts index 19cb5c3a0..0565bd389 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,6 +13,7 @@ import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; import { caseLawRouter } from "./routes/caseLaw"; +import { auditRouter } from "./routes/audit"; export const app = express(); const isProduction = process.env.NODE_ENV === "production"; @@ -157,5 +158,6 @@ app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); app.use("/case-law", caseLawRouter); +app.use("/audit", auditRouter); app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/lib/audit.ts b/backend/src/lib/audit.ts new file mode 100644 index 000000000..fa6d8e048 --- /dev/null +++ b/backend/src/lib/audit.ts @@ -0,0 +1,114 @@ +// Audit history of user actions -> public.audit_events (see the 20260728 +// migration). Fire-and-forget by design: recording an event must NEVER throw +// or block the user-facing path — failures are logged and swallowed. + +import type { createServerSupabase } from "./supabase"; + +type Db = ReturnType; + +export type AuditStatus = "completed" | "cancelled" | "failed"; + +export type AuditEventInput = { + userId: string; + userEmail?: string | null; + action: string; + status?: AuditStatus; + title?: string | null; + surface?: string | null; + projectId?: string | null; + chatId?: string | null; + documentId?: string | null; + reviewId?: string | null; + model?: string | null; + detail?: Record | null; +}; + +export async function recordAudit(db: Db, event: AuditEventInput): Promise { + try { + const { error } = await db.from("audit_events").insert({ + user_id: event.userId, + user_email: event.userEmail ?? null, + action: event.action, + status: event.status ?? "completed", + title: event.title?.slice(0, 300) ?? null, + surface: event.surface ?? null, + project_id: event.projectId ?? null, + chat_id: event.chatId ?? null, + document_id: event.documentId ?? null, + review_id: event.reviewId ?? null, + model: event.model ?? null, + detail: event.detail ?? null, + }); + if (error) console.error("[audit] insert failed:", error.message); + } catch (err) { + console.error("[audit] insert threw:", err instanceof Error ? err.message : err); + } +} + +/** Shape of the persisted assistant events we mine for artifact actions. */ +type TurnEvent = { + type?: string; + filename?: string; + document_id?: string; + title?: string; + workflow_id?: string; +}; + +/** + * Record one chat turn: a chat.message row plus one row per artifact the turn + * produced (generated/edited/replicated documents, applied workflows). + */ +export async function recordChatTurn( + db: Db, + base: { + userId: string; + userEmail?: string | null; + chatId: string | null; + projectId?: string | null; + title?: string | null; + model?: string | null; + status?: AuditStatus; + flags?: Record; + }, + events: unknown[] | null | undefined, +): Promise { + const surface = base.projectId ? "project" : "assistant"; + await recordAudit(db, { + userId: base.userId, + userEmail: base.userEmail, + action: "chat.message", + status: base.status ?? "completed", + title: base.title, + surface, + projectId: base.projectId ?? null, + chatId: base.chatId, + model: base.model, + detail: base.flags && Object.keys(base.flags).length ? base.flags : null, + }); + for (const raw of events ?? []) { + const ev = raw as TurnEvent; + const action = + ev?.type === "doc_created" + ? "document.generated" + : ev?.type === "doc_edited" + ? "document.edited" + : ev?.type === "doc_replicated" + ? "document.generated" + : ev?.type === "workflow_applied" + ? "workflow.applied" + : null; + if (!action) continue; + await recordAudit(db, { + userId: base.userId, + userEmail: base.userEmail, + action, + title: ev.filename ?? ev.title ?? null, + surface, + projectId: base.projectId ?? null, + chatId: base.chatId, + documentId: ev.document_id ?? null, + model: base.model, + detail: ev.workflow_id ? { workflow_id: ev.workflow_id } : null, + }); + } +} diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts new file mode 100644 index 000000000..aedde11c3 --- /dev/null +++ b/backend/src/routes/audit.ts @@ -0,0 +1,127 @@ +// Audit history — GET /audit (JSON, paginated) + GET /audit/export (CSV). +// Visibility: the caller's own events, plus events in projects they own or +// that are shared with their email. + +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { createServerSupabase } from "../lib/supabase"; + +export const auditRouter = Router(); +auditRouter.use(requireAuth); + +const PAGE_SIZE = 50; +const EXPORT_LIMIT = 2000; + +async function accessibleProjectIds( + db: ReturnType, + userId: string, + email: string | undefined, +): Promise { + const ids = new Set(); + const own = await db.from("projects").select("id").eq("user_id", userId); + for (const row of (own.data ?? []) as { id: string }[]) ids.add(row.id); + if (email) { + const shared = await db + .from("projects") + .select("id") + .contains("shared_with", [email]); + for (const row of (shared.data ?? []) as { id: string }[]) ids.add(row.id); + } + return [...ids]; +} + +type AuditQuery = { + q?: string; + action?: string; + from?: string; + to?: string; + page: number; + limit: number; +}; + +function parseQuery(raw: Record, limit: number): AuditQuery { + const str = (v: unknown) => + typeof v === "string" && v.trim() ? v.trim() : undefined; + const page = Math.max(Number.parseInt(String(raw.page ?? "1"), 10) || 1, 1); + return { + q: str(raw.q)?.slice(0, 200), + action: str(raw.action)?.slice(0, 60), + from: str(raw.from), + to: str(raw.to), + page, + limit, + }; +} + +async function queryEvents( + db: ReturnType, + userId: string, + email: string | undefined, + q: AuditQuery, +) { + const projectIds = await accessibleProjectIds(db, userId, email); + let query = db + .from("audit_events") + .select( + "id, created_at, user_email, action, status, title, surface, project_id, chat_id, document_id, review_id, model, detail", + { count: "exact" }, + ); + query = projectIds.length + ? query.or( + `user_id.eq.${userId},project_id.in.(${projectIds.join(",")})`, + ) + : query.eq("user_id", userId); + if (q.action) query = query.eq("action", q.action); + if (q.q) query = query.ilike("title", `%${q.q.replace(/[%_]/g, "\\$&")}%`); + if (q.from) query = query.gte("created_at", q.from); + if (q.to) query = query.lte("created_at", `${q.to}T23:59:59.999Z`); + return query + .order("created_at", { ascending: false }) + .range((q.page - 1) * q.limit, q.page * q.limit - 1); +} + +auditRouter.get("/", async (req, res) => { + const userId = res.locals.userId as string; + const email = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + const q = parseQuery(req.query as Record, PAGE_SIZE); + const { data, error, count } = await queryEvents(db, userId, email, q); + if (error) return void res.status(500).json({ detail: error.message }); + res.json({ events: data ?? [], total: count ?? 0, page: q.page, pageSize: PAGE_SIZE }); +}); + +function csvCell(v: unknown): string { + const s = v == null ? "" : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +auditRouter.get("/export", async (req, res) => { + const userId = res.locals.userId as string; + const email = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + const q = parseQuery(req.query as Record, EXPORT_LIMIT); + q.page = 1; + const { data, error } = await queryEvents(db, userId, email, q); + if (error) return void res.status(500).json({ detail: error.message }); + const header = "created_at,user,action,status,title,application,project_id,model"; + const rows = ((data ?? []) as Record[]).map((e) => + [ + e.created_at, + e.user_email, + e.action, + e.status, + e.title, + e.surface, + e.project_id, + e.model, + ] + .map(csvCell) + .join(","), + ); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader( + "Content-Disposition", + 'attachment; filename="history-export.csv"', + ); + res.send([header, ...rows].join("\n")); +}); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 2bb3dfda6..820502bfe 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { recordChatTurn } from "../lib/audit"; import { buildDocContext, buildMessages, @@ -622,9 +623,35 @@ chatRouter.post("/", requireAuth, async (req, res) => { .update({ title: lastUser.content.slice(0, 120) }) .eq("id", chatId); } + + void recordChatTurn( + db, + { + userId, + userEmail, + chatId, + projectId: resolvedProjectId, + title: chatTitle ?? lastUser?.content?.slice(0, 120) ?? null, + model, + }, + persistedEvents, + ); } catch (err) { if (isAbortError(err)) { devLog("[chat/stream] client aborted stream", { chatId }); + void recordChatTurn( + db, + { + userId, + userEmail, + chatId, + projectId: resolvedProjectId, + title: chatTitle, + model, + status: "cancelled", + }, + null, + ); if (err instanceof AssistantStreamError) { const partial = buildCancelledAssistantMessage({ fullText: err.fullText, diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 22ecd2286..88dd67e01 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { recordAudit } from "../lib/audit"; import { buildContentDisposition, downloadFile, @@ -1438,6 +1439,14 @@ export async function handleDocumentUpload( active_version_number: 1, } : updated; + void recordAudit(db, { + userId, + userEmail: res.locals.userEmail as string | undefined, + action: "document.uploaded", + title: filename, + surface: "assistant", + documentId: (updated as { id?: string } | null)?.id ?? null, + }); return void res.status(201).json(responseDoc); } catch (e) { await db.from("documents").update({ status: "error" }).eq("id", doc.id); diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 56ea6efb5..3600348e0 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { recordChatTurn } from "../lib/audit"; import { buildProjectDocContext, buildMessages, @@ -232,6 +233,19 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { .update({ title: lastUser.content.slice(0, 120) }) .eq("id", chatId); } + + void recordChatTurn( + db, + { + userId, + userEmail, + chatId, + projectId, + title: chatTitle ?? lastUser?.content?.slice(0, 120) ?? null, + model, + }, + persistedEvents, + ); } catch (err) { if (isAbortError(err)) { console.log("[project-chat/stream] client aborted stream", { diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 27c28c3d4..5f0d292d8 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { recordAudit } from "../lib/audit"; import { downloadFile } from "../lib/storage"; import { attachActiveVersionPaths, @@ -166,6 +167,15 @@ tabularRouter.post("/", requireAuth, async (req, res) => { ); if (cells.length) await db.from("tabular_cells").insert(cells); + void recordAudit(db, { + userId, + userEmail, + action: "tabular.created", + title: (review as { title?: string | null }).title ?? null, + surface: "tabular", + projectId: project_id ?? null, + reviewId: (review as { id: string }).id, + }); res.status(201).json(review); }); @@ -940,6 +950,13 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { }), ); + void recordAudit(db, { + userId, + userEmail, + action: "tabular.generated", + surface: "tabular", + reviewId, + }); write("data: [DONE]\n\n"); } catch (err) { console.error("[tabular/generate] stream error", safeErrorLog(err)); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index ca77f5952..aa2340bfa 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -2,6 +2,7 @@ import crypto from "crypto"; import { Router } from "express"; import { requireAuth, requireMfaIfEnrolled } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { recordAudit } from "../lib/audit"; import { DEFAULT_TABULAR_MODEL, DEFAULT_TITLE_MODEL, @@ -1062,6 +1063,12 @@ userRouter.get( "Content-Disposition", `attachment; filename="${userExportFilename("account", userId)}"`, ); + void recordAudit(createServerSupabase(), { + userId, + userEmail: res.locals.userEmail as string | undefined, + action: "export.account", + surface: "account", + }); res.json(data); } catch (err) { const detail = errorMessage(err); @@ -1087,6 +1094,12 @@ userRouter.get( "Content-Disposition", `attachment; filename="${userExportFilename("chats", userId)}"`, ); + void recordAudit(createServerSupabase(), { + userId, + userEmail: res.locals.userEmail as string | undefined, + action: "export.chats", + surface: "account", + }); res.json(data); } catch (err) { const detail = errorMessage(err); @@ -1119,6 +1132,12 @@ userRouter.get( "Content-Disposition", `attachment; filename="${userExportFilename("tabular-reviews", userId)}"`, ); + void recordAudit(createServerSupabase(), { + userId, + userEmail: res.locals.userEmail as string | undefined, + action: "export.tabular", + surface: "account", + }); res.json(data); } catch (err) { const detail = errorMessage(err); diff --git a/frontend/public/icons/app-sidebar/history.svg b/frontend/public/icons/app-sidebar/history.svg new file mode 100644 index 000000000..7fcbad561 --- /dev/null +++ b/frontend/public/icons/app-sidebar/history.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/(pages)/history/page.tsx b/frontend/src/app/(pages)/history/page.tsx new file mode 100644 index 000000000..621c44b77 --- /dev/null +++ b/frontend/src/app/(pages)/history/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Download, Loader2, Search } from "lucide-react"; +import { + getAuditHistory, + exportAuditHistory, + type AuditEvent, +} from "@/app/lib/mikeApi"; +import { accountGlassInputClassName } from "../account/accountStyles"; +import { AccountSection } from "../account/AccountSection"; + +const ACTION_LABELS: Record = { + "chat.message": "Chat", + "document.uploaded": "Document upload", + "document.generated": "Generated document", + "document.edited": "Document edit", + "workflow.applied": "Workflow", + "tabular.created": "Tabular review", + "tabular.generated": "Tabular run", + "export.chats": "Chat export", + "export.account": "Account export", + "export.tabular": "Review export", +}; + +const STATUS_STYLES: Record = { + completed: "bg-green-50 text-green-700", + cancelled: "bg-amber-50 text-amber-700", + failed: "bg-red-50 text-red-700", +}; + +function eventHref(e: AuditEvent): string | null { + if (e.chat_id) { + return e.project_id + ? `/projects/${e.project_id}/assistant/chat/${e.chat_id}` + : `/assistant/chat/${e.chat_id}`; + } + if (e.review_id) return `/tabular-reviews/${e.review_id}`; + if (e.project_id) return `/projects/${e.project_id}`; + return null; +} + +export default function HistoryPage() { + return ( +
+
+

+ History +

+
+
+ +
+
+ ); +} + +function HistoryTable() { + const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [exporting, setExporting] = useState(false); + const [q, setQ] = useState(""); + const [action, setAction] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + + const load = useCallback( + async (nextPage: number, append: boolean) => { + setLoading(true); + try { + const out = await getAuditHistory({ + q: q || undefined, + action: action || undefined, + from: from || undefined, + to: to || undefined, + page: nextPage, + }); + setEvents((cur) => (append ? [...cur, ...out.events] : out.events)); + setTotal(out.total); + setPage(nextPage); + } catch { + if (!append) setEvents([]); + } finally { + setLoading(false); + } + }, + [q, action, from, to], + ); + + useEffect(() => { + void load(1, false); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload on filter change only + }, [action, from, to]); + + const handleExport = async () => { + setExporting(true); + try { + const { blob, filename } = await exportAuditHistory({ + q: q || undefined, + action: action || undefined, + from: from || undefined, + to: to || undefined, + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename ?? "history-export.csv"; + a.click(); + URL.revokeObjectURL(url); + } catch { + alert("Export failed."); + } finally { + setExporting(false); + } + }; + + return ( +
+

+ Your actions, plus activity in projects shared with you. +

+ +
+
+ + setQ(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void load(1, false); + }} + placeholder="Search history" + className={`h-9 w-full pl-8 ${accountGlassInputClassName}`} + /> +
+ + setFrom(e.target.value)} + className={`h-9 ${accountGlassInputClassName}`} + aria-label="From date" + /> + setTo(e.target.value)} + className={`h-9 ${accountGlassInputClassName}`} + aria-label="To date" + /> + +
+ + +
+ + + + + + + + + + + + + + {events.map((e) => { + const href = eventHref(e); + return ( + + + + + + + + + + ); + })} + {!loading && events.length === 0 && ( + + + + )} + +
UserCreatedTitleStatusTypeApplicationModel
+ {e.user_email ?? "—"} + + {new Date(e.created_at).toLocaleString( + undefined, + { + dateStyle: "medium", + timeStyle: "short", + }, + )} + + {href ? ( + + {e.title ?? "—"} + + ) : ( + (e.title ?? "—") + )} + + + {e.status} + + + {ACTION_LABELS[e.action] ?? e.action} + + {e.surface ?? "—"} + + {e.model ?? "—"} +
+ No history yet — actions appear here as + you use the app. +
+
+ {loading && ( +
+ +
+ )} + {!loading && events.length < total && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index ec46d29a2..570f71e15 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -21,6 +21,7 @@ import { TabularReviewSkeuoIcon, WorkflowSkeuoIcon, } from "@/app/components/shared/AppSidebarSkeuoIcons"; +import { HistorySkeuoIcon } from "@/app/components/shared/HistorySkeuoIcon"; import { ProjectSvgIcon, } from "@/app/components/shared/FolderSvgIcon"; @@ -38,6 +39,7 @@ const NAV_ITEMS = [ { href: "/library", label: "Library", icon: LibrarySkeuoIcon }, { href: "/tabular-reviews", label: "Tabular Review", icon: TabularReviewSkeuoIcon }, { href: "/workflows", label: "Workflows", icon: WorkflowSkeuoIcon }, + { href: "/history", label: "History", icon: HistorySkeuoIcon }, ]; interface AppSidebarProps { diff --git a/frontend/src/app/components/shared/HistorySkeuoIcon.tsx b/frontend/src/app/components/shared/HistorySkeuoIcon.tsx new file mode 100644 index 000000000..9f18142b9 --- /dev/null +++ b/frontend/src/app/components/shared/HistorySkeuoIcon.tsx @@ -0,0 +1,20 @@ +import Image, { type ImageProps } from "next/image"; + +type IconProps = Omit; + +/** History nav icon (Clue custom) — same asset pattern as AppSidebarSkeuoIcons. */ +export function HistorySkeuoIcon({ className, ...props }: IconProps) { + return ( + + ); +} diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 6dbaa1e6e..eafec8fd7 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -240,6 +240,56 @@ export interface UserLookupResult { display_name: string | null; } +// --------------------------------------------------------------------------- +// Audit history +// --------------------------------------------------------------------------- + +export interface AuditEvent { + id: string; + created_at: string; + user_email: string | null; + action: string; + status: string; + title: string | null; + surface: string | null; + project_id: string | null; + chat_id: string | null; + document_id: string | null; + review_id: string | null; + model: string | null; + detail: Record | null; +} + +export async function getAuditHistory(params: { + q?: string; + action?: string; + from?: string; + to?: string; + page?: number; +}): Promise<{ events: AuditEvent[]; total: number; page: number; pageSize: number }> { + const qs = new URLSearchParams(); + if (params.q) qs.set("q", params.q); + if (params.action) qs.set("action", params.action); + if (params.from) qs.set("from", params.from); + if (params.to) qs.set("to", params.to); + if (params.page) qs.set("page", String(params.page)); + return apiRequest(`/audit?${qs.toString()}`); +} + +export async function exportAuditHistory(params: { + q?: string; + action?: string; + from?: string; + to?: string; +}): Promise<{ blob: Blob; filename: string | null }> { + const qs = new URLSearchParams(); + if (params.q) qs.set("q", params.q); + if (params.action) qs.set("action", params.action); + if (params.from) qs.set("from", params.from); + if (params.to) qs.set("to", params.to); + return apiBlobRequest(`/audit/export?${qs.toString()}`); +} + export async function getUserProfile(): Promise { return apiRequest("/user/profile"); }