From 69fd84a88f246a6bc114e68d0fc32592d5bd0775 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 25 Jun 2026 14:06:27 +0200 Subject: [PATCH] fix(webapp): accept org invites for orgs with many projects Move dev environment creation out of the membership transaction so accepting an invite no longer hits the 5s Prisma transaction timeout. --- .../fix-invite-accept-many-projects.md | 8 + apps/webapp/app/models/member.server.ts | 364 +++++++++-- 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, 918 insertions(+), 72 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 00000000000..bfef9e15ce6 --- /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 97613fe677b..32de34217f1 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, +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, }: { - user: { id: string; email: string }; inviteId: string; + user: { id: string; email: string }; + member: OrgMember; + organization: Pick; + projects: Pick[]; + maximumConcurrencyLimit: number; }) { - 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, - }, - }, - }, - }); + 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; - // 2. Join the organization - const member = await tx.orgMember.create({ - data: { - organizationId: invite.organizationId, - userId: user.id, - role: invite.role, - }, - }); + 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), + }); + } +} + +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 }, + }, + }); - // 4. Check for other invites - const remainingInvites = await tx.orgMemberInvite.findMany({ + 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 c10ae173310..c2b4ea5abdd 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 aa28eff0e3c..5ae9409b307 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} +