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:
100
apps/api-server/src/procedures/orgs/basic.ts
Normal file
100
apps/api-server/src/procedures/orgs/basic.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Basic org procedures - list, create, get
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug } from "./helpers.js";
|
||||
|
||||
/**
|
||||
* List all orgs the current user is a member of
|
||||
*/
|
||||
export const orgsList = os.orgs.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const orgs = await context.db
|
||||
.selectFrom("org_members")
|
||||
.innerJoin("orgs", "orgs.id", "org_members.org_id")
|
||||
.where("org_members.user_id", "=", context.user.id)
|
||||
.select([
|
||||
"orgs.id",
|
||||
"orgs.slug",
|
||||
"orgs.display_name",
|
||||
"orgs.logo_url",
|
||||
"orgs.created_at",
|
||||
])
|
||||
.orderBy("orgs.created_at", "desc")
|
||||
.execute();
|
||||
|
||||
return orgs.map((o) => ({
|
||||
id: o.id,
|
||||
slug: o.slug,
|
||||
displayName: o.display_name,
|
||||
logoUrl: o.logo_url,
|
||||
createdAt: o.created_at,
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new org
|
||||
* The creating user becomes the owner
|
||||
*/
|
||||
export const orgsCreate = os.orgs.create
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { slug, displayName } = input;
|
||||
|
||||
try {
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Create the org
|
||||
const org = await trx
|
||||
.insertInto("orgs")
|
||||
.values({
|
||||
slug,
|
||||
display_name: displayName,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Add the creating user as owner
|
||||
await trx
|
||||
.insertInto("org_members")
|
||||
.values({
|
||||
org_id: org.id,
|
||||
user_id: context.user.id,
|
||||
role: "owner",
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
return { slug };
|
||||
} catch (error) {
|
||||
// Handle unique constraint violation on slug
|
||||
if (error instanceof Error && error.message.includes("orgs_slug_key")) {
|
||||
throw new ORPCError("CONFLICT", { message: "Slug already in use" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single org by slug
|
||||
* Requires membership
|
||||
*/
|
||||
export const orgsGet = os.orgs.get
|
||||
.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);
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
displayName: org.displayName,
|
||||
logoUrl: org.logoUrl,
|
||||
createdAt: org.createdAt,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user