Implement Workstream J: Org procedures (backend)
Add complete organization management procedures: - orgs.list, create, get, update, delete, leave - orgs.members.list, updateRole, remove - orgs.invites.list, create, cancel, accept - orgs.sites.list Key features: - Role-based access control (owner > admin > member) - Transaction-protected owner count checks to prevent race conditions - Privilege escalation prevention (only owners can invite owners) - Graceful constraint violation handling with friendly error messages - Email sending for org invitations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
158
apps/api-server/src/procedures/orgs/members.ts
Normal file
158
apps/api-server/src/procedures/orgs/members.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user