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:
RevIQ
2026-01-09 16:50:29 +08:00
parent 2d445cc47b
commit 9cf95095c3
8 changed files with 765 additions and 73 deletions

View 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,
};
});