import type { Organization, OrgMember, Project } from "@trigger.dev/database"; import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; export const INVITE_NOT_FOUND = "Invite not found"; export const INVITE_BLOCKED_DIRECTORY_MANAGED = "Membership for this organization is managed by Directory Sync, so invites can't be accepted."; export const ENV_SETUP_INCOMPLETE = "You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists."; export function isAcceptInviteFormError(error: unknown): error is Error { return ( error instanceof Error && (error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE || error.message === INVITE_BLOCKED_DIRECTORY_MANAGED) ); } const tokenValueLength = 40; const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); export async function getTeamMembersAndInvites({ userId, organizationId, }: { userId: string; organizationId: string; }) { const org = await prisma.organization.findFirst({ where: { id: organizationId, members: { some: { userId } } }, select: { members: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true, avatarUrl: true, }, }, }, }, invites: { select: { id: true, email: true, updatedAt: true, inviter: { select: { id: true, name: true, email: true, avatarUrl: true, }, }, }, }, }, }); if (!org) { return null; } return { members: org.members, invites: org.invites }; } export async function inviteMembers({ slug, emails, userId, rbacRoleId, }: { slug: string; emails: string[]; userId: string; /** * Optional RBAC role to attach to the invite. When set, accepted * invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember * is created. * * `OrgMemberInvite.role` is still set if the plugin isn't installed. */ rbacRoleId?: string | null; }) { const org = await prisma.organization.findFirst({ where: { slug, members: { some: { userId } } }, }); if (!org) { throw new Error("User does not have access to this organization"); } const invites = [...new Set(emails)].map( (email) => ({ email, token: tokenGenerator(), organizationId: org.id, inviterId: userId, role: "MEMBER", rbacRoleId: rbacRoleId ?? null, }) satisfies Prisma.OrgMemberInviteCreateManyInput ); await prisma.orgMemberInvite.createMany({ data: invites, }); return await prisma.orgMemberInvite.findMany({ where: { organizationId: org.id, inviterId: userId, email: { in: emails, }, }, include: { organization: true, inviter: true, }, }); } export async function getInviteFromToken({ token }: { token: string }) { return await prisma.orgMemberInvite.findFirst({ where: { token, }, include: { organization: true, inviter: true, }, }); } export async function getUsersInvites({ email }: { email: string }) { return await prisma.orgMemberInvite.findMany({ where: { email, organization: { deletedAt: null, }, }, include: { organization: true, inviter: true, }, }); } async function getProjectsMissingMemberDevelopmentEnvironments({ memberId, organizationId, projects, }: { memberId: string; organizationId: string; projects: Pick[]; }) { if (projects.length === 0) { return []; } const existingEnvs = await prisma.runtimeEnvironment.findMany({ where: { orgMemberId: memberId, organizationId, type: "DEVELOPMENT", projectId: { in: projects.map((project) => project.id) }, }, select: { projectId: true }, }); const existingProjectIds = new Set(existingEnvs.map((env) => env.projectId)); return projects.filter((project) => !existingProjectIds.has(project.id)); } export async function provisionMemberDevelopmentEnvironments({ inviteId, user, member, organization, projects, maximumConcurrencyLimit, }: { inviteId: string; user: { id: string; email: string }; member: OrgMember; organization: Pick; projects: Pick[]; maximumConcurrencyLimit: number; }) { const projectsNeedingEnvs = await getProjectsMissingMemberDevelopmentEnvironments({ memberId: member.id, organizationId: organization.id, projects, }); const projectIds = projects.map((project) => project.id); const createdProjectIds: string[] = []; let failedProjectId: string | undefined; let failedProjectIndex: number | undefined; try { for (const [index, project] of projectsNeedingEnvs.entries()) { failedProjectId = project.id; failedProjectIndex = index; await createEnvironment({ organization, project, type: "DEVELOPMENT", // We set this true but no backfill (yet!?) so never used // for dev environments isBranchableEnvironment: true, member, maximumConcurrencyLimit, }); createdProjectIds.push(project.id); failedProjectId = undefined; failedProjectIndex = undefined; } } catch (error) { logger.error("acceptInvite: development environment creation failed after membership created", { inviteId, userId: user.id, organizationId: organization.id, orgMemberId: member.id, projectIds, failedProjectId, failedProjectIndex, totalProjects: projectsNeedingEnvs.length, createdProjectIds, error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error), }); throw new Error(ENV_SETUP_INCOMPLETE); } } async function assignInviteRbacRole({ userId, organizationId, rbacRoleId, }: { userId: string; organizationId: string; rbacRoleId: string; }) { try { const roleResult = await rbac.setUserRole({ userId, organizationId, roleId: rbacRoleId, }); if (!roleResult.ok) { logger.error("acceptInvite: skipped RBAC role assignment", { organizationId, userId, rbacRoleId, reason: roleResult.error, }); } } catch (error) { logger.error("acceptInvite: RBAC role assignment threw", { organizationId, userId, rbacRoleId, error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error), }); } } async function tryRecoverIncompleteInviteAccept({ user, organizationId, inviteId, }: { user: { id: string; email: string }; organizationId: string; inviteId: string; }) { const member = await prisma.orgMember.findFirst({ where: { userId: user.id, organizationId, organization: { deletedAt: null }, }, include: { organization: { include: { projects: { where: { deletedAt: null } }, }, }, }, }); if (!member) { return null; } const missingProjects = await getProjectsMissingMemberDevelopmentEnvironments({ memberId: member.id, organizationId, projects: member.organization.projects, }); if (missingProjects.length === 0) { return null; } const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( organizationId, "DEVELOPMENT" ); await provisionMemberDevelopmentEnvironments({ inviteId, user, member, organization: member.organization, projects: missingProjects, maximumConcurrencyLimit, }); return { remainingInvites: await getUsersInvites({ email: user.email }), organization: member.organization, }; } export async function acceptInvite({ user, inviteId, organizationId, }: { user: { id: string; email: string }; inviteId: string; organizationId?: string; }) { const invite = await prisma.orgMemberInvite.findFirst({ where: { id: inviteId, email: user.email, organization: { deletedAt: null, }, }, include: { organization: { include: { projects: { where: { deletedAt: null } }, }, }, }, }); if (!invite) { if (organizationId) { const recovered = await tryRecoverIncompleteInviteAccept({ user, organizationId, inviteId, }); if (recovered) { return recovered; } } throw new Error(INVITE_NOT_FOUND); } // Directory-managed membership: accepting an invite would add a member // outside the directory. Block it (the invite can still be revoked by an // admin). Fail-open on a plugin error so a hiccup doesn't strand joiners. const membershipPolicy = await ssoController.getMembershipPolicy(invite.organizationId); if (membershipPolicy.isOk() && !membershipPolicy.value.manualMembershipAllowed) { throw new Error(INVITE_BLOCKED_DIRECTORY_MANAGED); } const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( invite.organizationId, "DEVELOPMENT" ); let member = await prisma.orgMember.findFirst({ where: { organizationId: invite.organizationId, userId: user.id, organization: { deletedAt: null }, }, }); if (!member) { try { member = await prisma.orgMember.create({ data: { organizationId: invite.organizationId, userId: user.id, role: invite.role, }, }); } catch (error) { if ( error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2002" ) { member = await prisma.orgMember.findFirst({ where: { organizationId: invite.organizationId, userId: user.id, organization: { deletedAt: null }, }, }); if (!member) { throw error; } } else { throw error; } } } await provisionMemberDevelopmentEnvironments({ inviteId, user, member, organization: invite.organization, projects: invite.organization.projects, maximumConcurrencyLimit, }); // Consume the invite only after development environments are provisioned so // a failed setup can be retried from /invites. try { await prisma.orgMemberInvite.delete({ where: { id: inviteId, email: user.email, }, }); } catch (error) { if ( !(error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") ) { throw error; } } const remainingInvites = await getUsersInvites({ email: user.email }); // If the invite carried an explicit RBAC role, assign it. Best-effort: the // invite is already consumed and membership created above, so a failure here // — a returned {ok:false} or a thrown error from the plugin — must not block // joining the org. Swallow and log either way; without the catch a plugin // throw escapes and turns the whole invite-accept into a 400. if (invite.rbacRoleId) { await assignInviteRbacRole({ userId: user.id, organizationId: invite.organization.id, rbacRoleId: invite.rbacRoleId, }); } // Deliberate re-admission clears any sticky-removal tombstone so this // membership isn't shadowed by a prior removal (best-effort; no-op in OSS). await ssoController .clearMembershipRemoval({ organizationId: invite.organization.id, userId: user.id }) .unwrapOr(undefined); return { remainingInvites, organization: invite.organization }; } export async function declineInvite({ user, inviteId, }: { user: { id: string; email: string }; inviteId: string; }) { return await prisma.$transaction(async (tx) => { //1. delete invite const declinedInvite = await tx.orgMemberInvite.delete({ where: { id: inviteId, email: user.email, }, include: { organization: true, }, }); //2. check for other invites const remainingInvites = await tx.orgMemberInvite.findMany({ where: { email: user.email, }, }); return { remainingInvites, organization: declinedInvite.organization }; }); } export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) { return await prisma.orgMemberInvite.update({ where: { id: inviteId, inviterId: userId, }, data: { updatedAt: new Date(), }, include: { inviter: true, organization: true, }, }); } export async function revokeInvite({ userId, orgSlug, inviteId, }: { userId: string; orgSlug: string; inviteId: string; }) { const invite = await prisma.orgMemberInvite.findFirst({ where: { id: inviteId, organization: { slug: orgSlug, members: { some: { userId, }, }, }, }, select: { id: true, email: true, organization: true, }, }); if (!invite) { throw new Error("Invite not found"); } await prisma.orgMemberInvite.delete({ where: { id: invite.id, }, }); return { email: invite.email, organization: invite.organization }; }