import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; import { GenericQueryCtx } from "convex/server"; import { DataModel, Id, Doc } from "./_generated/dataModel"; const PERMISSION_KEYS = [ "manage_channels", "manage_roles", "manage_messages", "create_invite", "embed_links", "attach_files", "move_members", "mute_members", "manage_nicknames", ] as const; export async function getRolesForUser( ctx: GenericQueryCtx, userId: Id<"userProfiles"> ): Promise[]> { const assignments = await ctx.db .query("userRoles") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect(); const roles = await Promise.all( assignments.map((ur) => ctx.db.get(ur.roleId)) ); return roles.filter((r): r is Doc<"roles"> => r !== null); } // List all roles export const list = query({ args: {}, returns: v.array(v.any()), handler: async (ctx) => { const roles = await ctx.db.query("roles").collect(); return roles.sort((a, b) => (b.position || 0) - (a.position || 0)); }, }); // Create new role export const create = mutation({ args: { name: v.optional(v.string()), color: v.optional(v.string()), permissions: v.optional(v.any()), position: v.optional(v.number()), isHoist: v.optional(v.boolean()), }, returns: v.any(), handler: async (ctx, args) => { const id = await ctx.db.insert("roles", { name: args.name || "new role", color: args.color || "#99aab5", position: args.position || 0, permissions: args.permissions || {}, isHoist: args.isHoist || false, }); return await ctx.db.get(id); }, }); // Update role properties export const update = mutation({ args: { id: v.id("roles"), name: v.optional(v.string()), color: v.optional(v.string()), permissions: v.optional(v.any()), position: v.optional(v.number()), isHoist: v.optional(v.boolean()), }, returns: v.any(), handler: async (ctx, args) => { const role = await ctx.db.get(args.id); if (!role) throw new Error("Role not found"); // Owner is frozen — we can't let a client rename/recolour it // or strip its permissions, since that would defeat the // assign/unassign guards in one shot. if (role.name === "Owner") { throw new Error("The Owner role can't be edited."); } const { id, ...fields } = args; const updates: Record = {}; for (const [key, value] of Object.entries(fields)) { if (value !== undefined) updates[key] = value; } if (Object.keys(updates).length > 0) { await ctx.db.patch(id, updates); } return await ctx.db.get(id); }, }); /** * Batch-reorder roles by passing a list of `{id, position}` pairs. * Used by the Roles & Permissions settings surface after a drag- * drop drop. Owner and @everyone are refused so their positions * stay pinned at the natural extremes of the list. */ export const reorder = mutation({ args: { updates: v.array( v.object({ id: v.id("roles"), position: v.number(), }), ), }, returns: v.null(), handler: async (ctx, args) => { for (const u of args.updates) { const role = await ctx.db.get(u.id); if (!role) continue; if (role.name === "Owner" || role.name === "@everyone") continue; await ctx.db.patch(u.id, { position: u.position }); } return null; }, }); // Delete role export const remove = mutation({ args: { id: v.id("roles") }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { const role = await ctx.db.get(args.id); if (!role) throw new Error("Role not found"); if (role.name === "Owner") { throw new Error("The Owner role can't be deleted."); } if (role.name === "@everyone") { throw new Error("The @everyone role can't be deleted."); } const assignments = await ctx.db .query("userRoles") .withIndex("by_role", (q) => q.eq("roleId", args.id)) .collect(); for (const a of assignments) { await ctx.db.delete(a._id); } await ctx.db.delete(args.id); return { success: true }; }, }); // List members with roles export const listMembers = query({ args: {}, returns: v.array(v.any()), handler: async (ctx) => { const users = await ctx.db.query("userProfiles").collect(); return await Promise.all( users.map(async (user) => ({ id: user._id, username: user.username, public_identity_key: user.publicIdentityKey, roles: await getRolesForUser(ctx, user._id), })) ); }, }); // Assign role to user export const assign = mutation({ args: { roleId: v.id("roles"), userId: v.id("userProfiles"), }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { // Owner is immutable — it's granted once during first-user // bootstrap (convex/auth.ts) and the app never reassigns it. The // UI already hides it from the ManageRoles checkbox list, but we // also reject it server-side so a crafted client can't sneak it // onto another user. const role = await ctx.db.get(args.roleId); if (role?.name === "Owner") { throw new Error("The Owner role can't be assigned."); } const existing = await ctx.db .query("userRoles") .withIndex("by_user_and_role", (q) => q.eq("userId", args.userId).eq("roleId", args.roleId) ) .unique(); if (!existing) { await ctx.db.insert("userRoles", { userId: args.userId, roleId: args.roleId, }); } return { success: true }; }, }); // Remove role from user export const unassign = mutation({ args: { roleId: v.id("roles"), userId: v.id("userProfiles"), }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { // Owner is immutable — see `assign` above. Removing it would // leave the server without a permission-bearing admin. const role = await ctx.db.get(args.roleId); if (role?.name === "Owner") { throw new Error("The Owner role can't be removed."); } const existing = await ctx.db .query("userRoles") .withIndex("by_user_and_role", (q) => q.eq("userId", args.userId).eq("roleId", args.roleId) ) .unique(); if (existing) { await ctx.db.delete(existing._id); } return { success: true }; }, }); // Get current user's aggregated permissions export const getMyPermissions = query({ args: { userId: v.id("userProfiles") }, returns: v.object({ manage_channels: v.boolean(), manage_roles: v.boolean(), manage_messages: v.boolean(), create_invite: v.boolean(), embed_links: v.boolean(), attach_files: v.boolean(), move_members: v.boolean(), mute_members: v.boolean(), manage_nicknames: v.boolean(), }), handler: async (ctx, args) => { const roles = await getRolesForUser(ctx, args.userId); const finalPerms: Record = {}; for (const key of PERMISSION_KEYS) { finalPerms[key] = roles.some( (role) => (role.permissions as Record)?.[key] ); } return finalPerms as { manage_channels: boolean; manage_roles: boolean; manage_messages: boolean; create_invite: boolean; embed_links: boolean; attach_files: boolean; move_members: boolean; mute_members: boolean; manage_nicknames: boolean; }; }, });