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,124 @@
/**
* Helper functions for org procedures
* Provides org lookup, membership verification, and role checks
*/
import type { DB, OrgRole } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import { ORPCError } from "@orpc/server";
// ===== Types =====
/** Org info returned from lookup */
export interface OrgInfo {
id: number;
slug: string;
displayName: string;
logoUrl: string | null;
createdAt: Date;
}
/** User's membership in an org */
export interface OrgMembership {
id: number;
role: OrgRole;
createdAt: Date;
}
// ===== Role Hierarchy =====
const ROLE_LEVELS: Record<OrgRole, number> = {
owner: 3,
admin: 2,
member: 1,
};
// ===== Helper Functions =====
/**
* Look up an org by slug
* @throws NOT_FOUND if org doesn't exist
*/
export async function lookupOrgBySlug(
db: Kysely<DB>,
slug: string,
): Promise<OrgInfo> {
const org = await db
.selectFrom("orgs")
.select(["id", "slug", "display_name", "logo_url", "created_at"])
.where("slug", "=", slug)
.executeTakeFirst();
if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
}
return {
id: org.id,
slug: org.slug,
displayName: org.display_name,
logoUrl: org.logo_url,
createdAt: org.created_at,
};
}
/**
* Get a user's membership in an org
* @throws FORBIDDEN if user is not a member
*/
export async function getMembership(
db: Kysely<DB>,
orgId: number,
userId: number,
): Promise<OrgMembership> {
const membership = await db
.selectFrom("org_members")
.select(["id", "role", "created_at"])
.where("org_id", "=", orgId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!membership) {
throw new ORPCError("FORBIDDEN", {
message: "You are not a member of this organization",
});
}
return {
id: membership.id,
role: membership.role,
createdAt: membership.created_at,
};
}
/**
* Verify user has the required role or higher
* Role hierarchy: owner > admin > member
* @throws FORBIDDEN if user lacks required role
*/
export function requireRole(
membership: OrgMembership,
requiredRole: OrgRole,
): void {
if (ROLE_LEVELS[membership.role] < ROLE_LEVELS[requiredRole]) {
throw new ORPCError("FORBIDDEN", { message: "Insufficient permissions" });
}
}
/**
* Count the number of owners in an org
* Used to prevent removing/demoting the last owner
*/
export async function countOwners(
db: Kysely<DB>,
orgId: number,
): Promise<number> {
const result = await db
.selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count"))
.where("org_id", "=", orgId)
.where("role", "=", "owner")
.executeTakeFirstOrThrow();
return Number(result.count);
}