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:
95
apps/api-server/src/procedures/orgs/management.ts
Normal file
95
apps/api-server/src/procedures/orgs/management.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Org management procedures - update, delete, leave
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import {
|
||||
countOwners,
|
||||
getMembership,
|
||||
lookupOrgBySlug,
|
||||
requireRole,
|
||||
} from "./helpers.js";
|
||||
|
||||
/**
|
||||
* Update org details
|
||||
* Requires admin or owner role
|
||||
*/
|
||||
export const orgsUpdate = os.orgs.update
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, displayName, logoUrl } = input;
|
||||
|
||||
// Lookup org and verify membership with admin+ role
|
||||
const org = await lookupOrgBySlug(context.db, slug);
|
||||
const membership = await getMembership(context.db, org.id, context.user.id);
|
||||
requireRole(membership, "admin");
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updates: Record<string, unknown> = { updated_at: new Date() };
|
||||
if (displayName !== undefined) {
|
||||
updates.display_name = displayName;
|
||||
}
|
||||
if (logoUrl !== undefined) {
|
||||
updates.logo_url = logoUrl;
|
||||
}
|
||||
|
||||
await context.db
|
||||
.updateTable("orgs")
|
||||
.set(updates)
|
||||
.where("id", "=", org.id)
|
||||
.execute();
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete an org
|
||||
* Requires owner role
|
||||
* FK CASCADE handles deleting members, invites, and sites
|
||||
*/
|
||||
export const orgsDelete = os.orgs.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = 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.deleteFrom("orgs").where("id", "=", org.id).execute();
|
||||
});
|
||||
|
||||
/**
|
||||
* Leave an org
|
||||
* Cannot leave if you're the only owner
|
||||
* Uses transaction to prevent race condition where multiple owners leave simultaneously
|
||||
*/
|
||||
export const orgsLeave = os.orgs.leave
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug } = input;
|
||||
|
||||
// Lookup org and get 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) => {
|
||||
// If user is an owner, check if they're the last one (with lock)
|
||||
if (membership.role === "owner") {
|
||||
const ownerCount = await countOwners(trx, org.id);
|
||||
if (ownerCount === 1) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Cannot leave as the only owner. Transfer ownership or delete the organization.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove membership
|
||||
await trx
|
||||
.deleteFrom("org_members")
|
||||
.where("org_id", "=", org.id)
|
||||
.where("user_id", "=", context.user.id)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user