@@ -6,25 +6,22 @@ import {
66 permissionGroup ,
77 permissionGroupMember ,
88 permissions ,
9- subscription as subscriptionTable ,
9+ session as sessionTable ,
1010 user ,
11- userStats ,
1211 type WorkspaceInvitationStatus ,
1312 workspaceEnvironment ,
1413 workspaceInvitation ,
1514} from '@sim/db/schema'
1615import { createLogger } from '@sim/logger'
17- import { and , eq , inArray } from 'drizzle-orm'
16+ import { and , eq , or } from 'drizzle-orm'
1817import { type NextRequest , NextResponse } from 'next/server'
1918import { z } from 'zod'
2019import { getEmailSubject , renderInvitationEmail } from '@/components/emails'
2120import { AuditAction , AuditResourceType , recordAudit } from '@/lib/audit/log'
2221import { getSession } from '@/lib/auth'
2322import { hasAccessControlAccess } from '@/lib/billing'
2423import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
25- import { isOrgPlan , sqlIsPro } from '@/lib/billing/plan-helpers'
26- import { requireStripeClient } from '@/lib/billing/stripe-client'
27- import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
24+ import { ensureUserInOrganization } from '@/lib/billing/organizations/membership'
2825import { getBaseUrl } from '@/lib/core/utils/urls'
2926import { generateId } from '@/lib/core/utils/uuid'
3027import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
@@ -268,157 +265,70 @@ export async function PUT(
268265 }
269266
270267 if ( status === 'cancelled' ) {
271- const isAdmin = await db
268+ const hasInvitationAdminAccess = await db
272269 . select ( )
273270 . from ( member )
274271 . where (
275272 and (
276273 eq ( member . organizationId , organizationId ) ,
277274 eq ( member . userId , session . user . id ) ,
278- eq ( member . role , 'admin' )
275+ or ( eq ( member . role , 'owner' ) , eq ( member . role , ' admin') )
279276 )
280277 )
281278 . then ( ( rows ) => rows . length > 0 )
282279
283- if ( ! isAdmin ) {
280+ if ( ! hasInvitationAdminAccess ) {
284281 return NextResponse . json (
285- { error : 'Only organization admins can cancel invitations' } ,
282+ { error : 'Only organization owners and admins can cancel invitations' } ,
286283 { status : 403 }
287284 )
288285 }
289286 }
290287
291- // Enforce: user can only be part of a single organization
288+ let membershipAlreadyExists = false
289+
292290 if ( status === 'accepted' ) {
293- // Check if user is already a member of ANY organization
294- const existingOrgMemberships = await db
295- . select ( { organizationId : member . organizationId } )
296- . from ( member )
297- . where ( eq ( member . userId , session . user . id ) )
291+ const membershipResult = await ensureUserInOrganization ( {
292+ userId : session . user . id ,
293+ organizationId,
294+ role : orgInvitation . role ,
295+ } )
298296
299- if ( existingOrgMemberships . length > 0 ) {
300- // Check if already a member of THIS specific organization
301- const alreadyMemberOfThisOrg = existingOrgMemberships . some (
302- ( m ) => m . organizationId === organizationId
303- )
297+ if ( ! membershipResult . success ) {
298+ if ( membershipResult . existingOrgId ) {
299+ await db
300+ . update ( invitation )
301+ . set ( {
302+ status : 'rejected' ,
303+ } )
304+ . where ( eq ( invitation . id , invitationId ) )
304305
305- if ( alreadyMemberOfThisOrg ) {
306306 return NextResponse . json (
307- { error : 'You are already a member of this organization' } ,
308- { status : 400 }
307+ {
308+ error :
309+ 'You are already a member of an organization. Leave your current organization before accepting a new invitation.' ,
310+ } ,
311+ { status : 409 }
309312 )
310313 }
311314
312- // Member of a different organization
313- // Mark the invitation as rejected since they can't accept it
314- await db
315- . update ( invitation )
316- . set ( {
317- status : 'rejected' ,
318- } )
319- . where ( eq ( invitation . id , invitationId ) )
320-
321315 return NextResponse . json (
322- {
323- error :
324- 'You are already a member of an organization. Leave your current organization before accepting a new invitation.' ,
325- } ,
326- { status : 409 }
316+ { error : membershipResult . error || 'Failed to join this organization' } ,
317+ { status : 400 }
327318 )
328319 }
329- }
330320
331- let personalProToCancel : any = null
321+ membershipAlreadyExists = membershipResult . alreadyMember
322+ }
332323
333324 await db . transaction ( async ( tx ) => {
334325 await tx . update ( invitation ) . set ( { status } ) . where ( eq ( invitation . id , invitationId ) )
335326
336327 if ( status === 'accepted' ) {
337- await tx . insert ( member ) . values ( {
338- id : generateId ( ) ,
339- userId : session . user . id ,
340- organizationId,
341- role : orgInvitation . role ,
342- createdAt : new Date ( ) ,
343- } )
344-
345- // Snapshot Pro usage and cancel Pro subscription when joining a paid team
346- try {
347- const orgSubs = await tx
348- . select ( )
349- . from ( subscriptionTable )
350- . where (
351- and (
352- eq ( subscriptionTable . referenceId , organizationId ) ,
353- inArray ( subscriptionTable . status , ENTITLED_SUBSCRIPTION_STATUSES )
354- )
355- )
356- . limit ( 1 )
357-
358- const orgSub = orgSubs [ 0 ]
359- const orgIsPaid = orgSub && isOrgPlan ( orgSub . plan )
360-
361- if ( orgIsPaid ) {
362- const userId = session . user . id
363-
364- // Find user's active personal Pro subscription
365- const personalSubs = await tx
366- . select ( )
367- . from ( subscriptionTable )
368- . where (
369- and (
370- eq ( subscriptionTable . referenceId , userId ) ,
371- inArray ( subscriptionTable . status , ENTITLED_SUBSCRIPTION_STATUSES ) ,
372- sqlIsPro ( subscriptionTable . plan )
373- )
374- )
375- . limit ( 1 )
376-
377- const personalPro = personalSubs [ 0 ]
378- if ( personalPro ) {
379- // Snapshot the current Pro usage before resetting
380- const userStatsRows = await tx
381- . select ( {
382- currentPeriodCost : userStats . currentPeriodCost ,
383- } )
384- . from ( userStats )
385- . where ( eq ( userStats . userId , userId ) )
386- . limit ( 1 )
387-
388- if ( userStatsRows . length > 0 ) {
389- const currentProUsage = userStatsRows [ 0 ] . currentPeriodCost || '0'
390-
391- // Snapshot Pro usage and reset currentPeriodCost so new usage goes to team
392- await tx
393- . update ( userStats )
394- . set ( {
395- proPeriodCostSnapshot : currentProUsage ,
396- currentPeriodCost : '0' , // Reset so new usage is attributed to team
397- currentPeriodCopilotCost : '0' , // Reset copilot cost for new period
398- } )
399- . where ( eq ( userStats . userId , userId ) )
400-
401- logger . info ( 'Snapshotted Pro usage when joining team' , {
402- userId,
403- proUsageSnapshot : currentProUsage ,
404- organizationId,
405- } )
406- }
407-
408- // Mark for cancellation after transaction
409- if ( personalPro . cancelAtPeriodEnd !== true ) {
410- personalProToCancel = personalPro
411- }
412- }
413- }
414- } catch ( error ) {
415- logger . error ( 'Failed to handle Pro user joining team' , {
416- userId : session . user . id ,
417- organizationId,
418- error,
419- } )
420- // Don't fail the whole invitation acceptance due to this
421- }
328+ await tx
329+ . update ( sessionTable )
330+ . set ( { activeOrganizationId : organizationId } )
331+ . where ( eq ( sessionTable . userId , session . user . id ) )
422332
423333 // Auto-assign to permission group if one has autoAddNewMembers enabled
424334 try {
@@ -436,20 +346,29 @@ export async function PUT(
436346 . limit ( 1 )
437347
438348 if ( autoAddGroup ) {
439- await tx . insert ( permissionGroupMember ) . values ( {
440- id : generateId ( ) ,
441- permissionGroupId : autoAddGroup . id ,
442- userId : session . user . id ,
443- assignedBy : null ,
444- assignedAt : new Date ( ) ,
445- } )
446-
447- logger . info ( 'Auto-assigned new member to permission group' , {
448- userId : session . user . id ,
449- organizationId,
450- permissionGroupId : autoAddGroup . id ,
451- permissionGroupName : autoAddGroup . name ,
452- } )
349+ const [ existingPermissionGroupMember ] = await tx
350+ . select ( { id : permissionGroupMember . id } )
351+ . from ( permissionGroupMember )
352+ . where ( eq ( permissionGroupMember . userId , session . user . id ) )
353+ . limit ( 1 )
354+
355+ if ( ! existingPermissionGroupMember ) {
356+ await tx . insert ( permissionGroupMember ) . values ( {
357+ id : generateId ( ) ,
358+ permissionGroupId : autoAddGroup . id ,
359+ userId : session . user . id ,
360+ assignedBy : null ,
361+ assignedAt : new Date ( ) ,
362+ } )
363+
364+ logger . info ( 'Auto-assigned new member to permission group' , {
365+ userId : session . user . id ,
366+ organizationId,
367+ permissionGroupId : autoAddGroup . id ,
368+ permissionGroupName : autoAddGroup . name ,
369+ membershipAlreadyExists,
370+ } )
371+ }
453372 }
454373 }
455374 } catch ( error ) {
@@ -557,45 +476,7 @@ export async function PUT(
557476 }
558477 }
559478
560- // Handle Pro subscription cancellation after transaction commits
561- if ( personalProToCancel ) {
562- try {
563- const stripe = requireStripeClient ( )
564- if ( personalProToCancel . stripeSubscriptionId ) {
565- try {
566- await stripe . subscriptions . update ( personalProToCancel . stripeSubscriptionId , {
567- cancel_at_period_end : true ,
568- } )
569- } catch ( stripeError ) {
570- logger . error ( 'Failed to set cancel_at_period_end on Stripe for personal Pro' , {
571- userId : session . user . id ,
572- subscriptionId : personalProToCancel . id ,
573- stripeSubscriptionId : personalProToCancel . stripeSubscriptionId ,
574- error : stripeError ,
575- } )
576- }
577- }
578-
579- await db
580- . update ( subscriptionTable )
581- . set ( { cancelAtPeriodEnd : true } )
582- . where ( eq ( subscriptionTable . id , personalProToCancel . id ) )
583-
584- logger . info ( 'Auto-cancelled personal Pro at period end after joining paid team' , {
585- userId : session . user . id ,
586- personalSubscriptionId : personalProToCancel . id ,
587- organizationId,
588- } )
589- } catch ( dbError ) {
590- logger . error ( 'Failed to update DB cancelAtPeriodEnd for personal Pro' , {
591- userId : session . user . id ,
592- subscriptionId : personalProToCancel . id ,
593- error : dbError ,
594- } )
595- }
596- }
597-
598- if ( status === 'accepted' ) {
479+ if ( status === 'accepted' && ! membershipAlreadyExists ) {
599480 try {
600481 await syncUsageLimitsFromSubscription ( session . user . id )
601482 } catch ( syncError ) {
0 commit comments