@@ -8,7 +8,7 @@ import { rbac } from "~/services/rbac.server";
88
99export const INVITE_NOT_FOUND = "Invite not found" ;
1010export 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
1313export 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+
193220export 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+
254379export 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
363490export async function declineInvite ( {
0 commit comments