Skip to content

Commit f7dfa42

Browse files
committed
workspace re-org checkpoint
1 parent 64cdab2 commit f7dfa42

35 files changed

Lines changed: 16712 additions & 698 deletions

File tree

apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts

Lines changed: 59 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
1615
import { createLogger } from '@sim/logger'
17-
import { and, eq, inArray } from 'drizzle-orm'
16+
import { and, eq, or } from 'drizzle-orm'
1817
import { type NextRequest, NextResponse } from 'next/server'
1918
import { z } from 'zod'
2019
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
2120
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
2221
import { getSession } from '@/lib/auth'
2322
import { hasAccessControlAccess } from '@/lib/billing'
2423
import { 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'
2825
import { getBaseUrl } from '@/lib/core/utils/urls'
2926
import { generateId } from '@/lib/core/utils/uuid'
3027
import { 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

Comments
 (0)