Files
DiscordClone/convex/audit.ts
Bryan1029384756 593eaba82e 1.1.3
2026-04-18 15:41:51 -05:00

128 lines
3.8 KiB
TypeScript

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",
} 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<DataModel>,
args: {
actorId: Id<"userProfiles">;
action: string;
targetType?: string;
targetId?: string;
targetName?: string;
metadata?: unknown;
},
): Promise<void> {
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<DataModel>,
userId: Id<"userProfiles">,
): Promise<boolean> {
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 },
}));
},
});