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:
Waleed
2025-12-08 17:29:55 -08:00
committed by GitHub
parent 87084edbe6
commit e067b584ee
5 changed files with 134 additions and 186 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 })

View File

@@ -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')
}
})