diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index 06ee1dfce6..d517c8d8ca 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -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 diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 7f4328469d..6bd6115eb2 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -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 + * Response: AdminSingleResponse */ import { db } from '@sim/db' @@ -129,8 +132,6 @@ export const POST = withAdminAuthParams(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(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(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, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 311a136f0b..ef5c9e9663 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -12,7 +12,6 @@ * Body: * - name?: string - Organization name * - slug?: string - Organization slug - * - orgUsageLimit?: number - Usage limit (null to clear) * * Response: AdminSingleResponse */ @@ -112,14 +111,10 @@ export const PATCH = withAdminAuthParams(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 diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts index 73a4405b54..388570fc99 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts @@ -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(async (request, context) = return notFoundResponse('Subscription') } + const newSeatCount = body.seats + let stripeUpdated = false + if (subData.plan === 'enterprise') { const currentMetadata = (subData.metadata as Record) || {} const newMetadata = { ...currentMetadata, - seats: body.seats, + seats: newSeatCount, } await db @@ -118,23 +122,72 @@ export const PATCH = withAdminAuthParams(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 }) diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/move-to-org/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/move-to-org/route.ts deleted file mode 100644 index cdb774f897..0000000000 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/move-to-org/route.ts +++ /dev/null @@ -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(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') - } -})