128 lines
3.8 KiB
TypeScript
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 },
|
|
}));
|
|
},
|
|
});
|