Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/sim/app/api/v1/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
* GET /api/v1/admin/users/:id - Get user details
* GET /api/v1/admin/users/:id/billing - Get user billing info
* PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked)
* POST /api/v1/admin/users/:id/billing/move-to-org - Move user to organization
*
* Workspaces:
* GET /api/v1/admin/workspaces - List all workspaces
Expand All @@ -36,7 +35,7 @@
* GET /api/v1/admin/organizations/:id - Get organization details
* PATCH /api/v1/admin/organizations/:id - Update organization
* GET /api/v1/admin/organizations/:id/members - List organization members
* POST /api/v1/admin/organizations/:id/members - Add member to organization
* POST /api/v1/admin/organizations/:id/members - Add/update member in organization
* GET /api/v1/admin/organizations/:id/members/:mid - Get member details
* PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role
* DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member
Expand Down
73 changes: 67 additions & 6 deletions apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
*
* Add a user to an organization with full billing logic.
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
* If user is already a member, updates their role if different.
*
* Body:
* - userId: string - User ID to add
* - role: string - Role ('admin' | 'member')
* - skipBillingLogic?: boolean - Skip Pro cancellation (default: false)
*
* Response: AdminSingleResponse<AdminMember>
* Response: AdminSingleResponse<AdminMember & {
* action: 'created' | 'updated' | 'already_member',
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
* }>
*/

import { db } from '@sim/db'
Expand Down Expand Up @@ -129,8 +132,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('role must be "admin" or "member"')
}

const skipBillingLogic = body.skipBillingLogic === true

const [orgData] = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
Expand All @@ -151,11 +152,71 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return notFoundResponse('User')
}

const [existingMember] = await db
.select({
id: member.id,
role: member.role,
createdAt: member.createdAt,
organizationId: member.organizationId,
})
.from(member)
.where(eq(member.userId, body.userId))
.limit(1)

if (existingMember) {
if (existingMember.organizationId === organizationId) {
if (existingMember.role !== body.role) {
await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id))

logger.info(
`Admin API: Updated user ${body.userId} role in organization ${organizationId}`,
{
previousRole: existingMember.role,
newRole: body.role,
}
)

return singleResponse({
id: existingMember.id,
userId: body.userId,
organizationId,
role: body.role,
createdAt: existingMember.createdAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
action: 'updated' as const,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}

return singleResponse({
id: existingMember.id,
userId: body.userId,
organizationId,
role: existingMember.role,
createdAt: existingMember.createdAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
action: 'already_member' as const,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}

return badRequestResponse(
`User is already a member of another organization. Users can only belong to one organization at a time.`
)
}

const result = await addUserToOrganization({
userId: body.userId,
organizationId,
role: body.role,
skipBillingLogic,
})

if (!result.success) {
Expand All @@ -176,11 +237,11 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
role: body.role,
memberId: result.memberId,
billingActions: result.billingActions,
skipBillingLogic,
})

return singleResponse({
...data,
action: 'created' as const,
billingActions: {
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
Expand Down
13 changes: 4 additions & 9 deletions apps/sim/app/api/v1/admin/organizations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
* Body:
* - name?: string - Organization name
* - slug?: string - Organization slug
* - orgUsageLimit?: number - Usage limit (null to clear)
*
* Response: AdminSingleResponse<AdminOrganization>
*/
Expand Down Expand Up @@ -112,14 +111,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
updateData.slug = body.slug.trim()
}

if (body.orgUsageLimit !== undefined) {
if (body.orgUsageLimit === null) {
updateData.orgUsageLimit = null
} else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) {
updateData.orgUsageLimit = body.orgUsageLimit.toFixed(2)
} else {
return badRequestResponse('orgUsageLimit must be a non-negative number or null')
}
if (Object.keys(updateData).length === 1) {
return badRequestResponse(
'No valid fields to update. Use /billing endpoint for orgUsageLimit.'
)
}

const [updated] = await db
Expand Down
71 changes: 62 additions & 9 deletions apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@
*
* PATCH /api/v1/admin/organizations/[id]/seats
*
* Update organization seat count (for admin override of enterprise seats).
* Update organization seat count with Stripe sync (matches user flow).
*
* Body:
* - seats: number - New seat count (for enterprise metadata.seats)
* - seats: number - New seat count (positive integer)
*
* Response: AdminSingleResponse<{ success: true, seats: number }>
* Response: AdminSingleResponse<{ success: true, seats: number, plan: string, stripeUpdated?: boolean }>
*/

import { db } from '@sim/db'
import { organization, subscription } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
Expand Down Expand Up @@ -105,11 +106,14 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
return notFoundResponse('Subscription')
}

const newSeatCount = body.seats
let stripeUpdated = false

if (subData.plan === 'enterprise') {
const currentMetadata = (subData.metadata as Record<string, unknown>) || {}
const newMetadata = {
...currentMetadata,
seats: body.seats,
seats: newSeatCount,
}

await db
Expand All @@ -118,23 +122,72 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
.where(eq(subscription.id, subData.id))

logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
seats: body.seats,
seats: newSeatCount,
})
} else {
} else if (subData.plan === 'team') {
if (subData.stripeSubscriptionId) {
const stripe = requireStripeClient()

const stripeSubscription = await stripe.subscriptions.retrieve(subData.stripeSubscriptionId)

if (stripeSubscription.status !== 'active') {
return badRequestResponse('Stripe subscription is not active')
}

const subscriptionItem = stripeSubscription.items.data[0]
if (!subscriptionItem) {
return internalErrorResponse('No subscription item found in Stripe subscription')
}

const currentSeats = subData.seats || 1

logger.info('Admin API: Updating Stripe subscription quantity', {
organizationId,
stripeSubscriptionId: subData.stripeSubscriptionId,
subscriptionItemId: subscriptionItem.id,
currentSeats,
newSeatCount,
})

await stripe.subscriptions.update(subData.stripeSubscriptionId, {
items: [
{
id: subscriptionItem.id,
quantity: newSeatCount,
},
],
proration_behavior: 'create_prorations',
})

stripeUpdated = true
}

await db
.update(subscription)
.set({ seats: body.seats })
.set({ seats: newSeatCount })
.where(eq(subscription.id, subData.id))

logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
seats: body.seats,
seats: newSeatCount,
stripeUpdated,
})
} else {
await db
.update(subscription)
.set({ seats: newSeatCount })
.where(eq(subscription.id, subData.id))

logger.info(`Admin API: Updated seats for organization ${organizationId}`, {
seats: newSeatCount,
plan: subData.plan,
})
}

return singleResponse({
success: true,
seats: body.seats,
seats: newSeatCount,
plan: subData.plan,
stripeUpdated,
})
} catch (error) {
logger.error('Admin API: Failed to update organization seats', { error, organizationId })
Expand Down
Loading