/** * Org member management procedures - list, updateRole, remove */ import { ORPCError } from "@orpc/server"; import { authMiddleware, os } from "../base.js"; import { countOwners, getMembership, lookupOrgBySlug, requireRole, } from "./helpers.js"; /** * List all members of an org * Any member can view the member list */ export const membersList = os.orgs.members.list .use(authMiddleware) .handler(async ({ input, context }) => { const { slug } = input; // Lookup org and verify membership const org = await lookupOrgBySlug(context.db, slug); await getMembership(context.db, org.id, context.user.id); // Get members with user details const members = await context.db .selectFrom("org_members") .innerJoin("users", "users.id", "org_members.user_id") .where("org_members.org_id", "=", org.id) .select([ "org_members.id", "org_members.user_id", "org_members.role", "org_members.created_at", "users.email", "users.display_name", ]) .orderBy("org_members.created_at", "asc") .execute(); return members.map((m) => ({ id: m.id, userId: m.user_id, email: m.email, displayName: m.display_name, role: m.role, createdAt: m.created_at, })); }); /** * Update a member's role * Only owners can change roles * Uses transaction to prevent race condition when demoting owners */ export const membersUpdateRole = os.orgs.members.updateRole .use(authMiddleware) .handler(async ({ input, context }) => { const { slug, userId, role: newRole } = input; // Lookup org and verify ownership const org = await lookupOrgBySlug(context.db, slug); const membership = await getMembership(context.db, org.id, context.user.id); requireRole(membership, "owner"); await context.db.transaction().execute(async (trx) => { // Get the target member's current membership const targetMember = await trx .selectFrom("org_members") .select(["id", "role"]) .where("org_id", "=", org.id) .where("user_id", "=", userId) .executeTakeFirst(); if (!targetMember) { throw new ORPCError("NOT_FOUND", { message: "Member not found" }); } // If demoting an owner, check if they're the last one if (targetMember.role === "owner" && newRole !== "owner") { const ownerCount = await countOwners(trx, org.id); if (ownerCount === 1) { throw new ORPCError("BAD_REQUEST", { message: "Cannot demote the only owner", }); } } // Update the role await trx .updateTable("org_members") .set({ role: newRole }) .where("id", "=", targetMember.id) .execute(); }); }); /** * Remove a member from an org * Owners can remove anyone, admins can only remove members * Uses transaction to prevent race condition when removing owners */ export const membersRemove = os.orgs.members.remove .use(authMiddleware) .handler(async ({ input, context }) => { const { slug, userId } = input; // Lookup org and verify membership const org = await lookupOrgBySlug(context.db, slug); const membership = await getMembership(context.db, org.id, context.user.id); await context.db.transaction().execute(async (trx) => { // Get the target member const targetMember = await trx .selectFrom("org_members") .select(["id", "role"]) .where("org_id", "=", org.id) .where("user_id", "=", userId) .executeTakeFirst(); if (!targetMember) { throw new ORPCError("NOT_FOUND", { message: "Member not found" }); } // Permission checks if (membership.role === "owner") { // Owners can remove anyone except the last owner if (targetMember.role === "owner") { const ownerCount = await countOwners(trx, org.id); if (ownerCount === 1) { throw new ORPCError("BAD_REQUEST", { message: "Cannot remove the only owner", }); } } } else if (membership.role === "admin") { // Admins can only remove members (not owners or other admins) if (targetMember.role !== "member") { throw new ORPCError("FORBIDDEN", { message: "Admins can only remove members", }); } } else { // Members cannot remove anyone throw new ORPCError("FORBIDDEN", { message: "Insufficient permissions", }); } // Remove the member await trx .deleteFrom("org_members") .where("id", "=", targetMember.id) .execute(); }); });