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:
124
apps/api-server/src/procedures/orgs/helpers.ts
Normal file
124
apps/api-server/src/procedures/orgs/helpers.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user