Skip to content

Commit ad7a7d4

Browse files
committed
f
1 parent ea42a99 commit ad7a7d4

3 files changed

Lines changed: 355 additions & 79 deletions

File tree

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

Lines changed: 198 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { rbac } from "~/services/rbac.server";
88

99
export const INVITE_NOT_FOUND = "Invite not found";
1010
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.";
11+
"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.";
1212

1313
export function isAcceptInviteFormError(error: unknown): error is Error {
1414
return (
@@ -190,6 +190,33 @@ export async function getUsersInvites({ email }: { email: string }) {
190190
});
191191
}
192192

193+
async function getProjectsMissingMemberDevelopmentEnvironments({
194+
memberId,
195+
organizationId,
196+
projects,
197+
}: {
198+
memberId: string;
199+
organizationId: string;
200+
projects: Pick<Project, "id">[];
201+
}) {
202+
if (projects.length === 0) {
203+
return [];
204+
}
205+
206+
const existingEnvs = await prisma.runtimeEnvironment.findMany({
207+
where: {
208+
orgMemberId: memberId,
209+
organizationId,
210+
type: "DEVELOPMENT",
211+
projectId: { in: projects.map((project) => project.id) },
212+
},
213+
select: { projectId: true },
214+
});
215+
const existingProjectIds = new Set(existingEnvs.map((env) => env.projectId));
216+
217+
return projects.filter((project) => !existingProjectIds.has(project.id));
218+
}
219+
193220
export async function provisionMemberDevelopmentEnvironments({
194221
inviteId,
195222
user,
@@ -205,13 +232,18 @@ export async function provisionMemberDevelopmentEnvironments({
205232
projects: Pick<Project, "id">[];
206233
maximumConcurrencyLimit: number;
207234
}) {
208-
const projectIds = projects.map((p) => p.id);
235+
const projectsNeedingEnvs = await getProjectsMissingMemberDevelopmentEnvironments({
236+
memberId: member.id,
237+
organizationId: organization.id,
238+
projects,
239+
});
240+
const projectIds = projects.map((project) => project.id);
209241
const createdProjectIds: string[] = [];
210242
let failedProjectId: string | undefined;
211243
let failedProjectIndex: number | undefined;
212244

213245
try {
214-
for (const [index, project] of projects.entries()) {
246+
for (const [index, project] of projectsNeedingEnvs.entries()) {
215247
failedProjectId = project.id;
216248
failedProjectIndex = index;
217249

@@ -239,7 +271,7 @@ export async function provisionMemberDevelopmentEnvironments({
239271
projectIds,
240272
failedProjectId,
241273
failedProjectIndex,
242-
totalProjects: projects.length,
274+
totalProjects: projectsNeedingEnvs.length,
243275
createdProjectIds,
244276
error:
245277
error instanceof Error
@@ -251,113 +283,208 @@ export async function provisionMemberDevelopmentEnvironments({
251283
}
252284
}
253285

286+
async function assignInviteRbacRole({
287+
userId,
288+
organizationId,
289+
rbacRoleId,
290+
}: {
291+
userId: string;
292+
organizationId: string;
293+
rbacRoleId: string;
294+
}) {
295+
try {
296+
const roleResult = await rbac.setUserRole({
297+
userId,
298+
organizationId,
299+
roleId: rbacRoleId,
300+
});
301+
if (!roleResult.ok) {
302+
logger.error("acceptInvite: skipped RBAC role assignment", {
303+
organizationId,
304+
userId,
305+
rbacRoleId,
306+
reason: roleResult.error,
307+
});
308+
}
309+
} catch (error) {
310+
logger.error("acceptInvite: RBAC role assignment threw", {
311+
organizationId,
312+
userId,
313+
rbacRoleId,
314+
error:
315+
error instanceof Error
316+
? { name: error.name, message: error.message, stack: error.stack }
317+
: String(error),
318+
});
319+
}
320+
}
321+
322+
async function tryRecoverIncompleteInviteAccept({ user }: { user: { id: string; email: string } }) {
323+
const members = await prisma.orgMember.findMany({
324+
where: {
325+
userId: user.id,
326+
organization: { deletedAt: null },
327+
},
328+
include: {
329+
organization: {
330+
include: {
331+
projects: { where: { deletedAt: null } },
332+
},
333+
},
334+
},
335+
});
336+
337+
const incompleteMemberships = [];
338+
for (const member of members) {
339+
const missingProjects = await getProjectsMissingMemberDevelopmentEnvironments({
340+
memberId: member.id,
341+
organizationId: member.organizationId,
342+
projects: member.organization.projects,
343+
});
344+
if (missingProjects.length > 0) {
345+
incompleteMemberships.push({
346+
member,
347+
organization: member.organization,
348+
missingProjects,
349+
});
350+
}
351+
}
352+
353+
if (incompleteMemberships.length === 0) {
354+
return null;
355+
}
356+
357+
for (const { member, organization, missingProjects } of incompleteMemberships) {
358+
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
359+
organization.id,
360+
"DEVELOPMENT"
361+
);
362+
363+
await provisionMemberDevelopmentEnvironments({
364+
inviteId: "recovery",
365+
user,
366+
member,
367+
organization,
368+
projects: missingProjects,
369+
maximumConcurrencyLimit,
370+
});
371+
}
372+
373+
return {
374+
remainingInvites: await getUsersInvites({ email: user.email }),
375+
organization: incompleteMemberships[0].organization,
376+
};
377+
}
378+
254379
export async function acceptInvite({
255380
user,
256381
inviteId,
257382
}: {
258383
user: { id: string; email: string };
259384
inviteId: string;
260385
}) {
261-
const pendingInvite = await prisma.orgMemberInvite.findFirst({
386+
const invite = await prisma.orgMemberInvite.findFirst({
262387
where: { id: inviteId, email: user.email },
263-
select: { id: true, organizationId: true },
388+
include: {
389+
organization: {
390+
include: {
391+
projects: { where: { deletedAt: null } },
392+
},
393+
},
394+
},
264395
});
265-
if (!pendingInvite) {
396+
397+
if (!invite) {
398+
const recovered = await tryRecoverIncompleteInviteAccept({ user });
399+
if (recovered) {
400+
return recovered;
401+
}
266402
throw new Error(INVITE_NOT_FOUND);
267403
}
268404

269405
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
270-
pendingInvite.organizationId,
406+
invite.organizationId,
271407
"DEVELOPMENT"
272408
);
273409

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-
});
410+
let member = await prisma.orgMember.findFirst({
411+
where: {
412+
organizationId: invite.organizationId,
413+
userId: user.id,
414+
},
415+
});
290416

291-
const member = await tx.orgMember.create({
417+
if (!member) {
418+
try {
419+
member = await prisma.orgMember.create({
292420
data: {
293421
organizationId: invite.organizationId,
294422
userId: user.id,
295423
role: invite.role,
296424
},
297425
});
298-
299-
return {
300-
member,
301-
organization: invite.organization,
302-
rbacRoleId: invite.rbacRoleId,
303-
};
304-
});
305-
} catch (error) {
306-
if (error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025") {
307-
throw new Error(INVITE_NOT_FOUND);
426+
} catch (error) {
427+
if (
428+
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
429+
error.code === "P2002"
430+
) {
431+
member = await prisma.orgMember.findFirst({
432+
where: {
433+
organizationId: invite.organizationId,
434+
userId: user.id,
435+
},
436+
});
437+
if (!member) {
438+
throw error;
439+
}
440+
} else {
441+
throw error;
442+
}
308443
}
309-
throw error;
310444
}
311445

312446
await provisionMemberDevelopmentEnvironments({
313447
inviteId,
314448
user,
315-
member: result.member,
316-
organization: result.organization,
317-
projects: result.organization.projects,
449+
member,
450+
organization: invite.organization,
451+
projects: invite.organization.projects,
318452
maximumConcurrencyLimit,
319453
});
320454

321-
const remainingInvites = await prisma.orgMemberInvite.findMany({
322-
where: {
323-
email: user.email,
324-
},
325-
});
455+
// Consume the invite only after development environments are provisioned so
456+
// a failed setup can be retried from /invites.
457+
try {
458+
await prisma.orgMemberInvite.delete({
459+
where: {
460+
id: inviteId,
461+
email: user.email,
462+
},
463+
});
464+
} catch (error) {
465+
if (
466+
!(error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025")
467+
) {
468+
throw error;
469+
}
470+
}
471+
472+
const remainingInvites = await getUsersInvites({ email: user.email });
326473

327474
// If the invite carried an explicit RBAC role, assign it. Best-effort: the
328475
// invite is already consumed and membership created above, so a failure here
329476
// — a returned {ok:false} or a thrown error from the plugin — must not block
330477
// joining the org. Swallow and log either way; without the catch a plugin
331478
// throw escapes and turns the whole invite-accept into a 400.
332-
if (result.rbacRoleId) {
333-
try {
334-
const roleResult = await rbac.setUserRole({
335-
userId: user.id,
336-
organizationId: result.organization.id,
337-
roleId: result.rbacRoleId,
338-
});
339-
if (!roleResult.ok) {
340-
logger.error("acceptInvite: skipped RBAC role assignment", {
341-
organizationId: result.organization.id,
342-
userId: user.id,
343-
rbacRoleId: result.rbacRoleId,
344-
reason: roleResult.error,
345-
});
346-
}
347-
} catch (error) {
348-
logger.error("acceptInvite: RBAC role assignment threw", {
349-
organizationId: result.organization.id,
350-
userId: user.id,
351-
rbacRoleId: result.rbacRoleId,
352-
error:
353-
error instanceof Error
354-
? { name: error.name, message: error.message, stack: error.stack }
355-
: String(error),
356-
});
357-
}
479+
if (invite.rbacRoleId) {
480+
await assignInviteRbacRole({
481+
userId: user.id,
482+
organizationId: invite.organization.id,
483+
rbacRoleId: invite.rbacRoleId,
484+
});
358485
}
359486

360-
return { remainingInvites, organization: result.organization };
487+
return { remainingInvites, organization: invite.organization };
361488
}
362489

363490
export async function declineInvite({

apps/webapp/app/routes/invites.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ export const action: ActionFunction = async ({ request }) => {
8989
}
9090
} catch (error) {
9191
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.
92+
// Membership may already exist while the invite is still present if env
93+
// provisioning failed. With no invites left, the loader would redirect
94+
// and discard a 400 FormError — send the user to orgs with a toast instead.
9595
if (error.message === ENV_SETUP_INCOMPLETE) {
9696
const remainingInvites = await getUsersInvites({ email: user.email });
9797
if (remainingInvites.length === 0) {

0 commit comments

Comments
 (0)