/** * Org invite procedures - list, create, cancel, accept */ import { ORPCError } from "@orpc/server"; import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; import { generateExpiry, generateSecureBase58Token, } 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 = generateSecureBase58Token(); 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); return { success: true }; }); /** * 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" }); } return { success: true }; }); /** * 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; } return { success: true }; });