mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(admin): updated admin routes to consolidate duplicate behavior (#2257)
* feat(admin): updated admin routes to consolidate duplicate behavior * ack PR comments
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* Body:
|
||||
* - name?: string - Organization name
|
||||
* - slug?: string - Organization slug
|
||||
* - orgUsageLimit?: number - Usage limit (null to clear)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganization>
|
||||
*/
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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 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: newSeatCount })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
stripeUpdated,
|
||||
})
|
||||
} else {
|
||||
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,
|
||||
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 })
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* POST /api/v1/admin/users/[id]/billing/move-to-org
|
||||
*
|
||||
* Move a user to an organization with full billing logic.
|
||||
* Enforces single-org constraint, handles Pro snapshot/cancellation.
|
||||
*
|
||||
* Body:
|
||||
* - organizationId: string - Target organization ID
|
||||
* - role?: string - Role in organization ('admin' | 'member'), defaults to 'member'
|
||||
* - skipBillingLogic?: boolean - Skip Pro handling (default: false)
|
||||
*
|
||||
* Response: AdminSingleResponse<{
|
||||
* success: true,
|
||||
* memberId: string,
|
||||
* organizationId: string,
|
||||
* role: string,
|
||||
* action: 'created' | 'updated' | 'already_member',
|
||||
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
|
||||
* }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, user } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
|
||||
const logger = createLogger('AdminUserMoveToOrgAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: userId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.organizationId || typeof body.organizationId !== 'string') {
|
||||
return badRequestResponse('organizationId is required')
|
||||
}
|
||||
|
||||
const role = body.role || 'member'
|
||||
if (!['admin', 'member'].includes(role)) {
|
||||
return badRequestResponse('role must be "admin" or "member"')
|
||||
}
|
||||
|
||||
const skipBillingLogic = body.skipBillingLogic === true
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id, name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, body.organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({ id: member.id, organizationId: member.organizationId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const existingInThisOrg = existingMemberships.find(
|
||||
(m) => m.organizationId === body.organizationId
|
||||
)
|
||||
if (existingInThisOrg) {
|
||||
if (existingInThisOrg.role !== role) {
|
||||
await db.update(member).set({ role }).where(eq(member.id, existingInThisOrg.id))
|
||||
|
||||
logger.info(
|
||||
`Admin API: Updated user ${userId} role in organization ${body.organizationId}`,
|
||||
{
|
||||
previousRole: existingInThisOrg.role,
|
||||
newRole: role,
|
||||
}
|
||||
)
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
memberId: existingInThisOrg.id,
|
||||
organizationId: body.organizationId,
|
||||
organizationName: orgData.name,
|
||||
role,
|
||||
action: 'updated',
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
memberId: existingInThisOrg.id,
|
||||
organizationId: body.organizationId,
|
||||
organizationName: orgData.name,
|
||||
role: existingInThisOrg.role,
|
||||
action: 'already_member',
|
||||
billingActions: {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await addUserToOrganization({
|
||||
userId,
|
||||
organizationId: body.organizationId,
|
||||
role,
|
||||
skipBillingLogic,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return badRequestResponse(result.error || 'Failed to move user to organization')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Moved user ${userId} to organization ${body.organizationId}`, {
|
||||
role,
|
||||
memberId: result.memberId,
|
||||
billingActions: result.billingActions,
|
||||
skipBillingLogic,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
memberId: result.memberId,
|
||||
organizationId: body.organizationId,
|
||||
organizationName: orgData.name,
|
||||
role,
|
||||
action: 'created',
|
||||
billingActions: {
|
||||
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
|
||||
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to move user to organization', { error, userId })
|
||||
return internalErrorResponse('Failed to move user to organization')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user