import { query } from "./_generated/server"; import { v } from "convex/values"; import type { GenericMutationCtx, GenericQueryCtx, } from "convex/server"; import type { DataModel, Id } from "./_generated/dataModel"; import { hasPermission } from "./roles"; import { getPublicStorageUrl } from "./storageUrl"; /** * Known audit actions. Client-facing label mapping lives in the * settings UI; the backend just stores strings so future actions * don't require a schema change. */ export const AUDIT_ACTIONS = { CHANNEL_CREATE: "channel.create", CHANNEL_DELETE: "channel.delete", CHANNEL_RENAME: "channel.rename", CHANNEL_UPDATE_TOPIC: "channel.update_topic", ROLE_CREATE: "role.create", ROLE_DELETE: "role.delete", ROLE_UPDATE: "role.update", ROLE_ASSIGN: "role.assign", ROLE_UNASSIGN: "role.unassign", SERVER_SETTINGS_UPDATE: "server.settings_update", BAN_ADD: "ban.add", BAN_REMOVE: "ban.remove", MESSAGES_PURGE_ALL: "messages.purge_all", MESSAGES_IMPORT_BULK: "messages.import_bulk", GHOST_MERGE: "user.ghost_merge", KEYS_GRANT: "keys.grant", } as const; /** * Internal helper — call from mutations that mutate server state. * Silent on failure: an audit-write that throws would roll back the * real mutation, which is worse than a missing log entry. */ export async function logAudit( ctx: GenericMutationCtx, args: { actorId: Id<"userProfiles">; action: string; targetType?: string; targetId?: string; targetName?: string; metadata?: unknown; }, ): Promise { try { await ctx.db.insert("auditLog", { actorId: args.actorId, action: args.action, targetType: args.targetType, targetId: args.targetId, targetName: args.targetName, metadata: args.metadata, createdAt: Date.now(), }); } catch { // Audit is best-effort. Don't block the caller. } } // Any moderator-adjacent permission is enough to view the log. We // don't want to leak the log to @everyone but equally don't want to // hide it behind a narrow permission no role has by default. async function canViewAuditLog( ctx: GenericQueryCtx, userId: Id<"userProfiles">, ): Promise { return ( (await hasPermission(ctx, userId, "ban_members")) || (await hasPermission(ctx, userId, "manage_channels")) || (await hasPermission(ctx, userId, "manage_roles")) || (await hasPermission(ctx, userId, "manage_messages")) ); } export const list = query({ args: { actorId: v.id("userProfiles"), limit: v.optional(v.number()), }, returns: v.array(v.any()), handler: async (ctx, args) => { if (!(await canViewAuditLog(ctx, args.actorId))) { throw new Error("Not authorized to view the audit log."); } const limit = Math.min(Math.max(args.limit ?? 200, 1), 500); const rows = await ctx.db .query("auditLog") .withIndex("by_created_at") .order("desc") .take(limit); // Walk rows once, de-duping actors via the map itself. Iterating // a Set widens the element type and breaks `ctx.db.get`'s // narrowing — using the map as its own seen-set avoids that. const actors = new Map< string, { username: string; displayName?: string; avatarUrl: string | null } >(); for (const r of rows) { if (actors.has(r.actorId)) continue; const user = await ctx.db.get(r.actorId); if (!user) continue; let avatarUrl: string | null = null; if (user.avatarStorageId) { avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId); } actors.set(r.actorId, { username: user.username, displayName: user.displayName, avatarUrl, }); } return rows.map((r) => ({ _id: r._id, action: r.action, targetType: r.targetType, targetId: r.targetId, targetName: r.targetName, metadata: r.metadata, createdAt: r.createdAt, actor: actors.get(r.actorId) ?? { username: "unknown", avatarUrl: null }, })); }, });