Skip to content

Commit e067b58

Browse files
authored
feat(admin): updated admin routes to consolidate duplicate behavior (#2257)
* feat(admin): updated admin routes to consolidate duplicate behavior * ack PR comments
1 parent 87084ed commit e067b58

File tree

5 files changed

+134
-186
lines changed

5 files changed

+134
-186
lines changed

apps/sim/app/api/v1/admin/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
* GET /api/v1/admin/users/:id - Get user details
1414
* GET /api/v1/admin/users/:id/billing - Get user billing info
1515
* PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked)
16-
* POST /api/v1/admin/users/:id/billing/move-to-org - Move user to organization
1716
*
1817
* Workspaces:
1918
* GET /api/v1/admin/workspaces - List all workspaces
@@ -36,7 +35,7 @@
3635
* GET /api/v1/admin/organizations/:id - Get organization details
3736
* PATCH /api/v1/admin/organizations/:id - Update organization
3837
* GET /api/v1/admin/organizations/:id/members - List organization members
39-
* POST /api/v1/admin/organizations/:id/members - Add member to organization
38+
* POST /api/v1/admin/organizations/:id/members - Add/update member in organization
4039
* GET /api/v1/admin/organizations/:id/members/:mid - Get member details
4140
* PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role
4241
* DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member

apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313
*
1414
* Add a user to an organization with full billing logic.
1515
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
16+
* If user is already a member, updates their role if different.
1617
*
1718
* Body:
1819
* - userId: string - User ID to add
1920
* - role: string - Role ('admin' | 'member')
20-
* - skipBillingLogic?: boolean - Skip Pro cancellation (default: false)
2121
*
22-
* Response: AdminSingleResponse<AdminMember>
22+
* Response: AdminSingleResponse<AdminMember & {
23+
* action: 'created' | 'updated' | 'already_member',
24+
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
25+
* }>
2326
*/
2427

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

132-
const skipBillingLogic = body.skipBillingLogic === true
133-
134135
const [orgData] = await db
135136
.select({ id: organization.id, name: organization.name })
136137
.from(organization)
@@ -151,11 +152,71 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
151152
return notFoundResponse('User')
152153
}
153154

155+
const [existingMember] = await db
156+
.select({
157+
id: member.id,
158+
role: member.role,
159+
createdAt: member.createdAt,
160+
organizationId: member.organizationId,
161+
})
162+
.from(member)
163+
.where(eq(member.userId, body.userId))
164+
.limit(1)
165+
166+
if (existingMember) {
167+
if (existingMember.organizationId === organizationId) {
168+
if (existingMember.role !== body.role) {
169+
await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id))
170+
171+
logger.info(
172+
`Admin API: Updated user ${body.userId} role in organization ${organizationId}`,
173+
{
174+
previousRole: existingMember.role,
175+
newRole: body.role,
176+
}
177+
)
178+
179+
return singleResponse({
180+
id: existingMember.id,
181+
userId: body.userId,
182+
organizationId,
183+
role: body.role,
184+
createdAt: existingMember.createdAt.toISOString(),
185+
userName: userData.name,
186+
userEmail: userData.email,
187+
action: 'updated' as const,
188+
billingActions: {
189+
proUsageSnapshotted: false,
190+
proCancelledAtPeriodEnd: false,
191+
},
192+
})
193+
}
194+
195+
return singleResponse({
196+
id: existingMember.id,
197+
userId: body.userId,
198+
organizationId,
199+
role: existingMember.role,
200+
createdAt: existingMember.createdAt.toISOString(),
201+
userName: userData.name,
202+
userEmail: userData.email,
203+
action: 'already_member' as const,
204+
billingActions: {
205+
proUsageSnapshotted: false,
206+
proCancelledAtPeriodEnd: false,
207+
},
208+
})
209+
}
210+
211+
return badRequestResponse(
212+
`User is already a member of another organization. Users can only belong to one organization at a time.`
213+
)
214+
}
215+
154216
const result = await addUserToOrganization({
155217
userId: body.userId,
156218
organizationId,
157219
role: body.role,
158-
skipBillingLogic,
159220
})
160221

161222
if (!result.success) {
@@ -176,11 +237,11 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
176237
role: body.role,
177238
memberId: result.memberId,
178239
billingActions: result.billingActions,
179-
skipBillingLogic,
180240
})
181241

182242
return singleResponse({
183243
...data,
244+
action: 'created' as const,
184245
billingActions: {
185246
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
186247
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,

apps/sim/app/api/v1/admin/organizations/[id]/route.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
* Body:
1313
* - name?: string - Organization name
1414
* - slug?: string - Organization slug
15-
* - orgUsageLimit?: number - Usage limit (null to clear)
1615
*
1716
* Response: AdminSingleResponse<AdminOrganization>
1817
*/
@@ -112,14 +111,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
112111
updateData.slug = body.slug.trim()
113112
}
114113

115-
if (body.orgUsageLimit !== undefined) {
116-
if (body.orgUsageLimit === null) {
117-
updateData.orgUsageLimit = null
118-
} else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) {
119-
updateData.orgUsageLimit = body.orgUsageLimit.toFixed(2)
120-
} else {
121-
return badRequestResponse('orgUsageLimit must be a non-negative number or null')
122-
}
114+
if (Object.keys(updateData).length === 1) {
115+
return badRequestResponse(
116+
'No valid fields to update. Use /billing endpoint for orgUsageLimit.'
117+
)
123118
}
124119

125120
const [updated] = await db

apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77
*
88
* PATCH /api/v1/admin/organizations/[id]/seats
99
*
10-
* Update organization seat count (for admin override of enterprise seats).
10+
* Update organization seat count with Stripe sync (matches user flow).
1111
*
1212
* Body:
13-
* - seats: number - New seat count (for enterprise metadata.seats)
13+
* - seats: number - New seat count (positive integer)
1414
*
15-
* Response: AdminSingleResponse<{ success: true, seats: number }>
15+
* Response: AdminSingleResponse<{ success: true, seats: number, plan: string, stripeUpdated?: boolean }>
1616
*/
1717

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

109+
const newSeatCount = body.seats
110+
let stripeUpdated = false
111+
108112
if (subData.plan === 'enterprise') {
109113
const currentMetadata = (subData.metadata as Record<string, unknown>) || {}
110114
const newMetadata = {
111115
...currentMetadata,
112-
seats: body.seats,
116+
seats: newSeatCount,
113117
}
114118

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

120124
logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
121-
seats: body.seats,
125+
seats: newSeatCount,
122126
})
123-
} else {
127+
} else if (subData.plan === 'team') {
128+
if (subData.stripeSubscriptionId) {
129+
const stripe = requireStripeClient()
130+
131+
const stripeSubscription = await stripe.subscriptions.retrieve(subData.stripeSubscriptionId)
132+
133+
if (stripeSubscription.status !== 'active') {
134+
return badRequestResponse('Stripe subscription is not active')
135+
}
136+
137+
const subscriptionItem = stripeSubscription.items.data[0]
138+
if (!subscriptionItem) {
139+
return internalErrorResponse('No subscription item found in Stripe subscription')
140+
}
141+
142+
const currentSeats = subData.seats || 1
143+
144+
logger.info('Admin API: Updating Stripe subscription quantity', {
145+
organizationId,
146+
stripeSubscriptionId: subData.stripeSubscriptionId,
147+
subscriptionItemId: subscriptionItem.id,
148+
currentSeats,
149+
newSeatCount,
150+
})
151+
152+
await stripe.subscriptions.update(subData.stripeSubscriptionId, {
153+
items: [
154+
{
155+
id: subscriptionItem.id,
156+
quantity: newSeatCount,
157+
},
158+
],
159+
proration_behavior: 'create_prorations',
160+
})
161+
162+
stripeUpdated = true
163+
}
164+
124165
await db
125166
.update(subscription)
126-
.set({ seats: body.seats })
167+
.set({ seats: newSeatCount })
127168
.where(eq(subscription.id, subData.id))
128169

129170
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
130-
seats: body.seats,
171+
seats: newSeatCount,
172+
stripeUpdated,
173+
})
174+
} else {
175+
await db
176+
.update(subscription)
177+
.set({ seats: newSeatCount })
178+
.where(eq(subscription.id, subData.id))
179+
180+
logger.info(`Admin API: Updated seats for organization ${organizationId}`, {
181+
seats: newSeatCount,
182+
plan: subData.plan,
131183
})
132184
}
133185

134186
return singleResponse({
135187
success: true,
136-
seats: body.seats,
188+
seats: newSeatCount,
137189
plan: subData.plan,
190+
stripeUpdated,
138191
})
139192
} catch (error) {
140193
logger.error('Admin API: Failed to update organization seats', { error, organizationId })

0 commit comments

Comments
 (0)