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