Files
publisher-dashboard/apps/api-server/src/procedures/orgs/helpers.ts
igm 6fa4da1abb Fix lint errors and add ast-grep rule for countAll
- 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>
2026-01-12 13:40:06 +08:00

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