From ea42a9901dfe248ea673d98c8bf08333379221a6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Thu, 25 Jun 2026 14:06:27 +0200 Subject: [PATCH 1/2] 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 | 172 ++++++++--- apps/webapp/app/models/organization.server.ts | 6 +- apps/webapp/app/routes/invites.tsx | 38 ++- apps/webapp/test/member.server.test.ts | 279 ++++++++++++++++++ 5 files changed, 455 insertions(+), 48 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..1a7acd32bc3 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 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,65 +190,138 @@ export async function getUsersInvites({ email }: { email: string }) { }); } -export async function acceptInvite({ - user, +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 projectIds = projects.map((p) => p.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 projects.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: projects.length, + createdProjectIds, + 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({ - where: { - email: user.email, - }, + throw new Error(ENV_SETUP_INCOMPLETE); + } +} + +export async function acceptInvite({ + user, + inviteId, +}: { + user: { id: string; email: string }; + inviteId: string; +}) { + const pendingInvite = await prisma.orgMemberInvite.findFirst({ + where: { id: inviteId, email: user.email }, + select: { id: true, organizationId: true }, + }); + if (!pendingInvite) { + throw new Error(INVITE_NOT_FOUND); + } + + const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( + pendingInvite.organizationId, + "DEVELOPMENT" + ); + + let result; + try { + result = await prisma.$transaction(async (tx) => { + const invite = await tx.orgMemberInvite.delete({ + where: { + id: inviteId, + email: user.email, + }, + include: { + organization: { + include: { + projects: { where: { deletedAt: null } }, + }, + }, + }, + }); + + const member = await tx.orgMember.create({ + data: { + organizationId: invite.organizationId, + userId: user.id, + role: invite.role, + }, + }); + + return { + member, + organization: invite.organization, + rbacRoleId: invite.rbacRoleId, + }; }); + } catch (error) { + if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") { + throw new Error(INVITE_NOT_FOUND); + } + throw error; + } - return { - remainingInvites, - organization: invite.organization, - inviteRole: invite.role, - rbacRoleId: invite.rbacRoleId, - }; + await provisionMemberDevelopmentEnvironments({ + inviteId, + user, + member: result.member, + organization: result.organization, + projects: result.organization.projects, + maximumConcurrencyLimit, + }); + + const remainingInvites = await prisma.orgMemberInvite.findMany({ + where: { + email: user.email, + }, }); // If the invite carried an explicit RBAC role, assign it. Best-effort: the @@ -271,7 +357,7 @@ export async function acceptInvite({ } } - return { remainingInvites: result.remainingInvites, organization: result.organization }; + return { remainingInvites, organization: result.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..83eae7ab8db 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"; @@ -80,8 +87,30 @@ export const action: ActionFunction = async ({ request }) => { ); } } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + } catch (error) { + if (isAcceptInviteFormError(error)) { + // Membership was created and the invite deleted before 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: { __form__: [error.message] }, + }, + { status: 400 } + ); + } + throw error; } }; @@ -111,6 +140,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) => (
diff --git a/apps/webapp/test/member.server.test.ts b/apps/webapp/test/member.server.test.ts new file mode 100644 index 00000000000..9f004eee497 --- /dev/null +++ b/apps/webapp/test/member.server.test.ts @@ -0,0 +1,279 @@ +import { randomBytes } from "node:crypto"; +import { describe, expect, vi } from "vitest"; +import type { PrismaClient } from "@trigger.dev/database"; + +const prismaHolder = vi.hoisted(() => ({ + client: null as PrismaClient | null, +})); + +vi.mock("~/services/rbac.server", () => ({ + rbac: { + setUserRole: async () => ({ ok: true as const }), + }, +})); + +vi.mock("~/db.server", () => ({ + get prisma() { + if (!prismaHolder.client) { + throw new Error("test prisma not set"); + } + return prismaHolder.client; + }, + get $replica() { + if (!prismaHolder.client) { + throw new Error("test prisma not set"); + } + return prismaHolder.client; + }, +})); + +import { postgresTest } from "@internal/testcontainers"; + +vi.setConfig({ testTimeout: 60_000 }); + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)) + .toString("hex") + .slice(0, len); +} + +async function seedInviteFixture( + prisma: PrismaClient, + opts: { activeProjectCount: number; deletedProjectCount?: number } +) { + const suffix = randomHex(8); + const inviter = await prisma.user.create({ + data: { + email: `inviter-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + }, + }); + const invitee = await prisma.user.create({ + data: { + email: `invitee-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + }, + }); + + const organization = await prisma.organization.create({ + data: { + title: `invite-org-${suffix}`, + slug: `invite-org-${suffix}`, + v3Enabled: true, + members: { create: { userId: inviter.id, role: "ADMIN" } }, + }, + }); + + const activeProjects = []; + for (let i = 0; i < opts.activeProjectCount; i++) { + activeProjects.push( + await prisma.project.create({ + data: { + name: `active-project-${i}-${suffix}`, + slug: `active-proj-${i}-${suffix}`, + externalRef: `proj_active_${i}_${suffix}`, + organizationId: organization.id, + engine: "V2", + }, + }) + ); + } + + const deletedProjectCount = opts.deletedProjectCount ?? 0; + for (let i = 0; i < deletedProjectCount; i++) { + await prisma.project.create({ + data: { + name: `deleted-project-${i}-${suffix}`, + slug: `deleted-proj-${i}-${suffix}`, + externalRef: `proj_deleted_${i}_${suffix}`, + organizationId: organization.id, + engine: "V2", + deletedAt: new Date(), + }, + }); + } + + const invite = await prisma.orgMemberInvite.create({ + data: { + email: invitee.email, + organizationId: organization.id, + inviterId: inviter.id, + role: "MEMBER", + }, + }); + + return { inviter, invitee, organization, activeProjects, invite }; +} + +function devEnvKeys(apiKey: string, pkApiKey: string) { + return { apiKey, pkApiKey, shortcode: randomHex(4) }; +} + +describe("acceptInvite", () => { + postgresTest( + "creates member and dev environments for active projects only (many projects)", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite } = await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 25, + deletedProjectCount: 3, + }); + + const beforeEnvCount = await prisma.runtimeEnvironment.count(); + + const { organization: joinedOrg } = await acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }); + + expect(joinedOrg.id).toBe(organization.id); + + const member = await prisma.orgMember.findFirst({ + where: { userId: invitee.id, organizationId: organization.id }, + }); + expect(member).not.toBeNull(); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member!.id, + type: "DEVELOPMENT", + }, + }); + expect(devEnvs).toHaveLength(activeProjects.length); + + const envProjectIds = new Set(devEnvs.map((e) => e.projectId)); + for (const project of activeProjects) { + expect(envProjectIds.has(project.id)).toBe(true); + } + + const newEnvCount = await prisma.runtimeEnvironment.count(); + expect(newEnvCount - beforeEnvCount).toBe(activeProjects.length); + } + ); + + postgresTest( + "rejects wrong email without creating member or environments", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server"); + + const { invitee, organization, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 2, + }); + + const beforeMemberCount = await prisma.orgMember.count({ + where: { organizationId: organization.id, userId: invitee.id }, + }); + const beforeEnvCount = await prisma.runtimeEnvironment.count(); + + await expect( + acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: "wrong@example.com" }, + }) + ).rejects.toThrow(INVITE_NOT_FOUND); + + const afterMemberCount = await prisma.orgMember.count({ + where: { organizationId: organization.id, userId: invitee.id }, + }); + expect(afterMemberCount).toBe(beforeMemberCount); + + const afterEnvCount = await prisma.runtimeEnvironment.count(); + expect(afterEnvCount).toBe(beforeEnvCount); + } + ); + + postgresTest( + "rejects already consumed invite with normalized error", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server"); + + const { invitee, organization, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 1, + }); + + await prisma.orgMemberInvite.delete({ where: { id: invite.id } }); + + await expect( + acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }) + ).rejects.toThrow(INVITE_NOT_FOUND); + + const member = await prisma.orgMember.findFirst({ + where: { userId: invitee.id, organizationId: organization.id }, + }); + expect(member).toBeNull(); + } + ); +}); + +describe("provisionMemberDevelopmentEnvironments", () => { + postgresTest( + "throws partial-success error when env creation fails mid-loop", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionMemberDevelopmentEnvironments, ENV_SETUP_INCOMPLETE } = + await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 3, + }); + + await prisma.orgMemberInvite.delete({ where: { id: invite.id } }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + + const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`); + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + ...keys, + projectId: activeProjects[1].id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + + await expect( + provisionMemberDevelopmentEnvironments({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + member, + organization, + projects: activeProjects, + maximumConcurrencyLimit: 5, + }) + ).rejects.toThrow(ENV_SETUP_INCOMPLETE); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member.id, + type: "DEVELOPMENT", + }, + }); + + const envProjectIds = devEnvs.map((e) => e.projectId); + expect(envProjectIds).toContain(activeProjects[0].id); + expect(envProjectIds).toContain(activeProjects[1].id); + expect(envProjectIds).not.toContain(activeProjects[2].id); + } + ); +}); From ad7a7d4aefa641ffd575f92d9a2deee6e45748f6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Fri, 26 Jun 2026 18:31:08 +0000 Subject: [PATCH 2/2] f --- apps/webapp/app/models/member.server.ts | 269 +++++++++++++++++------- apps/webapp/app/routes/invites.tsx | 6 +- apps/webapp/test/member.server.test.ts | 159 +++++++++++++- 3 files changed, 355 insertions(+), 79 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 1a7acd32bc3..6749a58ace9 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -8,7 +8,7 @@ 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 again or contact support if this persists."; + "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 ( @@ -190,6 +190,33 @@ export async function getUsersInvites({ email }: { email: string }) { }); } +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, @@ -205,13 +232,18 @@ export async function provisionMemberDevelopmentEnvironments({ projects: Pick[]; maximumConcurrencyLimit: number; }) { - const projectIds = projects.map((p) => p.id); + 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 projects.entries()) { + for (const [index, project] of projectsNeedingEnvs.entries()) { failedProjectId = project.id; failedProjectIndex = index; @@ -239,7 +271,7 @@ export async function provisionMemberDevelopmentEnvironments({ projectIds, failedProjectId, failedProjectIndex, - totalProjects: projects.length, + totalProjects: projectsNeedingEnvs.length, createdProjectIds, error: error instanceof Error @@ -251,6 +283,99 @@ export async function provisionMemberDevelopmentEnvironments({ } } +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 }: { user: { id: string; email: string } }) { + const members = await prisma.orgMember.findMany({ + where: { + userId: user.id, + organization: { deletedAt: null }, + }, + include: { + organization: { + include: { + projects: { where: { deletedAt: null } }, + }, + }, + }, + }); + + const incompleteMemberships = []; + for (const member of members) { + const missingProjects = await getProjectsMissingMemberDevelopmentEnvironments({ + memberId: member.id, + organizationId: member.organizationId, + projects: member.organization.projects, + }); + if (missingProjects.length > 0) { + incompleteMemberships.push({ + member, + organization: member.organization, + missingProjects, + }); + } + } + + if (incompleteMemberships.length === 0) { + return null; + } + + for (const { member, organization, missingProjects } of incompleteMemberships) { + const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( + organization.id, + "DEVELOPMENT" + ); + + await provisionMemberDevelopmentEnvironments({ + inviteId: "recovery", + user, + member, + organization, + projects: missingProjects, + maximumConcurrencyLimit, + }); + } + + return { + remainingInvites: await getUsersInvites({ email: user.email }), + organization: incompleteMemberships[0].organization, + }; +} + export async function acceptInvite({ user, inviteId, @@ -258,106 +383,108 @@ export async function acceptInvite({ user: { id: string; email: string }; inviteId: string; }) { - const pendingInvite = await prisma.orgMemberInvite.findFirst({ + const invite = await prisma.orgMemberInvite.findFirst({ where: { id: inviteId, email: user.email }, - select: { id: true, organizationId: true }, + include: { + organization: { + include: { + projects: { where: { deletedAt: null } }, + }, + }, + }, }); - if (!pendingInvite) { + + if (!invite) { + const recovered = await tryRecoverIncompleteInviteAccept({ user }); + if (recovered) { + return recovered; + } throw new Error(INVITE_NOT_FOUND); } const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit( - pendingInvite.organizationId, + invite.organizationId, "DEVELOPMENT" ); - let result; - try { - result = await prisma.$transaction(async (tx) => { - const invite = await tx.orgMemberInvite.delete({ - where: { - id: inviteId, - email: user.email, - }, - include: { - organization: { - include: { - projects: { where: { deletedAt: null } }, - }, - }, - }, - }); + let member = await prisma.orgMember.findFirst({ + where: { + organizationId: invite.organizationId, + userId: user.id, + }, + }); - const member = await tx.orgMember.create({ + if (!member) { + try { + member = await prisma.orgMember.create({ data: { organizationId: invite.organizationId, userId: user.id, role: invite.role, }, }); - - return { - member, - organization: invite.organization, - rbacRoleId: invite.rbacRoleId, - }; - }); - } catch (error) { - if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") { - throw new Error(INVITE_NOT_FOUND); + } catch (error) { + if ( + error instanceof PrismaNamespace.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + member = await prisma.orgMember.findFirst({ + where: { + organizationId: invite.organizationId, + userId: user.id, + }, + }); + if (!member) { + throw error; + } + } else { + throw error; + } } - throw error; } await provisionMemberDevelopmentEnvironments({ inviteId, user, - member: result.member, - organization: result.organization, - projects: result.organization.projects, + member, + organization: invite.organization, + projects: invite.organization.projects, maximumConcurrencyLimit, }); - const remainingInvites = await prisma.orgMemberInvite.findMany({ - where: { - email: user.email, - }, - }); + // 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 (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, organization: result.organization }; + return { remainingInvites, organization: invite.organization }; } export async function declineInvite({ diff --git a/apps/webapp/app/routes/invites.tsx b/apps/webapp/app/routes/invites.tsx index 83eae7ab8db..cb7b8a26770 100644 --- a/apps/webapp/app/routes/invites.tsx +++ b/apps/webapp/app/routes/invites.tsx @@ -89,9 +89,9 @@ export const action: ActionFunction = async ({ request }) => { } } catch (error) { if (isAcceptInviteFormError(error)) { - // Membership was created and the invite deleted before 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. + // 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) { diff --git a/apps/webapp/test/member.server.test.ts b/apps/webapp/test/member.server.test.ts index 9f004eee497..c6bfc333ea9 100644 --- a/apps/webapp/test/member.server.test.ts +++ b/apps/webapp/test/member.server.test.ts @@ -218,11 +218,11 @@ describe("acceptInvite", () => { describe("provisionMemberDevelopmentEnvironments", () => { postgresTest( - "throws partial-success error when env creation fails mid-loop", + "skips projects that already have development environments", { timeout: 60_000 }, async ({ prisma }) => { prismaHolder.client = prisma; - const { provisionMemberDevelopmentEnvironments, ENV_SETUP_INCOMPLETE } = + const { provisionMemberDevelopmentEnvironments } = await import("../app/models/member.server"); const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { @@ -251,13 +251,56 @@ describe("provisionMemberDevelopmentEnvironments", () => { }, }); + await provisionMemberDevelopmentEnvironments({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + member, + organization, + projects: activeProjects, + maximumConcurrencyLimit: 5, + }); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member.id, + type: "DEVELOPMENT", + }, + }); + + expect(devEnvs).toHaveLength(activeProjects.length); + } + ); + + postgresTest( + "throws partial-success error when env creation fails mid-loop", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { provisionMemberDevelopmentEnvironments, ENV_SETUP_INCOMPLETE } = + await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 2, + }); + + await prisma.orgMemberInvite.delete({ where: { id: invite.id } }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + await expect( provisionMemberDevelopmentEnvironments({ inviteId: invite.id, user: { id: invitee.id, email: invitee.email }, member, organization, - projects: activeProjects, + projects: [...activeProjects, { id: "missing-project-id" }], maximumConcurrencyLimit: 5, }) ).rejects.toThrow(ENV_SETUP_INCOMPLETE); @@ -270,10 +313,116 @@ describe("provisionMemberDevelopmentEnvironments", () => { }, }); - const envProjectIds = devEnvs.map((e) => e.projectId); + const envProjectIds = devEnvs.map((env) => env.projectId); expect(envProjectIds).toContain(activeProjects[0].id); expect(envProjectIds).toContain(activeProjects[1].id); - expect(envProjectIds).not.toContain(activeProjects[2].id); + } + ); +}); + +describe("acceptInvite recovery", () => { + postgresTest( + "retries successfully when membership exists and the invite is still pending", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite } = await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 3, + }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + + const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`); + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + ...keys, + projectId: activeProjects[0].id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + + const { organization: joinedOrg } = await acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }); + + expect(joinedOrg.id).toBe(organization.id); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member.id, + type: "DEVELOPMENT", + }, + }); + expect(devEnvs).toHaveLength(activeProjects.length); + + const remainingInvite = await prisma.orgMemberInvite.findFirst({ + where: { id: invite.id }, + }); + expect(remainingInvite).toBeNull(); + } + ); + + postgresTest( + "recovers when the invite was already consumed but development environments are incomplete", + { timeout: 60_000 }, + async ({ prisma }) => { + prismaHolder.client = prisma; + const { acceptInvite } = await import("../app/models/member.server"); + + const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, { + activeProjectCount: 3, + }); + + await prisma.orgMemberInvite.delete({ where: { id: invite.id } }); + + const member = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: invitee.id, + role: "MEMBER", + }, + }); + + const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`); + await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + ...keys, + projectId: activeProjects[0].id, + organizationId: organization.id, + orgMemberId: member.id, + }, + }); + + const { organization: joinedOrg } = await acceptInvite({ + inviteId: invite.id, + user: { id: invitee.id, email: invitee.email }, + }); + + expect(joinedOrg.id).toBe(organization.id); + + const devEnvs = await prisma.runtimeEnvironment.findMany({ + where: { + organizationId: organization.id, + orgMemberId: member.id, + type: "DEVELOPMENT", + }, + }); + expect(devEnvs).toHaveLength(activeProjects.length); } ); });