Files
publisher-dashboard/apps/api-server/src/procedures/orgs/members.ts
RevIQ 9cf95095c3 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>
2026-01-09 16:50:29 +08:00

159 lines
4.6 KiB
TypeScript

/**
* 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();
});
});