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

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

View File

@@ -0,0 +1,14 @@
/**
* Org procedures - organization management
*/
export { orgsCreate, orgsGet, orgsList } from "./basic.js";
export {
invitesAccept,
invitesCancel,
invitesCreate,
invitesList,
} from "./invites.js";
export { orgsDelete, orgsLeave, orgsUpdate } from "./management.js";
export { membersList, membersRemove, membersUpdateRole } from "./members.js";
export { sitesList } from "./sites.js";

View File

@@ -0,0 +1,219 @@
/**
* Org invite procedures - list, create, cancel, accept
*/
import { ORPCError } from "@orpc/server";
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
import { sendOrgInviteEmail } from "../../utils/email.js";
import { authMiddleware, os } from "../base.js";
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
/**
* List pending invites for an org
* Requires admin or owner role
*/
export const invitesList = os.orgs.invites.list
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify admin+ role
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
requireRole(membership, "admin");
// Get non-expired invites with inviter details
const invites = await context.db
.selectFrom("org_invites")
.innerJoin("users", "users.id", "org_invites.invited_by")
.where("org_invites.org_id", "=", org.id)
.where("org_invites.expires_at", ">", new Date())
.select([
"org_invites.id",
"org_invites.email",
"org_invites.role",
"org_invites.created_at",
"org_invites.expires_at",
"users.display_name as inviter_name",
"users.email as inviter_email",
])
.orderBy("org_invites.created_at", "desc")
.execute();
return invites.map((i) => ({
id: i.id,
email: i.email,
role: i.role,
invitedBy: i.inviter_name ?? i.inviter_email,
createdAt: i.created_at,
expiresAt: i.expires_at,
}));
});
/**
* Create an invite for a new member
* Requires admin or owner role
* Only owners can invite new owners (privilege escalation prevention)
*/
export const invitesCreate = os.orgs.invites.create
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug, email: rawEmail, role } = input;
const email = rawEmail.toLowerCase();
// Lookup org and verify admin+ role
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
requireRole(membership, "admin");
// Only owners can invite new owners (prevent privilege escalation)
if (role === "owner") {
requireRole(membership, "owner");
}
// Check if user is already a member (by email)
const existingMember = await context.db
.selectFrom("org_members")
.innerJoin("users", "users.id", "org_members.user_id")
.where("org_members.org_id", "=", org.id)
.where("users.email", "=", email)
.select(["org_members.id"])
.executeTakeFirst();
if (existingMember) {
throw new ORPCError("CONFLICT", {
message: "This user is already a member of the organization",
});
}
// Generate invite token and expiry
const token = generateSecureToken();
const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60);
try {
// Create the invite
await context.db
.insertInto("org_invites")
.values({
org_id: org.id,
email,
role,
invited_by: context.user.id,
token,
expires_at: expiresAt,
})
.execute();
} catch (error) {
// Handle unique constraint violation (race condition: another invite was created)
if (
error instanceof Error &&
error.message.includes("org_invites_org_id_email_key")
) {
throw new ORPCError("CONFLICT", {
message: "An invitation is already pending for this email",
});
}
throw error;
}
// Send invitation email
const inviterName = context.user.displayName ?? context.user.email;
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
});
/**
* Cancel a pending invite
* Requires admin or owner role
*/
export const invitesCancel = os.orgs.invites.cancel
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug, inviteId } = input;
// Lookup org and verify admin+ role
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
requireRole(membership, "admin");
// Delete the invite
const result = await context.db
.deleteFrom("org_invites")
.where("id", "=", inviteId)
.where("org_id", "=", org.id)
.executeTakeFirst();
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Invitation not found" });
}
});
/**
* Accept an invitation
* Token-based lookup, requires auth but no org membership
* Handles race condition if user is already a member
*/
export const invitesAccept = os.orgs.invites.accept
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { token } = input;
// Find the invite by token (must not be expired)
const invite = await context.db
.selectFrom("org_invites")
.where("token", "=", token)
.where("expires_at", ">", new Date())
.select(["id", "org_id", "email", "role"])
.executeTakeFirst();
if (!invite) {
throw new ORPCError("NOT_FOUND", {
message: "Invalid or expired invitation",
});
}
// Verify the authenticated user's email matches the invite email
if (context.user.email.toLowerCase() !== invite.email.toLowerCase()) {
throw new ORPCError("FORBIDDEN", {
message: "This invitation was sent to a different email address",
});
}
try {
// Accept the invite in a transaction
await context.db.transaction().execute(async (trx) => {
// Add user as a member
await trx
.insertInto("org_members")
.values({
org_id: invite.org_id,
user_id: context.user.id,
role: invite.role,
})
.execute();
// Delete the invite
await trx
.deleteFrom("org_invites")
.where("id", "=", invite.id)
.execute();
});
} catch (error) {
// Handle unique constraint violation (user is already a member)
if (
error instanceof Error &&
error.message.includes("org_members_org_id_user_id_key")
) {
// Clean up the invite since user is already a member
await context.db
.deleteFrom("org_invites")
.where("id", "=", invite.id)
.execute();
throw new ORPCError("CONFLICT", {
message: "You are already a member of this organization",
});
}
throw error;
}
});

View File

@@ -0,0 +1,95 @@
/**
* Org management procedures - update, delete, leave
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import {
countOwners,
getMembership,
lookupOrgBySlug,
requireRole,
} from "./helpers.js";
/**
* Update org details
* Requires admin or owner role
*/
export const orgsUpdate = os.orgs.update
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug, displayName, logoUrl } = input;
// Lookup org and verify membership with admin+ role
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
requireRole(membership, "admin");
// Build update object with only provided fields
const updates: Record<string, unknown> = { updated_at: new Date() };
if (displayName !== undefined) {
updates.display_name = displayName;
}
if (logoUrl !== undefined) {
updates.logo_url = logoUrl;
}
await context.db
.updateTable("orgs")
.set(updates)
.where("id", "=", org.id)
.execute();
});
/**
* Delete an org
* Requires owner role
* FK CASCADE handles deleting members, invites, and sites
*/
export const orgsDelete = os.orgs.delete
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug } = input;
// Lookup org and verify ownership
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
requireRole(membership, "owner");
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
});
/**
* Leave an org
* Cannot leave if you're the only owner
* Uses transaction to prevent race condition where multiple owners leave simultaneously
*/
export const orgsLeave = os.orgs.leave
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug } = input;
// Lookup org and get membership
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
await context.db.transaction().execute(async (trx) => {
// If user is an owner, check if they're the last one (with lock)
if (membership.role === "owner") {
const ownerCount = await countOwners(trx, org.id);
if (ownerCount === 1) {
throw new ORPCError("BAD_REQUEST", {
message:
"Cannot leave as the only owner. Transfer ownership or delete the organization.",
});
}
}
// Remove membership
await trx
.deleteFrom("org_members")
.where("org_id", "=", org.id)
.where("user_id", "=", context.user.id)
.execute();
});
});

View File

@@ -0,0 +1,158 @@
/**
* Org member management procedures - list, updateRole, remove
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
import {
countOwners,
getMembership,
lookupOrgBySlug,
requireRole,
} from "./helpers.js";
/**
* List all members of an org
* Any member can view the member list
*/
export const membersList = os.orgs.members.list
.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);
// Get members with user details
const members = await context.db
.selectFrom("org_members")
.innerJoin("users", "users.id", "org_members.user_id")
.where("org_members.org_id", "=", org.id)
.select([
"org_members.id",
"org_members.user_id",
"org_members.role",
"org_members.created_at",
"users.email",
"users.display_name",
])
.orderBy("org_members.created_at", "asc")
.execute();
return members.map((m) => ({
id: m.id,
userId: m.user_id,
email: m.email,
displayName: m.display_name,
role: m.role,
createdAt: m.created_at,
}));
});
/**
* Update a member's role
* Only owners can change roles
* Uses transaction to prevent race condition when demoting owners
*/
export const membersUpdateRole = os.orgs.members.updateRole
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug, userId, role: newRole } = input;
// Lookup org and verify ownership
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
requireRole(membership, "owner");
await context.db.transaction().execute(async (trx) => {
// Get the target member's current membership
const targetMember = await trx
.selectFrom("org_members")
.select(["id", "role"])
.where("org_id", "=", org.id)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!targetMember) {
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
}
// If demoting an owner, check if they're the last one
if (targetMember.role === "owner" && newRole !== "owner") {
const ownerCount = await countOwners(trx, org.id);
if (ownerCount === 1) {
throw new ORPCError("BAD_REQUEST", {
message: "Cannot demote the only owner",
});
}
}
// Update the role
await trx
.updateTable("org_members")
.set({ role: newRole })
.where("id", "=", targetMember.id)
.execute();
});
});
/**
* Remove a member from an org
* Owners can remove anyone, admins can only remove members
* Uses transaction to prevent race condition when removing owners
*/
export const membersRemove = os.orgs.members.remove
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { slug, userId } = input;
// Lookup org and verify membership
const org = await lookupOrgBySlug(context.db, slug);
const membership = await getMembership(context.db, org.id, context.user.id);
await context.db.transaction().execute(async (trx) => {
// Get the target member
const targetMember = await trx
.selectFrom("org_members")
.select(["id", "role"])
.where("org_id", "=", org.id)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!targetMember) {
throw new ORPCError("NOT_FOUND", { message: "Member not found" });
}
// Permission checks
if (membership.role === "owner") {
// Owners can remove anyone except the last owner
if (targetMember.role === "owner") {
const ownerCount = await countOwners(trx, org.id);
if (ownerCount === 1) {
throw new ORPCError("BAD_REQUEST", {
message: "Cannot remove the only owner",
});
}
}
} else if (membership.role === "admin") {
// Admins can only remove members (not owners or other admins)
if (targetMember.role !== "member") {
throw new ORPCError("FORBIDDEN", {
message: "Admins can only remove members",
});
}
} else {
// Members cannot remove anyone
throw new ORPCError("FORBIDDEN", {
message: "Insufficient permissions",
});
}
// Remove the member
await trx
.deleteFrom("org_members")
.where("id", "=", targetMember.id)
.execute();
});
});

View File

@@ -0,0 +1,34 @@
/**
* Org sites procedures - list
*/
import { authMiddleware, os } from "../base.js";
import { getMembership, lookupOrgBySlug } from "./helpers.js";
/**
* List all sites for an org
* Any member can view the site list
*/
export const sitesList = os.orgs.sites.list
.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);
// Get sites
const sites = await context.db
.selectFrom("org_sites")
.where("org_id", "=", org.id)
.select(["id", "domain", "created_at"])
.orderBy("created_at", "asc")
.execute();
return sites.map((s) => ({
id: s.id,
domain: s.domain,
createdAt: s.created_at,
}));
});

View File

@@ -34,6 +34,22 @@ import {
} from "./procedures/me/sessions.js"; } from "./procedures/me/sessions.js";
import { setPassword } from "./procedures/me/set-password.js"; import { setPassword } from "./procedures/me/set-password.js";
import { updateProfile } from "./procedures/me/update-profile.js"; import { updateProfile } from "./procedures/me/update-profile.js";
import {
invitesAccept,
invitesCancel,
invitesCreate,
invitesList,
membersList,
membersRemove,
membersUpdateRole,
orgsCreate,
orgsDelete,
orgsGet,
orgsLeave,
orgsList,
orgsUpdate,
sitesList,
} from "./procedures/orgs/index.js";
import { import {
createAuthenticationOptions as createAuthOptions, createAuthenticationOptions as createAuthOptions,
createRegistrationOptions as createRegOptions, createRegistrationOptions as createRegOptions,
@@ -176,79 +192,11 @@ const setupProfile = os.me.setupProfile
// - listSessions, revokeSession, revokeAllSessions // - listSessions, revokeSession, revokeAllSessions
// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices // - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices
// Orgs procedures (all require auth) // Orgs procedures - imported from ./procedures/orgs/index.js
const orgsList = os.orgs.list.use(authMiddleware).handler(async () => { // - orgsList, orgsCreate, orgsGet, orgsUpdate, orgsDelete, orgsLeave
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); // - membersList, membersUpdateRole, membersRemove
}); // - invitesList, invitesCreate, invitesCancel, invitesAccept
// - sitesList
const orgsCreate = os.orgs.create.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const orgsGet = os.orgs.get.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const orgsUpdate = os.orgs.update.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const orgsDelete = os.orgs.delete.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const orgsLeave = os.orgs.leave.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Orgs members procedures
const membersList = os.orgs.members.list
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const membersUpdateRole = os.orgs.members.updateRole
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const membersRemove = os.orgs.members.remove
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Orgs invites procedures
const invitesList = os.orgs.invites.list
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const invitesCreate = os.orgs.invites.create
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const invitesCancel = os.orgs.invites.cancel
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
const invitesAccept = os.orgs.invites.accept
.use(authMiddleware)
.handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Orgs sites procedures
const sitesList = os.orgs.sites.list.use(authMiddleware).handler(async () => {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
});
// Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later) // Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later)
const adminOrgsList = os.admin.orgs.list const adminOrgsList = os.admin.orgs.list