Skip to content

Commit ea42a99

Browse files
committed
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.
1 parent b1987dc commit ea42a99

5 files changed

Lines changed: 455 additions & 48 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fixed invite acceptance failing for organizations with many projects.
7+
8+
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.

apps/webapp/app/models/member.server.ts

Lines changed: 129 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
1-
import { type Prisma, prisma } from "~/db.server";
1+
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
2+
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
23
import { createEnvironment } from "./organization.server";
34
import { customAlphabet } from "nanoid";
45
import { logger } from "~/services/logger.server";
6+
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
57
import { rbac } from "~/services/rbac.server";
68

9+
export const INVITE_NOT_FOUND = "Invite not found";
10+
export const ENV_SETUP_INCOMPLETE =
11+
"You joined the organization, but we couldn't finish setting up your development environments. Please try again or contact support if this persists.";
12+
13+
export function isAcceptInviteFormError(error: unknown): error is Error {
14+
return (
15+
error instanceof Error &&
16+
(error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE)
17+
);
18+
}
19+
720
const tokenValueLength = 40;
821
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
922

@@ -177,65 +190,138 @@ export async function getUsersInvites({ email }: { email: string }) {
177190
});
178191
}
179192

180-
export async function acceptInvite({
181-
user,
193+
export async function provisionMemberDevelopmentEnvironments({
182194
inviteId,
195+
user,
196+
member,
197+
organization,
198+
projects,
199+
maximumConcurrencyLimit,
183200
}: {
184-
user: { id: string; email: string };
185201
inviteId: string;
202+
user: { id: string; email: string };
203+
member: OrgMember;
204+
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
205+
projects: Pick<Project, "id">[];
206+
maximumConcurrencyLimit: number;
186207
}) {
187-
const result = await prisma.$transaction(async (tx) => {
188-
// 1. Delete the invite and get the invite details
189-
const invite = await tx.orgMemberInvite.delete({
190-
where: {
191-
id: inviteId,
192-
email: user.email,
193-
},
194-
include: {
195-
organization: {
196-
include: {
197-
projects: true,
198-
},
199-
},
200-
},
201-
});
208+
const projectIds = projects.map((p) => p.id);
209+
const createdProjectIds: string[] = [];
210+
let failedProjectId: string | undefined;
211+
let failedProjectIndex: number | undefined;
202212

203-
// 2. Join the organization
204-
const member = await tx.orgMember.create({
205-
data: {
206-
organizationId: invite.organizationId,
207-
userId: user.id,
208-
role: invite.role,
209-
},
210-
});
213+
try {
214+
for (const [index, project] of projects.entries()) {
215+
failedProjectId = project.id;
216+
failedProjectIndex = index;
211217

212-
// 3. Create an environment for each project
213-
for (const project of invite.organization.projects) {
214218
await createEnvironment({
215-
organization: invite.organization,
219+
organization,
216220
project,
217221
type: "DEVELOPMENT",
218222
// We set this true but no backfill (yet!?) so never used
219223
// for dev environments
220224
isBranchableEnvironment: true,
221225
member,
222-
prismaClient: tx,
226+
maximumConcurrencyLimit,
223227
});
228+
229+
createdProjectIds.push(project.id);
230+
failedProjectId = undefined;
231+
failedProjectIndex = undefined;
224232
}
233+
} catch (error) {
234+
logger.error("acceptInvite: development environment creation failed after membership created", {
235+
inviteId,
236+
userId: user.id,
237+
organizationId: organization.id,
238+
orgMemberId: member.id,
239+
projectIds,
240+
failedProjectId,
241+
failedProjectIndex,
242+
totalProjects: projects.length,
243+
createdProjectIds,
244+
error:
245+
error instanceof Error
246+
? { name: error.name, message: error.message, stack: error.stack }
247+
: String(error),
248+
});
225249

226-
// 4. Check for other invites
227-
const remainingInvites = await tx.orgMemberInvite.findMany({
228-
where: {
229-
email: user.email,
230-
},
250+
throw new Error(ENV_SETUP_INCOMPLETE);
251+
}
252+
}
253+
254+
export async function acceptInvite({
255+
user,
256+
inviteId,
257+
}: {
258+
user: { id: string; email: string };
259+
inviteId: string;
260+
}) {
261+
const pendingInvite = await prisma.orgMemberInvite.findFirst({
262+
where: { id: inviteId, email: user.email },
263+
select: { id: true, organizationId: true },
264+
});
265+
if (!pendingInvite) {
266+
throw new Error(INVITE_NOT_FOUND);
267+
}
268+
269+
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
270+
pendingInvite.organizationId,
271+
"DEVELOPMENT"
272+
);
273+
274+
let result;
275+
try {
276+
result = await prisma.$transaction(async (tx) => {
277+
const invite = await tx.orgMemberInvite.delete({
278+
where: {
279+
id: inviteId,
280+
email: user.email,
281+
},
282+
include: {
283+
organization: {
284+
include: {
285+
projects: { where: { deletedAt: null } },
286+
},
287+
},
288+
},
289+
});
290+
291+
const member = await tx.orgMember.create({
292+
data: {
293+
organizationId: invite.organizationId,
294+
userId: user.id,
295+
role: invite.role,
296+
},
297+
});
298+
299+
return {
300+
member,
301+
organization: invite.organization,
302+
rbacRoleId: invite.rbacRoleId,
303+
};
231304
});
305+
} catch (error) {
306+
if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") {
307+
throw new Error(INVITE_NOT_FOUND);
308+
}
309+
throw error;
310+
}
232311

233-
return {
234-
remainingInvites,
235-
organization: invite.organization,
236-
inviteRole: invite.role,
237-
rbacRoleId: invite.rbacRoleId,
238-
};
312+
await provisionMemberDevelopmentEnvironments({
313+
inviteId,
314+
user,
315+
member: result.member,
316+
organization: result.organization,
317+
projects: result.organization.projects,
318+
maximumConcurrencyLimit,
319+
});
320+
321+
const remainingInvites = await prisma.orgMemberInvite.findMany({
322+
where: {
323+
email: user.email,
324+
},
239325
});
240326

241327
// If the invite carried an explicit RBAC role, assign it. Best-effort: the
@@ -271,7 +357,7 @@ export async function acceptInvite({
271357
}
272358
}
273359

274-
return { remainingInvites: result.remainingInvites, organization: result.organization };
360+
return { remainingInvites, organization: result.organization };
275361
}
276362

277363
export async function declineInvite({

apps/webapp/app/models/organization.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,20 +129,24 @@ export async function createEnvironment({
129129
isBranchableEnvironment = false,
130130
member,
131131
prismaClient = prisma,
132+
/** When set, skips billing lookup — caller must supply the limit for this org + type. */
133+
maximumConcurrencyLimit,
132134
}: {
133135
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
134136
project: Pick<Project, "id">;
135137
type: RuntimeEnvironment["type"];
136138
isBranchableEnvironment?: boolean;
137139
member?: OrgMember;
138140
prismaClient?: PrismaClientOrTransaction;
141+
maximumConcurrencyLimit?: number;
139142
}) {
140143
const slug = envSlug(type);
141144
const apiKey = createApiKeyForEnv(type);
142145
const pkApiKey = createPkApiKeyForEnv(type);
143146
const shortcode = createShortcode().join("-");
144147

145-
const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
148+
const limit =
149+
maximumConcurrencyLimit ?? (await getDefaultEnvironmentConcurrencyLimit(organization.id, type));
146150
const billingPause = await getInitialEnvPauseStateForBillingLimit(organization.id, type);
147151

148152
const environment = await prismaClient.runtimeEnvironment.create({

apps/webapp/app/routes/invites.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ import { Fieldset } from "~/components/primitives/Fieldset";
1111
import { FormTitle } from "~/components/primitives/FormTitle";
1212
import { Header2, Header3 } from "~/components/primitives/Headers";
1313
import { InputGroup } from "~/components/primitives/InputGroup";
14+
import { FormError } from "~/components/primitives/FormError";
1415
import { Paragraph } from "~/components/primitives/Paragraph";
15-
import { acceptInvite, declineInvite, getUsersInvites } from "~/models/member.server";
16-
import { redirectWithSuccessMessage } from "~/models/message.server";
16+
import {
17+
acceptInvite,
18+
declineInvite,
19+
ENV_SETUP_INCOMPLETE,
20+
getUsersInvites,
21+
isAcceptInviteFormError,
22+
} from "~/models/member.server";
23+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
1724
import { requireUser, requireUserId } from "~/services/session.server";
1825
import { invitesPath, rootPath } from "~/utils/pathBuilder";
1926
import { EnvelopeIcon } from "@heroicons/react/20/solid";
@@ -80,8 +87,30 @@ export const action: ActionFunction = async ({ request }) => {
8087
);
8188
}
8289
}
83-
} catch (error: any) {
84-
return json({ errors: { body: error.message } }, { status: 400 });
90+
} catch (error) {
91+
if (isAcceptInviteFormError(error)) {
92+
// Membership was created and the invite deleted before env provisioning
93+
// failed. With no invites left, the loader would redirect and discard
94+
// a 400 FormError — send the user to orgs with a toast instead.
95+
if (error.message === ENV_SETUP_INCOMPLETE) {
96+
const remainingInvites = await getUsersInvites({ email: user.email });
97+
if (remainingInvites.length === 0) {
98+
return redirectWithErrorMessage(rootPath(), request, error.message, {
99+
ephemeral: false,
100+
});
101+
}
102+
}
103+
104+
return json(
105+
{
106+
intent: submission.intent,
107+
payload: submission.payload,
108+
error: { __form__: [error.message] },
109+
},
110+
{ status: 400 }
111+
);
112+
}
113+
throw error;
85114
}
86115
};
87116

@@ -111,6 +140,7 @@ export default function Page() {
111140
className="mb-0 text-sky-500"
112141
title={simplur`You have ${invites.length} new invitation[|s]`}
113142
/>
143+
<FormError>{form.error}</FormError>
114144
{invites.map((invite) => (
115145
<Form key={invite.id} method="post" {...form.props}>
116146
<Fieldset>

0 commit comments

Comments
 (0)