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 0000000000..cbce945aee --- /dev/null +++ b/.server-changes/fix-invite-accept-many-projects.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fixed invite acceptance failing for organizations with many projects. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index e88f5a5ccf..8415653b1f 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,63 +190,137 @@ 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", isBranchableEnvironment: false, 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, + userEmail: user.email, + 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 @@ -269,7 +356,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 944d14505a..4eebdb58f4 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -125,6 +125,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; @@ -132,13 +134,16 @@ 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)); return await prismaClient.runtimeEnvironment.create({ data: { diff --git a/apps/webapp/app/routes/invites.tsx b/apps/webapp/app/routes/invites.tsx index 7b234d4129..ac224cb88f 100644 --- a/apps/webapp/app/routes/invites.tsx +++ b/apps/webapp/app/routes/invites.tsx @@ -11,8 +11,9 @@ 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 { acceptInvite, declineInvite, getUsersInvites, isAcceptInviteFormError } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { invitesPath, rootPath } from "~/utils/pathBuilder"; @@ -80,8 +81,18 @@ export const action: ActionFunction = async ({ request }) => { ); } } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + } catch (error) { + if (isAcceptInviteFormError(error)) { + return json( + { + intent: submission.intent, + payload: submission.payload, + error: { __form__: [error.message] }, + }, + { status: 400 } + ); + } + throw error; } }; @@ -108,6 +119,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 0000000000..f5370c5561 --- /dev/null +++ b/apps/webapp/test/member.server.test.ts @@ -0,0 +1,278 @@ +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); + } + ); +});