Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/migrations/20260728_audit_events.sql
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 20 additions & 0 deletions backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }));
114 changes: 114 additions & 0 deletions backend/src/lib/audit.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createServerSupabase>;

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<string, unknown> | null;
};

export async function recordAudit(db: Db, event: AuditEventInput): Promise<void> {
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<string, unknown>;
},
events: unknown[] | null | undefined,
): Promise<void> {
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,
});
}
}
127 changes: 127 additions & 0 deletions backend/src/routes/audit.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createServerSupabase>,
userId: string,
email: string | undefined,
): Promise<string[]> {
const ids = new Set<string>();
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<string, unknown>, 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<typeof createServerSupabase>,
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<string, unknown>, 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<string, unknown>, 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<string, unknown>[]).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"));
});
27 changes: 27 additions & 0 deletions backend/src/routes/chat.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions backend/src/routes/documents.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions backend/src/routes/projectChat.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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", {
Expand Down
Loading
Loading