- Fix template literal expressions: wrap Date.now() in String() - Add missing afterAll import in admin.test.ts - Fix countOwners to use countAll() without misleading <number> type - Add ast-grep rule to prevent countAll<number>() usage - Fix formatting issues from merge conflict resolution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
126 lines
2.8 KiB
TypeScript
126 lines
2.8 KiB
TypeScript
/**
|
|
* 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().as("count"))
|
|
.where("org_id", "=", orgId)
|
|
.where("role", "=", "owner")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
// PostgreSQL COUNT returns bigint (string), convert to number
|
|
return Number(result.count);
|
|
}
|