From cd9d20cc99422bb8fe821e07b04a7aaadd8a8e28 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 30 Jun 2026 14:35:35 +0200 Subject: [PATCH] fix(webapp): accept invites for orgs with many projects (#4043) Invite acceptance could fail for cloud organizations with many projects because the whole flow ran inside a single transaction and did too much work before it completed. In larger orgs, that pushed the transaction past its timeout and blocked the invite from being accepted. This PR moves the expensive parts of invite acceptance out of the transaction, excludes deleted projects from environment setup, fixes error handling on /invites, and adds regression coverage for the failure cases. --- .../fix-invite-accept-many-projects.md | 8 + apps/webapp/app/models/member.server.ts | 368 ++++++++--- apps/webapp/app/models/organization.server.ts | 6 +- apps/webapp/app/routes/invites.tsx | 41 +- apps/webapp/test/member.server.test.ts | 571 ++++++++++++++++++ 5 files changed, 920 insertions(+), 74 deletions(-) create mode 100644 .server-changes/fix-invite-accept-many-projects.md create mode 100644 apps/webapp/test/member.server.test.ts diff --git a/.server-changes/fix-invite-accept-many-projects.md b/.server-changes/fix-invite-accept-many-projects.md new file mode 100644 index 000000000..bfef9e15c --- /dev/null +++ b/.server-changes/fix-invite-accept-many-projects.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: fix +--- + +Fixed invite acceptance failing for organizations with many projects. + +When environment provisioning failed after membership was created, users with a single pending invite were redirected away before seeing the error. They now land on the orgs page with a persistent error toast; users with other pending invites still see a FormError on the invites page. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 97613fe67..32de34217 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,9 +1,22 @@ -import { type Prisma, prisma } from "~/db.server"; +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"; +export const INVITE_NOT_FOUND = "Invite not found"; +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) + ); +} + const tokenValueLength = 40; const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); @@ -177,101 +190,318 @@ export async function getUsersInvites({ email }: { email: string }) { }); } -export async function acceptInvite({ - user, - inviteId, +async function getProjectsMissingMemberDevelopmentEnvironments({ + memberId, + organizationId, + projects, }: { - user: { id: string; email: string }; - inviteId: string; + memberId: string; + organizationId: string; + projects: Pick[]; }) { - const result = await prisma.$transaction(async (tx) => { - // 1. Delete the invite and get the invite details - const invite = await tx.orgMemberInvite.delete({ - where: { - id: inviteId, - email: user.email, - }, - include: { - organization: { - include: { - projects: true, - }, - }, - }, - }); + if (projects.length === 0) { + return []; + } - // 2. Join the organization - const member = await tx.orgMember.create({ - data: { - organizationId: invite.organizationId, - userId: user.id, - role: invite.role, - }, - }); + 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; - // 3. Create an environment for each project - for (const project of invite.organization.projects) { await createEnvironment({ - organization: invite.organization, + organization, project, type: "DEVELOPMENT", // We set this true but no backfill (yet!?) so never used // for dev environments isBranchableEnvironment: true, member, - prismaClient: tx, + 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), + }); + } +} - // 4. Check for other invites - const remainingInvites = await tx.orgMemberInvite.findMany({ +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); + } + + 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; + } + } - return { - remainingInvites, - organization: invite.organization, - inviteRole: invite.role, - rbacRoleId: invite.rbacRoleId, - }; - }); + 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 (result.rbacRoleId) { - try { - const roleResult = await rbac.setUserRole({ - userId: user.id, - organizationId: result.organization.id, - roleId: result.rbacRoleId, - }); - if (!roleResult.ok) { - logger.error("acceptInvite: skipped RBAC role assignment", { - organizationId: result.organization.id, - userId: user.id, - rbacRoleId: result.rbacRoleId, - reason: roleResult.error, - }); - } - } catch (error) { - logger.error("acceptInvite: RBAC role assignment threw", { - organizationId: result.organization.id, - userId: user.id, - rbacRoleId: result.rbacRoleId, - error: - error instanceof Error - ? { name: error.name, message: error.message, stack: error.stack } - : String(error), - }); - } + if (invite.rbacRoleId) { + await assignInviteRbacRole({ + userId: user.id, + organizationId: invite.organization.id, + rbacRoleId: invite.rbacRoleId, + }); } - return { remainingInvites: result.remainingInvites, organization: result.organization }; + return { remainingInvites, organization: invite.organization }; } export async function declineInvite({ diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index c10ae1733..c2b4ea5ab 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -129,6 +129,8 @@ export async function createEnvironment({ isBranchableEnvironment = false, member, prismaClient = prisma, + /** When set, skips billing lookup — caller must supply the limit for this org + type. */ + maximumConcurrencyLimit, }: { organization: Pick; project: Pick; @@ -136,13 +138,15 @@ export async function createEnvironment({ isBranchableEnvironment?: boolean; member?: OrgMember; prismaClient?: PrismaClientOrTransaction; + maximumConcurrencyLimit?: number; }) { const slug = envSlug(type); const apiKey = createApiKeyForEnv(type); const pkApiKey = createPkApiKeyForEnv(type); const shortcode = createShortcode().join("-"); - const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type); + const limit = + maximumConcurrencyLimit ?? (await getDefaultEnvironmentConcurrencyLimit(organization.id, type)); const billingPause = await getInitialEnvPauseStateForBillingLimit(organization.id, type); const environment = await prismaClient.runtimeEnvironment.create({ diff --git a/apps/webapp/app/routes/invites.tsx b/apps/webapp/app/routes/invites.tsx index aa28eff0e..5ae9409b3 100644 --- a/apps/webapp/app/routes/invites.tsx +++ b/apps/webapp/app/routes/invites.tsx @@ -11,9 +11,16 @@ import { Fieldset } from "~/components/primitives/Fieldset"; import { FormTitle } from "~/components/primitives/FormTitle"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { InputGroup } from "~/components/primitives/InputGroup"; +import { FormError } from "~/components/primitives/FormError"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { acceptInvite, declineInvite, getUsersInvites } from "~/models/member.server"; -import { redirectWithSuccessMessage } from "~/models/message.server"; +import { + acceptInvite, + declineInvite, + ENV_SETUP_INCOMPLETE, + getUsersInvites, + isAcceptInviteFormError, +} from "~/models/member.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { invitesPath, rootPath } from "~/utils/pathBuilder"; import { EnvelopeIcon } from "@heroicons/react/20/solid"; @@ -33,6 +40,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const schema = z.object({ inviteId: z.string(), + organizationId: z.string().optional(), }); export const action: ActionFunction = async ({ request }) => { @@ -49,6 +57,7 @@ export const action: ActionFunction = async ({ request }) => { if (submission.intent === "accept") { const { remainingInvites, organization } = await acceptInvite({ inviteId: submission.value.inviteId, + organizationId: submission.value.organizationId, user: { id: user.id, email: user.email }, }); @@ -80,8 +89,30 @@ export const action: ActionFunction = async ({ request }) => { ); } } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + } catch (error) { + if (isAcceptInviteFormError(error)) { + // Membership may already exist while the invite is still present if env + // provisioning failed. With no invites left, the loader would redirect + // and discard a 400 FormError — send the user to orgs with a toast instead. + if (error.message === ENV_SETUP_INCOMPLETE) { + const remainingInvites = await getUsersInvites({ email: user.email }); + if (remainingInvites.length === 0) { + return redirectWithErrorMessage(rootPath(), request, error.message, { + ephemeral: false, + }); + } + } + + return json( + { + intent: submission.intent, + payload: submission.payload, + error: { "": [error.message] }, + }, + { status: 400 } + ); + } + throw error; } }; @@ -111,6 +142,7 @@ export default function Page() { className="mb-0 text-sky-500" title={simplur`You have ${invites.length} new invitation[|s]`} /> + {form.error} {invites.map((invite) => (
@@ -121,6 +153,7 @@ export default function Page() { Invited by {invite.inviter.displayName ?? invite.inviter.email} +