import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import type { GenericQueryCtx } from "convex/server"; import type { DataModel, Id } from "./_generated/dataModel"; import { hasPermission, getRolesForUser } from "./roles"; import { AUDIT_ACTIONS, logAudit } from "./audit"; import { getPublicStorageUrl } from "./storageUrl"; /** * Returns true if the user is currently banned. Callers should * surface this as an explicit `{ error: "Banned" }` at auth time or * throw from sensitive mutations like `messages.send`. */ export async function isBanned( ctx: GenericQueryCtx, userId: Id<"userProfiles">, ): Promise { const row = await ctx.db .query("bans") .withIndex("by_user", (q) => q.eq("userId", userId)) .first(); return !!row; } export const isUserBanned = query({ args: { userId: v.id("userProfiles") }, returns: v.boolean(), handler: async (ctx, args) => isBanned(ctx, args.userId), }); // List all bans with actor + target enrichment for the Bans tab. export const list = query({ args: { actorId: v.id("userProfiles") }, returns: v.array(v.any()), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "ban_members"))) { throw new Error("You don't have permission to view bans."); } const bans = await ctx.db.query("bans").collect(); const out = []; for (const b of bans) { const user = await ctx.db.get(b.userId); const actor = await ctx.db.get(b.bannedBy); let avatarUrl: string | null = null; if (user?.avatarStorageId) { avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId); } out.push({ _id: b._id, userId: b.userId, bannedBy: b.bannedBy, reason: b.reason ?? null, createdAt: b.createdAt, user: user ? { username: user.username, displayName: user.displayName, avatarUrl, } : null, actor: actor ? { username: actor.username, displayName: actor.displayName } : null, }); } out.sort((a, b) => b.createdAt - a.createdAt); return out; }, }); export const ban = mutation({ args: { actorId: v.id("userProfiles"), userId: v.id("userProfiles"), reason: v.optional(v.string()), }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "ban_members"))) { throw new Error("You don't have permission to ban members."); } if (args.actorId === args.userId) { throw new Error("You can't ban yourself."); } const target = await ctx.db.get(args.userId); if (!target) throw new Error("User not found."); // Refuse to ban isAdmin or Owner-role bearers. Single-server // deployment can't afford to lock itself out of administration. if (target.isAdmin) { throw new Error("Server admins can't be banned."); } const targetRoles = await getRolesForUser(ctx, args.userId); if (targetRoles.some((r) => r.name === "Owner")) { throw new Error("The Owner can't be banned."); } const existing = await ctx.db .query("bans") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .first(); if (existing) { // Update reason + actor if re-banning. await ctx.db.patch(existing._id, { bannedBy: args.actorId, reason: args.reason, createdAt: Date.now(), }); } else { await ctx.db.insert("bans", { userId: args.userId, bannedBy: args.actorId, reason: args.reason, createdAt: Date.now(), }); } await logAudit(ctx, { actorId: args.actorId, action: AUDIT_ACTIONS.BAN_ADD, targetType: "user", targetId: args.userId, targetName: target.displayName ?? target.username, metadata: args.reason ? { reason: args.reason } : undefined, }); return { success: true }; }, }); export const unban = mutation({ args: { actorId: v.id("userProfiles"), userId: v.id("userProfiles"), }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { if (!(await hasPermission(ctx, args.actorId, "ban_members"))) { throw new Error("You don't have permission to unban members."); } const row = await ctx.db .query("bans") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .first(); if (!row) return { success: true }; const target = await ctx.db.get(args.userId); await ctx.db.delete(row._id); await logAudit(ctx, { actorId: args.actorId, action: AUDIT_ACTIONS.BAN_REMOVE, targetType: "user", targetId: args.userId, targetName: target?.displayName ?? target?.username, }); return { success: true }; }, });