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.
This commit is contained in:
@@ -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.
|
||||
@@ -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<Project, "id">[];
|
||||
}) {
|
||||
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<Organization, "id" | "maximumConcurrencyLimit">;
|
||||
projects: Pick<Project, "id">[];
|
||||
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({
|
||||
|
||||
@@ -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<Organization, "id" | "maximumConcurrencyLimit">;
|
||||
project: Pick<Project, "id">;
|
||||
@@ -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({
|
||||
|
||||
@@ -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]`}
|
||||
/>
|
||||
<FormError>{form.error}</FormError>
|
||||
{invites.map((invite) => (
|
||||
<Form key={invite.id} method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
@@ -121,6 +153,7 @@ export default function Page() {
|
||||
Invited by {invite.inviter.displayName ?? invite.inviter.email}
|
||||
</Paragraph>
|
||||
<input name="inviteId" type="hidden" value={invite.id} />
|
||||
<input name="organizationId" type="hidden" value={invite.organizationId} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
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,
|
||||
organizationId: organization.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 invite for deleted organization 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,
|
||||
});
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id: organization.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
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: invitee.email },
|
||||
})
|
||||
).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(
|
||||
"skips projects that already have development environments",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma }) => {
|
||||
prismaHolder.client = prisma;
|
||||
const { provisionMemberDevelopmentEnvironments } =
|
||||
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 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, { id: "missing-project-id" }],
|
||||
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((env) => env.projectId);
|
||||
expect(envProjectIds).toContain(activeProjects[0].id);
|
||||
expect(envProjectIds).toContain(activeProjects[1].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,
|
||||
organizationId: organization.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,
|
||||
organizationId: organization.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);
|
||||
}
|
||||
);
|
||||
|
||||
postgresTest(
|
||||
"does not recover unrelated memberships when invite is missing and organizationId is omitted",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma }) => {
|
||||
prismaHolder.client = prisma;
|
||||
const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");
|
||||
|
||||
const fixtureA = await seedInviteFixture(prisma, { activeProjectCount: 2 });
|
||||
const fixtureB = await seedInviteFixture(prisma, { activeProjectCount: 2 });
|
||||
|
||||
await prisma.orgMemberInvite.delete({ where: { id: fixtureA.invite.id } });
|
||||
|
||||
const member = await prisma.orgMember.create({
|
||||
data: {
|
||||
organizationId: fixtureA.organization.id,
|
||||
userId: fixtureA.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: fixtureA.activeProjects[0].id,
|
||||
organizationId: fixtureA.organization.id,
|
||||
orgMemberId: member.id,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
acceptInvite({
|
||||
inviteId: fixtureA.invite.id,
|
||||
user: { id: fixtureA.invitee.id, email: fixtureA.invitee.email },
|
||||
})
|
||||
).rejects.toThrow(INVITE_NOT_FOUND);
|
||||
|
||||
const devEnvs = await prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
organizationId: fixtureA.organization.id,
|
||||
orgMemberId: member.id,
|
||||
type: "DEVELOPMENT",
|
||||
},
|
||||
});
|
||||
expect(devEnvs).toHaveLength(1);
|
||||
expect(fixtureB.invite.id).not.toBe(fixtureA.invite.id);
|
||||
}
|
||||
);
|
||||
|
||||
postgresTest(
|
||||
"does not recover memberships for a different organizationId than the stale invite",
|
||||
{ timeout: 60_000 },
|
||||
async ({ prisma }) => {
|
||||
prismaHolder.client = prisma;
|
||||
const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");
|
||||
|
||||
const fixtureA = await seedInviteFixture(prisma, { activeProjectCount: 2 });
|
||||
const fixtureB = await seedInviteFixture(prisma, { activeProjectCount: 2 });
|
||||
|
||||
await prisma.orgMemberInvite.delete({ where: { id: fixtureA.invite.id } });
|
||||
|
||||
const member = await prisma.orgMember.create({
|
||||
data: {
|
||||
organizationId: fixtureA.organization.id,
|
||||
userId: fixtureA.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: fixtureA.activeProjects[0].id,
|
||||
organizationId: fixtureA.organization.id,
|
||||
orgMemberId: member.id,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
acceptInvite({
|
||||
inviteId: fixtureA.invite.id,
|
||||
organizationId: fixtureB.organization.id,
|
||||
user: { id: fixtureA.invitee.id, email: fixtureA.invitee.email },
|
||||
})
|
||||
).rejects.toThrow(INVITE_NOT_FOUND);
|
||||
|
||||
const devEnvs = await prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
organizationId: fixtureA.organization.id,
|
||||
orgMemberId: member.id,
|
||||
type: "DEVELOPMENT",
|
||||
},
|
||||
});
|
||||
expect(devEnvs).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user