mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(admin): added more billing, subscriptions, and organization admin API routes (#2225)
* feat(admin): added more billing, subscriptions, and organization admin API routes * cleanup * ack PR comments * cleanup * ack PR comment
This commit is contained in:
@@ -1,17 +1,11 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
subscription as subscriptionTable,
|
||||
user,
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { member, user, userStats } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationMemberAPI')
|
||||
@@ -41,7 +35,6 @@ export async function GET(
|
||||
const url = new URL(request.url)
|
||||
const includeUsage = url.searchParams.get('include') === 'usage'
|
||||
|
||||
// Verify user has access to this organization
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -58,7 +51,6 @@ export async function GET(
|
||||
const userRole = userMember[0].role
|
||||
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
// Get target member details
|
||||
const memberQuery = db
|
||||
.select({
|
||||
id: member.id,
|
||||
@@ -80,7 +72,6 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user can view this member's details
|
||||
const canViewDetails = hasAdminAccess || session.user.id === memberId
|
||||
|
||||
if (!canViewDetails) {
|
||||
@@ -89,7 +80,6 @@ export async function GET(
|
||||
|
||||
let memberData = memberEntry[0]
|
||||
|
||||
// Include usage data if requested and user has permission
|
||||
if (includeUsage && hasAdminAccess) {
|
||||
const usageData = await db
|
||||
.select({
|
||||
@@ -181,7 +171,6 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if target member exists
|
||||
const targetMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -192,12 +181,10 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent changing owner role
|
||||
if (targetMember[0].role === 'owner') {
|
||||
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Prevent non-owners from promoting to admin
|
||||
if (role === 'admin' && userMember[0].role !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only owners can promote members to admin' },
|
||||
@@ -205,12 +192,10 @@ export async function PUT(
|
||||
)
|
||||
}
|
||||
|
||||
// Prevent admins from changing other admins' roles - only owners can modify admin roles
|
||||
if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') {
|
||||
return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Update member role
|
||||
const updatedMember = await db
|
||||
.update(member)
|
||||
.set({ role })
|
||||
@@ -264,9 +249,8 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, memberId } = await params
|
||||
const { id: organizationId, memberId: targetUserId } = await params
|
||||
|
||||
// Verify user has admin access
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -281,209 +265,54 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const canRemoveMembers =
|
||||
['owner', 'admin'].includes(userMember[0].role) || session.user.id === memberId
|
||||
['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId
|
||||
|
||||
if (!canRemoveMembers) {
|
||||
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if target member exists
|
||||
const targetMember = await db
|
||||
.select()
|
||||
.select({ id: member.id, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prevent removing the owner
|
||||
if (targetMember[0].role === 'owner') {
|
||||
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
|
||||
}
|
||||
const result = await removeUserFromOrganization({
|
||||
userId: targetUserId,
|
||||
organizationId,
|
||||
memberId: targetMember[0].id,
|
||||
})
|
||||
|
||||
// Capture departed member's usage and reset their cost to prevent double billing
|
||||
try {
|
||||
const departingUserStats = await db
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, memberId))
|
||||
.limit(1)
|
||||
|
||||
if (departingUserStats.length > 0 && departingUserStats[0].currentPeriodCost) {
|
||||
const usage = Number.parseFloat(departingUserStats[0].currentPeriodCost)
|
||||
if (usage > 0) {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({
|
||||
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ currentPeriodCost: '0' })
|
||||
.where(eq(userStats.userId, memberId))
|
||||
|
||||
logger.info('Captured departed member usage and reset user cost', {
|
||||
organizationId,
|
||||
memberId,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
if (!result.success) {
|
||||
if (result.error === 'Cannot remove organization owner') {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
} catch (usageCaptureError) {
|
||||
logger.error('Failed to capture departed member usage', {
|
||||
organizationId,
|
||||
memberId,
|
||||
error: usageCaptureError,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove member
|
||||
const removedMember = await db
|
||||
.delete(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.returning()
|
||||
|
||||
if (removedMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
if (result.error === 'Member not found') {
|
||||
return NextResponse.json({ error: result.error }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: result.error }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Organization member removed', {
|
||||
organizationId,
|
||||
removedMemberId: memberId,
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
wasSelfRemoval: session.user.id === memberId,
|
||||
wasSelfRemoval: session.user.id === targetUserId,
|
||||
billingActions: result.billingActions,
|
||||
})
|
||||
|
||||
// If the removed user left their last paid team and has a personal Pro set to cancel_at_period_end, restore it
|
||||
try {
|
||||
const remainingPaidTeams = await db
|
||||
.select({ orgId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, memberId))
|
||||
|
||||
let hasAnyPaidTeam = false
|
||||
if (remainingPaidTeams.length > 0) {
|
||||
const orgIds = remainingPaidTeams.map((m) => m.orgId)
|
||||
const orgPaidSubs = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(and(eq(subscriptionTable.status, 'active'), eq(subscriptionTable.plan, 'team')))
|
||||
|
||||
hasAnyPaidTeam = orgPaidSubs.some((s) => orgIds.includes(s.referenceId))
|
||||
}
|
||||
|
||||
if (!hasAnyPaidTeam) {
|
||||
const personalProRows = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, memberId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const personalPro = personalProRows[0]
|
||||
if (
|
||||
personalPro &&
|
||||
personalPro.cancelAtPeriodEnd === true &&
|
||||
personalPro.stripeSubscriptionId
|
||||
) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId: memberId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
logger.info('Restored personal Pro after leaving last paid team', {
|
||||
userId: memberId,
|
||||
personalSubscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId: memberId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
// Also restore the snapshotted Pro usage back to currentPeriodCost
|
||||
try {
|
||||
const userStatsRows = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, memberId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRows.length > 0) {
|
||||
const currentUsage = userStatsRows[0].currentPeriodCost || '0'
|
||||
const snapshotUsage = userStatsRows[0].proPeriodCostSnapshot || '0'
|
||||
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0', // Clear the snapshot
|
||||
})
|
||||
.where(eq(userStats.userId, memberId))
|
||||
|
||||
logger.info('Restored Pro usage after leaving team', {
|
||||
userId: memberId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage: snapshotUsage,
|
||||
restoredUsage: restoredUsage,
|
||||
})
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage after leaving team', {
|
||||
userId: memberId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (postRemoveError) {
|
||||
logger.error('Post-removal personal Pro restore check failed', {
|
||||
organizationId,
|
||||
memberId,
|
||||
error: postRemoveError,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
session.user.id === memberId
|
||||
session.user.id === targetUserId
|
||||
? 'You have left the organization'
|
||||
: 'Member removed successfully',
|
||||
data: {
|
||||
removedMemberId: memberId,
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
removedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
@@ -7,20 +7,48 @@
|
||||
* Set ADMIN_API_KEY environment variable and use x-admin-key header.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/v1/admin/users - List all users
|
||||
* GET /api/v1/admin/users/:id - Get user details
|
||||
* GET /api/v1/admin/workspaces - List all workspaces
|
||||
* GET /api/v1/admin/workspaces/:id - Get workspace details
|
||||
* GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows
|
||||
* DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows
|
||||
* GET /api/v1/admin/workspaces/:id/folders - List workspace folders
|
||||
* GET /api/v1/admin/workspaces/:id/export - Export workspace (ZIP/JSON)
|
||||
* POST /api/v1/admin/workspaces/:id/import - Import into workspace
|
||||
* GET /api/v1/admin/workflows - List all workflows
|
||||
* GET /api/v1/admin/workflows/:id - Get workflow details
|
||||
* DELETE /api/v1/admin/workflows/:id - Delete workflow
|
||||
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
|
||||
* POST /api/v1/admin/workflows/import - Import single workflow
|
||||
*
|
||||
* Users:
|
||||
* GET /api/v1/admin/users - List all users
|
||||
* 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
|
||||
* GET /api/v1/admin/workspaces/:id - Get workspace details
|
||||
* GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows
|
||||
* DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows
|
||||
* GET /api/v1/admin/workspaces/:id/folders - List workspace folders
|
||||
* GET /api/v1/admin/workspaces/:id/export - Export workspace (ZIP/JSON)
|
||||
* POST /api/v1/admin/workspaces/:id/import - Import into workspace
|
||||
*
|
||||
* Workflows:
|
||||
* GET /api/v1/admin/workflows - List all workflows
|
||||
* GET /api/v1/admin/workflows/:id - Get workflow details
|
||||
* DELETE /api/v1/admin/workflows/:id - Delete workflow
|
||||
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
|
||||
* POST /api/v1/admin/workflows/import - Import single workflow
|
||||
*
|
||||
* Organizations:
|
||||
* GET /api/v1/admin/organizations - List all organizations
|
||||
* 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
|
||||
* 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
|
||||
* GET /api/v1/admin/organizations/:id/billing - Get org billing summary
|
||||
* PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit
|
||||
* GET /api/v1/admin/organizations/:id/seats - Get seat analytics
|
||||
* PATCH /api/v1/admin/organizations/:id/seats - Update seat count
|
||||
*
|
||||
* Subscriptions:
|
||||
* GET /api/v1/admin/subscriptions - List all subscriptions
|
||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||
* PATCH /api/v1/admin/subscriptions/:id - Update subscription
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
@@ -42,13 +70,26 @@ export type {
|
||||
AdminErrorResponse,
|
||||
AdminFolder,
|
||||
AdminListResponse,
|
||||
AdminMember,
|
||||
AdminMemberDetail,
|
||||
AdminOrganization,
|
||||
AdminOrganizationBillingSummary,
|
||||
AdminOrganizationDetail,
|
||||
AdminSeatAnalytics,
|
||||
AdminSingleResponse,
|
||||
AdminSubscription,
|
||||
AdminUser,
|
||||
AdminUserBilling,
|
||||
AdminUserBillingWithSubscription,
|
||||
AdminWorkflow,
|
||||
AdminWorkflowDetail,
|
||||
AdminWorkspace,
|
||||
AdminWorkspaceDetail,
|
||||
DbMember,
|
||||
DbOrganization,
|
||||
DbSubscription,
|
||||
DbUser,
|
||||
DbUserStats,
|
||||
DbWorkflow,
|
||||
DbWorkflowFolder,
|
||||
DbWorkspace,
|
||||
@@ -73,6 +114,8 @@ export {
|
||||
parsePaginationParams,
|
||||
parseWorkflowVariables,
|
||||
toAdminFolder,
|
||||
toAdminOrganization,
|
||||
toAdminSubscription,
|
||||
toAdminUser,
|
||||
toAdminWorkflow,
|
||||
toAdminWorkspace,
|
||||
|
||||
135
apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts
Normal file
135
apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/billing
|
||||
*
|
||||
* Get organization billing summary including usage, seats, and member data.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganizationBillingSummary>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/billing
|
||||
*
|
||||
* Update organization billing settings.
|
||||
*
|
||||
* Body:
|
||||
* - orgUsageLimit?: number - New usage limit (null to clear)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, orgUsageLimit: string | null }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
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'
|
||||
import type { AdminOrganizationBillingSummary } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationBillingAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const billingData = await getOrganizationBillingData(organizationId)
|
||||
|
||||
if (!billingData) {
|
||||
return notFoundResponse('Organization or subscription')
|
||||
}
|
||||
|
||||
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
|
||||
const membersNearLimit = billingData.members.filter(
|
||||
(m) => !m.isOverLimit && m.percentUsed >= 80
|
||||
).length
|
||||
const usagePercentage =
|
||||
billingData.totalUsageLimit > 0
|
||||
? Math.round((billingData.totalCurrentUsage / billingData.totalUsageLimit) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const data: AdminOrganizationBillingSummary = {
|
||||
organizationId: billingData.organizationId,
|
||||
organizationName: billingData.organizationName,
|
||||
subscriptionPlan: billingData.subscriptionPlan,
|
||||
subscriptionStatus: billingData.subscriptionStatus,
|
||||
totalSeats: billingData.totalSeats,
|
||||
usedSeats: billingData.usedSeats,
|
||||
availableSeats: billingData.totalSeats - billingData.usedSeats,
|
||||
totalCurrentUsage: billingData.totalCurrentUsage,
|
||||
totalUsageLimit: billingData.totalUsageLimit,
|
||||
minimumBillingAmount: billingData.minimumBillingAmount,
|
||||
averageUsagePerMember: billingData.averageUsagePerMember,
|
||||
usagePercentage,
|
||||
billingPeriodStart: billingData.billingPeriodStart?.toISOString() ?? null,
|
||||
billingPeriodEnd: billingData.billingPeriodEnd?.toISOString() ?? null,
|
||||
membersOverLimit,
|
||||
membersNearLimit,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved billing summary for organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get organization billing', { error, organizationId })
|
||||
return internalErrorResponse('Failed to get organization billing')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const [orgData] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
if (body.orgUsageLimit !== undefined) {
|
||||
let newLimit: string | null = null
|
||||
|
||||
if (body.orgUsageLimit === null) {
|
||||
newLimit = null
|
||||
} else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) {
|
||||
newLimit = body.orgUsageLimit.toFixed(2)
|
||||
} else {
|
||||
return badRequestResponse('orgUsageLimit must be a non-negative number or null')
|
||||
}
|
||||
|
||||
await db
|
||||
.update(organization)
|
||||
.set({
|
||||
orgUsageLimit: newLimit,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
logger.info(`Admin API: Updated usage limit for organization ${organizationId}`, {
|
||||
newLimit,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
orgUsageLimit: newLimit,
|
||||
})
|
||||
}
|
||||
|
||||
return badRequestResponse('No valid fields to update')
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update organization billing', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization billing')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/members/[memberId]
|
||||
*
|
||||
* Get member details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminMemberDetail>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/members/[memberId]
|
||||
*
|
||||
* Update member role.
|
||||
*
|
||||
* Body:
|
||||
* - role: string - New role ('admin' | 'member')
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminMember>
|
||||
*
|
||||
* DELETE /api/v1/admin/organizations/[id]/members/[memberId]
|
||||
*
|
||||
* Remove member from organization with full billing logic.
|
||||
* Handles departed usage capture and Pro restoration like the regular flow.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - skipBillingLogic: boolean - Skip billing logic (default: false)
|
||||
*
|
||||
* Response: { success: true, memberId: string, billingActions: {...} }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { removeUserFromOrganization } 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'
|
||||
import type { AdminMember, AdminMemberDetail } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationMemberDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
memberId: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: organizationId, memberId } = await context.params
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [memberData] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
lastActive: userStats.lastActive,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberData) {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
const data: AdminMemberDetail = {
|
||||
id: memberData.id,
|
||||
userId: memberData.userId,
|
||||
organizationId: memberData.organizationId,
|
||||
role: memberData.role,
|
||||
createdAt: memberData.createdAt.toISOString(),
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
currentPeriodCost: memberData.currentPeriodCost ?? '0',
|
||||
currentUsageLimit: memberData.currentUsageLimit,
|
||||
lastActive: memberData.lastActive?.toISOString() ?? null,
|
||||
billingBlocked: memberData.billingBlocked ?? false,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved member ${memberId} from organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get member', { error, organizationId, memberId })
|
||||
return internalErrorResponse('Failed to get member')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId, memberId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.role || !['admin', 'member'].includes(body.role)) {
|
||||
return badRequestResponse('role must be "admin" or "member"')
|
||||
}
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
if (existingMember.role === 'owner') {
|
||||
return badRequestResponse('Cannot change owner role')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(member)
|
||||
.set({ role: body.role })
|
||||
.where(eq(member.id, memberId))
|
||||
.returning()
|
||||
|
||||
const [userData] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, updated.userId))
|
||||
.limit(1)
|
||||
|
||||
const data: AdminMember = {
|
||||
id: updated.id,
|
||||
userId: updated.userId,
|
||||
organizationId: updated.organizationId,
|
||||
role: updated.role,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
userName: userData?.name ?? '',
|
||||
userEmail: userData?.email ?? '',
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated member ${memberId} role to ${body.role}`, {
|
||||
organizationId,
|
||||
previousRole: existingMember.role,
|
||||
})
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update member', { error, organizationId, memberId })
|
||||
return internalErrorResponse('Failed to update member')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId, memberId } = await context.params
|
||||
const url = new URL(request.url)
|
||||
const skipBillingLogic = url.searchParams.get('skipBillingLogic') === 'true'
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
|
||||
const userId = existingMember.userId
|
||||
|
||||
const result = await removeUserFromOrganization({
|
||||
userId,
|
||||
organizationId,
|
||||
memberId,
|
||||
skipBillingLogic,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error === 'Cannot remove organization owner') {
|
||||
return badRequestResponse(result.error)
|
||||
}
|
||||
if (result.error === 'Member not found') {
|
||||
return notFoundResponse('Member')
|
||||
}
|
||||
return internalErrorResponse(result.error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Removed member ${memberId} from organization ${organizationId}`, {
|
||||
userId,
|
||||
billingActions: result.billingActions,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
memberId,
|
||||
userId,
|
||||
billingActions: {
|
||||
usageCaptured: result.billingActions.usageCaptured,
|
||||
proRestored: result.billingActions.proRestored,
|
||||
usageRestored: result.billingActions.usageRestored,
|
||||
skipBillingLogic,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to remove member', { error, organizationId, memberId })
|
||||
return internalErrorResponse('Failed to remove member')
|
||||
}
|
||||
})
|
||||
193
apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts
Normal file
193
apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/members
|
||||
*
|
||||
* List all members of an organization with their billing info.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminMemberDetail>
|
||||
*
|
||||
* POST /api/v1/admin/organizations/[id]/members
|
||||
*
|
||||
* Add a user to an organization with full billing logic.
|
||||
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
|
||||
*
|
||||
* Body:
|
||||
* - userId: string - User ID to add
|
||||
* - role: string - Role ('admin' | 'member')
|
||||
* - skipBillingLogic?: boolean - Skip Pro cancellation (default: false)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminMember>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { count, 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,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminMember,
|
||||
type AdminMemberDetail,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationMembersAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [countResult, membersData] = await Promise.all([
|
||||
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
|
||||
db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
lastActive: userStats.lastActive,
|
||||
billingBlocked: userStats.billingBlocked,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
.orderBy(member.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].count
|
||||
const data: AdminMemberDetail[] = membersData.map((m) => ({
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
organizationId: m.organizationId,
|
||||
role: m.role,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
userName: m.userName,
|
||||
userEmail: m.userEmail,
|
||||
currentPeriodCost: m.currentPeriodCost ?? '0',
|
||||
currentUsageLimit: m.currentUsageLimit,
|
||||
lastActive: m.lastActive?.toISOString() ?? null,
|
||||
billingBlocked: m.billingBlocked ?? false,
|
||||
}))
|
||||
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} members for organization ${organizationId}`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list organization members', { error, organizationId })
|
||||
return internalErrorResponse('Failed to list organization members')
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.userId || typeof body.userId !== 'string') {
|
||||
return badRequestResponse('userId is required')
|
||||
}
|
||||
|
||||
if (!body.role || !['admin', 'member'].includes(body.role)) {
|
||||
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)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, body.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const result = await addUserToOrganization({
|
||||
userId: body.userId,
|
||||
organizationId,
|
||||
role: body.role,
|
||||
skipBillingLogic,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return badRequestResponse(result.error || 'Failed to add member')
|
||||
}
|
||||
|
||||
const data: AdminMember = {
|
||||
id: result.memberId!,
|
||||
userId: body.userId,
|
||||
organizationId,
|
||||
role: body.role,
|
||||
createdAt: new Date().toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Added user ${body.userId} to organization ${organizationId}`, {
|
||||
role: body.role,
|
||||
memberId: result.memberId,
|
||||
billingActions: result.billingActions,
|
||||
skipBillingLogic,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...data,
|
||||
billingActions: {
|
||||
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
|
||||
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to add organization member', { error, organizationId })
|
||||
return internalErrorResponse('Failed to add organization member')
|
||||
}
|
||||
})
|
||||
140
apps/sim/app/api/v1/admin/organizations/[id]/route.ts
Normal file
140
apps/sim/app/api/v1/admin/organizations/[id]/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]
|
||||
*
|
||||
* Get organization details including member count and subscription.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganizationDetail>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]
|
||||
*
|
||||
* Update organization details.
|
||||
*
|
||||
* Body:
|
||||
* - name?: string - Organization name
|
||||
* - slug?: string - Organization slug
|
||||
* - orgUsageLimit?: number - Usage limit (null to clear)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminOrganization>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
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'
|
||||
import {
|
||||
type AdminOrganizationDetail,
|
||||
toAdminOrganization,
|
||||
toAdminSubscription,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const [orgData] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [memberCountResult, subscriptionData] = await Promise.all([
|
||||
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
|
||||
db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
const data: AdminOrganizationDetail = {
|
||||
...toAdminOrganization(orgData),
|
||||
memberCount: memberCountResult[0].count,
|
||||
subscription: subscriptionData[0] ? toAdminSubscription(subscriptionData[0]) : null,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get organization', { error, organizationId })
|
||||
return internalErrorResponse('Failed to get organization')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
||||
return badRequestResponse('name must be a non-empty string')
|
||||
}
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
|
||||
if (body.slug !== undefined) {
|
||||
if (typeof body.slug !== 'string' || body.slug.trim().length === 0) {
|
||||
return badRequestResponse('slug must be a non-empty string')
|
||||
}
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(organization)
|
||||
.set(updateData)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Admin API: Updated organization ${organizationId}`, {
|
||||
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
|
||||
})
|
||||
|
||||
return singleResponse(toAdminOrganization(updated))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update organization', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization')
|
||||
}
|
||||
})
|
||||
143
apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts
Normal file
143
apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations/[id]/seats
|
||||
*
|
||||
* Get organization seat analytics including member activity.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSeatAnalytics>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/seats
|
||||
*
|
||||
* Update organization seat count (for admin override of enterprise seats).
|
||||
*
|
||||
* Body:
|
||||
* - seats: number - New seat count (for enterprise metadata.seats)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, seats: number }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization, subscription } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
|
||||
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'
|
||||
import type { AdminSeatAnalytics } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationSeatsAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const analytics = await getOrganizationSeatAnalytics(organizationId)
|
||||
|
||||
if (!analytics) {
|
||||
return notFoundResponse('Organization or subscription')
|
||||
}
|
||||
|
||||
const data: AdminSeatAnalytics = {
|
||||
organizationId: analytics.organizationId,
|
||||
organizationName: analytics.organizationName,
|
||||
currentSeats: analytics.currentSeats,
|
||||
maxSeats: analytics.maxSeats,
|
||||
availableSeats: analytics.availableSeats,
|
||||
subscriptionPlan: analytics.subscriptionPlan,
|
||||
canAddSeats: analytics.canAddSeats,
|
||||
utilizationRate: analytics.utilizationRate,
|
||||
activeMembers: analytics.activeMembers,
|
||||
inactiveMembers: analytics.inactiveMembers,
|
||||
memberActivity: analytics.memberActivity.map((m) => ({
|
||||
userId: m.userId,
|
||||
userName: m.userName,
|
||||
userEmail: m.userEmail,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
lastActive: m.lastActive?.toISOString() ?? null,
|
||||
})),
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get organization seats', { error, organizationId })
|
||||
return internalErrorResponse('Failed to get organization seats')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
|
||||
return badRequestResponse('seats must be a positive integer')
|
||||
}
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [subData] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
if (!subData) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
if (subData.plan === 'enterprise') {
|
||||
const currentMetadata = (subData.metadata as Record<string, unknown>) || {}
|
||||
const newMetadata = {
|
||||
...currentMetadata,
|
||||
seats: body.seats,
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ metadata: newMetadata })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
|
||||
seats: body.seats,
|
||||
})
|
||||
} else {
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ seats: body.seats })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
|
||||
seats: body.seats,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
seats: body.seats,
|
||||
plan: subData.plan,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update organization seats', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization seats')
|
||||
}
|
||||
})
|
||||
49
apps/sim/app/api/v1/admin/organizations/route.ts
Normal file
49
apps/sim/app/api/v1/admin/organizations/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* GET /api/v1/admin/organizations
|
||||
*
|
||||
* List all organizations with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminOrganization>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization } from '@sim/db/schema'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminOrganization,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
toAdminOrganization,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminOrganizationsAPI')
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
|
||||
try {
|
||||
const [countResult, organizations] = await Promise.all([
|
||||
db.select({ total: count() }).from(organization),
|
||||
db.select().from(organization).orderBy(organization.name).limit(limit).offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminOrganization[] = organizations.map(toAdminOrganization)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} organizations (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list organizations', { error })
|
||||
return internalErrorResponse('Failed to list organizations')
|
||||
}
|
||||
})
|
||||
236
apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts
Normal file
236
apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* GET /api/v1/admin/subscriptions/[id]
|
||||
*
|
||||
* Get subscription details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSubscription>
|
||||
*
|
||||
* PATCH /api/v1/admin/subscriptions/[id]
|
||||
*
|
||||
* Update subscription details with optional side effects.
|
||||
*
|
||||
* Body:
|
||||
* - plan?: string - New plan (free, pro, team, enterprise)
|
||||
* - status?: string - New status (active, canceled, etc.)
|
||||
* - seats?: number - Seat count (for team plans)
|
||||
* - metadata?: object - Subscription metadata (for enterprise)
|
||||
* - periodStart?: string - Period start (ISO date)
|
||||
* - periodEnd?: string - Period end (ISO date)
|
||||
* - cancelAtPeriodEnd?: boolean - Cancel at period end flag
|
||||
* - syncLimits?: boolean - Sync usage limits for affected users (default: false)
|
||||
* - reason?: string - Reason for the change (for audit logging)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSubscription & { sideEffects }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
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'
|
||||
import { toAdminSubscription } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminSubscriptionDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
const VALID_PLANS = ['free', 'pro', 'team', 'enterprise']
|
||||
const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete']
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: subscriptionId } = await context.params
|
||||
|
||||
try {
|
||||
const [subData] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (!subData) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved subscription ${subscriptionId}`)
|
||||
|
||||
return singleResponse(toAdminSubscription(subData))
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to get subscription')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: subscriptionId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const syncLimits = body.syncLimits === true
|
||||
const reason = body.reason || 'Admin update (no reason provided)'
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
const warnings: string[] = []
|
||||
|
||||
if (body.plan !== undefined) {
|
||||
if (!VALID_PLANS.includes(body.plan)) {
|
||||
return badRequestResponse(`plan must be one of: ${VALID_PLANS.join(', ')}`)
|
||||
}
|
||||
if (body.plan !== existing.plan) {
|
||||
warnings.push(
|
||||
`Plan change from ${existing.plan} to ${body.plan}. This does NOT update Stripe - manual sync required.`
|
||||
)
|
||||
}
|
||||
updateData.plan = body.plan
|
||||
}
|
||||
|
||||
if (body.status !== undefined) {
|
||||
if (!VALID_STATUSES.includes(body.status)) {
|
||||
return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`)
|
||||
}
|
||||
if (body.status !== existing.status) {
|
||||
warnings.push(
|
||||
`Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.`
|
||||
)
|
||||
}
|
||||
updateData.status = body.status
|
||||
}
|
||||
|
||||
if (body.seats !== undefined) {
|
||||
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
|
||||
return badRequestResponse('seats must be a positive integer')
|
||||
}
|
||||
updateData.seats = body.seats
|
||||
}
|
||||
|
||||
if (body.metadata !== undefined) {
|
||||
if (typeof body.metadata !== 'object' || body.metadata === null) {
|
||||
return badRequestResponse('metadata must be an object')
|
||||
}
|
||||
updateData.metadata = {
|
||||
...((existing.metadata as Record<string, unknown>) || {}),
|
||||
...body.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
if (body.periodStart !== undefined) {
|
||||
const date = new Date(body.periodStart)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return badRequestResponse('periodStart must be a valid ISO date')
|
||||
}
|
||||
updateData.periodStart = date
|
||||
}
|
||||
|
||||
if (body.periodEnd !== undefined) {
|
||||
const date = new Date(body.periodEnd)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return badRequestResponse('periodEnd must be a valid ISO date')
|
||||
}
|
||||
updateData.periodEnd = date
|
||||
}
|
||||
|
||||
if (body.cancelAtPeriodEnd !== undefined) {
|
||||
if (typeof body.cancelAtPeriodEnd !== 'boolean') {
|
||||
return badRequestResponse('cancelAtPeriodEnd must be a boolean')
|
||||
}
|
||||
updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return badRequestResponse('No valid fields to update')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(subscription)
|
||||
.set(updateData)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.returning()
|
||||
|
||||
const sideEffects: {
|
||||
limitsSynced: boolean
|
||||
usersAffected: string[]
|
||||
errors: string[]
|
||||
} = {
|
||||
limitsSynced: false,
|
||||
usersAffected: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
if (syncLimits) {
|
||||
try {
|
||||
const referenceId = updated.referenceId
|
||||
|
||||
if (['free', 'pro'].includes(updated.plan)) {
|
||||
await syncUsageLimitsFromSubscription(referenceId)
|
||||
sideEffects.usersAffected.push(referenceId)
|
||||
sideEffects.limitsSynced = true
|
||||
} else if (['team', 'enterprise'].includes(updated.plan)) {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, referenceId))
|
||||
|
||||
for (const m of members) {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
sideEffects.usersAffected.push(m.userId)
|
||||
} catch (memberError) {
|
||||
sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`)
|
||||
logger.error('Admin API: Failed to sync limits for member', {
|
||||
userId: m.userId,
|
||||
error: memberError,
|
||||
})
|
||||
}
|
||||
}
|
||||
sideEffects.limitsSynced = members.length > 0
|
||||
}
|
||||
|
||||
logger.info('Admin API: Synced usage limits after subscription update', {
|
||||
subscriptionId,
|
||||
usersAffected: sideEffects.usersAffected.length,
|
||||
})
|
||||
} catch (syncError) {
|
||||
sideEffects.errors.push('Failed to sync usage limits')
|
||||
logger.error('Admin API: Failed to sync usage limits', {
|
||||
subscriptionId,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated subscription ${subscriptionId}`, {
|
||||
fields: Object.keys(updateData),
|
||||
previousPlan: existing.plan,
|
||||
previousStatus: existing.status,
|
||||
syncLimits,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...toAdminSubscription(updated),
|
||||
sideEffects,
|
||||
warnings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to update subscription')
|
||||
}
|
||||
})
|
||||
69
apps/sim/app/api/v1/admin/subscriptions/route.ts
Normal file
69
apps/sim/app/api/v1/admin/subscriptions/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* GET /api/v1/admin/subscriptions
|
||||
*
|
||||
* List all subscriptions with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
* - plan: string (optional) - Filter by plan (free, pro, team, enterprise)
|
||||
* - status: string (optional) - Filter by status (active, canceled, etc.)
|
||||
*
|
||||
* Response: AdminListResponse<AdminSubscription>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { and, count, eq, type SQL } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminSubscription,
|
||||
createPaginationMeta,
|
||||
parsePaginationParams,
|
||||
toAdminSubscription,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminSubscriptionsAPI')
|
||||
|
||||
export const GET = withAdminAuth(async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const { limit, offset } = parsePaginationParams(url)
|
||||
const planFilter = url.searchParams.get('plan')
|
||||
const statusFilter = url.searchParams.get('status')
|
||||
|
||||
try {
|
||||
const conditions: SQL<unknown>[] = []
|
||||
if (planFilter) {
|
||||
conditions.push(eq(subscription.plan, planFilter))
|
||||
}
|
||||
if (statusFilter) {
|
||||
conditions.push(eq(subscription.status, statusFilter))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [countResult, subscriptions] = await Promise.all([
|
||||
db.select({ total: count() }).from(subscription).where(whereClause),
|
||||
db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(whereClause)
|
||||
.orderBy(subscription.plan)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminSubscription[] = subscriptions.map(toAdminSubscription)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} subscriptions (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list subscriptions', { error })
|
||||
return internalErrorResponse('Failed to list subscriptions')
|
||||
}
|
||||
})
|
||||
@@ -5,7 +5,16 @@
|
||||
* All responses follow a consistent structure for predictability.
|
||||
*/
|
||||
|
||||
import type { user, workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import type {
|
||||
member,
|
||||
organization,
|
||||
subscription,
|
||||
user,
|
||||
userStats,
|
||||
workflow,
|
||||
workflowFolder,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
@@ -18,6 +27,10 @@ export type DbUser = InferSelectModel<typeof user>
|
||||
export type DbWorkspace = InferSelectModel<typeof workspace>
|
||||
export type DbWorkflow = InferSelectModel<typeof workflow>
|
||||
export type DbWorkflowFolder = InferSelectModel<typeof workflowFolder>
|
||||
export type DbOrganization = InferSelectModel<typeof organization>
|
||||
export type DbSubscription = InferSelectModel<typeof subscription>
|
||||
export type DbMember = InferSelectModel<typeof member>
|
||||
export type DbUserStats = InferSelectModel<typeof userStats>
|
||||
|
||||
// =============================================================================
|
||||
// Pagination
|
||||
@@ -400,3 +413,189 @@ function getNestedString(obj: Record<string, unknown>, path: string): string | u
|
||||
|
||||
return typeof current === 'string' ? current : undefined
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminOrganization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo: string | null
|
||||
orgUsageLimit: string | null
|
||||
storageUsedBytes: number
|
||||
departedMemberUsage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminOrganizationDetail extends AdminOrganization {
|
||||
memberCount: number
|
||||
subscription: AdminSubscription | null
|
||||
}
|
||||
|
||||
export function toAdminOrganization(dbOrg: DbOrganization): AdminOrganization {
|
||||
return {
|
||||
id: dbOrg.id,
|
||||
name: dbOrg.name,
|
||||
slug: dbOrg.slug,
|
||||
logo: dbOrg.logo,
|
||||
orgUsageLimit: dbOrg.orgUsageLimit,
|
||||
storageUsedBytes: dbOrg.storageUsedBytes,
|
||||
departedMemberUsage: dbOrg.departedMemberUsage,
|
||||
createdAt: dbOrg.createdAt.toISOString(),
|
||||
updatedAt: dbOrg.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Subscription Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminSubscription {
|
||||
id: string
|
||||
plan: string
|
||||
referenceId: string
|
||||
stripeCustomerId: string | null
|
||||
stripeSubscriptionId: string | null
|
||||
status: string | null
|
||||
periodStart: string | null
|
||||
periodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean | null
|
||||
seats: number | null
|
||||
trialStart: string | null
|
||||
trialEnd: string | null
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export function toAdminSubscription(dbSub: DbSubscription): AdminSubscription {
|
||||
return {
|
||||
id: dbSub.id,
|
||||
plan: dbSub.plan,
|
||||
referenceId: dbSub.referenceId,
|
||||
stripeCustomerId: dbSub.stripeCustomerId,
|
||||
stripeSubscriptionId: dbSub.stripeSubscriptionId,
|
||||
status: dbSub.status,
|
||||
periodStart: dbSub.periodStart?.toISOString() ?? null,
|
||||
periodEnd: dbSub.periodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: dbSub.cancelAtPeriodEnd,
|
||||
seats: dbSub.seats,
|
||||
trialStart: dbSub.trialStart?.toISOString() ?? null,
|
||||
trialEnd: dbSub.trialEnd?.toISOString() ?? null,
|
||||
metadata: dbSub.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Member Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminMember {
|
||||
id: string
|
||||
userId: string
|
||||
organizationId: string
|
||||
role: string
|
||||
createdAt: string
|
||||
// Joined user info
|
||||
userName: string
|
||||
userEmail: string
|
||||
}
|
||||
|
||||
export interface AdminMemberDetail extends AdminMember {
|
||||
// Billing/usage info from userStats
|
||||
currentPeriodCost: string
|
||||
currentUsageLimit: string | null
|
||||
lastActive: string | null
|
||||
billingBlocked: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Billing Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminUserBilling {
|
||||
userId: string
|
||||
// User info
|
||||
userName: string
|
||||
userEmail: string
|
||||
stripeCustomerId: string | null
|
||||
// Usage stats
|
||||
totalManualExecutions: number
|
||||
totalApiCalls: number
|
||||
totalWebhookTriggers: number
|
||||
totalScheduledExecutions: number
|
||||
totalChatExecutions: number
|
||||
totalTokensUsed: number
|
||||
totalCost: string
|
||||
currentUsageLimit: string | null
|
||||
currentPeriodCost: string
|
||||
lastPeriodCost: string | null
|
||||
billedOverageThisPeriod: string
|
||||
storageUsedBytes: number
|
||||
lastActive: string | null
|
||||
billingBlocked: boolean
|
||||
// Copilot usage
|
||||
totalCopilotCost: string
|
||||
currentPeriodCopilotCost: string
|
||||
lastPeriodCopilotCost: string | null
|
||||
totalCopilotTokens: number
|
||||
totalCopilotCalls: number
|
||||
}
|
||||
|
||||
export interface AdminUserBillingWithSubscription extends AdminUserBilling {
|
||||
subscriptions: AdminSubscription[]
|
||||
organizationMemberships: Array<{
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
role: string
|
||||
}>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Billing Summary Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AdminOrganizationBillingSummary {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
subscriptionPlan: string
|
||||
subscriptionStatus: string
|
||||
// Seats
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
availableSeats: number
|
||||
// Usage
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
minimumBillingAmount: number
|
||||
averageUsagePerMember: number
|
||||
usagePercentage: number
|
||||
// Billing period
|
||||
billingPeriodStart: string | null
|
||||
billingPeriodEnd: string | null
|
||||
// Alerts
|
||||
membersOverLimit: number
|
||||
membersNearLimit: number
|
||||
}
|
||||
|
||||
export interface AdminSeatAnalytics {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
currentSeats: number
|
||||
maxSeats: number
|
||||
availableSeats: number
|
||||
subscriptionPlan: string
|
||||
canAddSeats: boolean
|
||||
utilizationRate: number
|
||||
activeMembers: number
|
||||
inactiveMembers: number
|
||||
memberActivity: Array<{
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
role: string
|
||||
joinedAt: string
|
||||
lastActive: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
})
|
||||
260
apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
Normal file
260
apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* GET /api/v1/admin/users/[id]/billing
|
||||
*
|
||||
* Get user billing information including usage stats, subscriptions, and org memberships.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminUserBillingWithSubscription>
|
||||
*
|
||||
* PATCH /api/v1/admin/users/[id]/billing
|
||||
*
|
||||
* Update user billing settings with proper validation.
|
||||
*
|
||||
* Body:
|
||||
* - currentUsageLimit?: number | null - Usage limit (null to use default)
|
||||
* - billingBlocked?: boolean - Block/unblock billing
|
||||
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
|
||||
* - reason?: string - Reason for the change (for audit logging)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { eq, or } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
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'
|
||||
import {
|
||||
type AdminUserBillingWithSubscription,
|
||||
toAdminSubscription,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminUserBillingAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: userId } = await context.params
|
||||
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1)
|
||||
|
||||
const memberOrgs = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
organizationName: organization.name,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(member.organizationId, organization.id))
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
const orgIds = memberOrgs.map((m) => m.organizationId)
|
||||
|
||||
const subscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
orgIds.length > 0
|
||||
? or(
|
||||
eq(subscription.referenceId, userId),
|
||||
...orgIds.map((orgId) => eq(subscription.referenceId, orgId))
|
||||
)
|
||||
: eq(subscription.referenceId, userId)
|
||||
)
|
||||
|
||||
const data: AdminUserBillingWithSubscription = {
|
||||
userId: userData.id,
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
stripeCustomerId: userData.stripeCustomerId,
|
||||
totalManualExecutions: stats?.totalManualExecutions ?? 0,
|
||||
totalApiCalls: stats?.totalApiCalls ?? 0,
|
||||
totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0,
|
||||
totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0,
|
||||
totalChatExecutions: stats?.totalChatExecutions ?? 0,
|
||||
totalTokensUsed: stats?.totalTokensUsed ?? 0,
|
||||
totalCost: stats?.totalCost ?? '0',
|
||||
currentUsageLimit: stats?.currentUsageLimit ?? null,
|
||||
currentPeriodCost: stats?.currentPeriodCost ?? '0',
|
||||
lastPeriodCost: stats?.lastPeriodCost ?? null,
|
||||
billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0',
|
||||
storageUsedBytes: stats?.storageUsedBytes ?? 0,
|
||||
lastActive: stats?.lastActive?.toISOString() ?? null,
|
||||
billingBlocked: stats?.billingBlocked ?? false,
|
||||
totalCopilotCost: stats?.totalCopilotCost ?? '0',
|
||||
currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0',
|
||||
lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null,
|
||||
totalCopilotTokens: stats?.totalCopilotTokens ?? 0,
|
||||
totalCopilotCalls: stats?.totalCopilotCalls ?? 0,
|
||||
subscriptions: subscriptions.map(toAdminSubscription),
|
||||
organizationMemberships: memberOrgs.map((m) => ({
|
||||
organizationId: m.organizationId,
|
||||
organizationName: m.organizationName,
|
||||
role: m.role,
|
||||
})),
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved billing for user ${userId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get user billing', { error, userId })
|
||||
return internalErrorResponse('Failed to get user billing')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: userId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const reason = body.reason || 'Admin update (no reason provided)'
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [existingStats] = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const userSubscription = await getHighestPrioritySubscription(userId)
|
||||
const isTeamOrEnterpriseMember =
|
||||
userSubscription && ['team', 'enterprise'].includes(userSubscription.plan)
|
||||
|
||||
const [orgMembership] = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
const updated: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
if (body.currentUsageLimit !== undefined) {
|
||||
if (isTeamOrEnterpriseMember && orgMembership) {
|
||||
warnings.push(
|
||||
'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.'
|
||||
)
|
||||
}
|
||||
|
||||
if (body.currentUsageLimit === null) {
|
||||
updateData.currentUsageLimit = null
|
||||
} else if (typeof body.currentUsageLimit === 'number' && body.currentUsageLimit >= 0) {
|
||||
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
|
||||
if (body.currentUsageLimit < currentCost) {
|
||||
warnings.push(
|
||||
`New limit ($${body.currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
|
||||
)
|
||||
}
|
||||
updateData.currentUsageLimit = body.currentUsageLimit.toFixed(2)
|
||||
} else {
|
||||
return badRequestResponse('currentUsageLimit must be a non-negative number or null')
|
||||
}
|
||||
updateData.usageLimitUpdatedAt = new Date()
|
||||
updated.push('currentUsageLimit')
|
||||
}
|
||||
|
||||
if (body.billingBlocked !== undefined) {
|
||||
if (typeof body.billingBlocked !== 'boolean') {
|
||||
return badRequestResponse('billingBlocked must be a boolean')
|
||||
}
|
||||
|
||||
if (body.billingBlocked === false && existingStats?.billingBlocked === true) {
|
||||
warnings.push(
|
||||
'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.'
|
||||
)
|
||||
}
|
||||
|
||||
updateData.billingBlocked = body.billingBlocked
|
||||
updated.push('billingBlocked')
|
||||
}
|
||||
|
||||
if (body.currentPeriodCost !== undefined) {
|
||||
if (typeof body.currentPeriodCost !== 'number' || body.currentPeriodCost < 0) {
|
||||
return badRequestResponse('currentPeriodCost must be a non-negative number')
|
||||
}
|
||||
|
||||
const previousCost = existingStats?.currentPeriodCost || '0'
|
||||
warnings.push(
|
||||
`Manually adjusting currentPeriodCost from $${previousCost} to $${body.currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
|
||||
)
|
||||
|
||||
updateData.currentPeriodCost = body.currentPeriodCost.toFixed(2)
|
||||
updated.push('currentPeriodCost')
|
||||
}
|
||||
|
||||
if (updated.length === 0) {
|
||||
return badRequestResponse('No valid fields to update')
|
||||
}
|
||||
|
||||
if (existingStats) {
|
||||
await db.update(userStats).set(updateData).where(eq(userStats.userId, userId))
|
||||
} else {
|
||||
await db.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
...updateData,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated billing for user ${userId}`, {
|
||||
updated,
|
||||
warnings,
|
||||
reason,
|
||||
previousValues: existingStats
|
||||
? {
|
||||
currentUsageLimit: existingStats.currentUsageLimit,
|
||||
billingBlocked: existingStats.billingBlocked,
|
||||
currentPeriodCost: existingStats.currentPeriodCost,
|
||||
}
|
||||
: null,
|
||||
newValues: updateData,
|
||||
isTeamMember: !!orgMembership,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
updated,
|
||||
warnings,
|
||||
reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update user billing', { error, userId })
|
||||
return internalErrorResponse('Failed to update user billing')
|
||||
}
|
||||
})
|
||||
@@ -84,7 +84,6 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
})
|
||||
|
||||
// Build filter conditions
|
||||
const filters = {
|
||||
workspaceId: params.workspaceId,
|
||||
workflowIds: params.workflowIds?.split(',').filter(Boolean),
|
||||
|
||||
588
apps/sim/lib/billing/organizations/membership.ts
Normal file
588
apps/sim/lib/billing/organizations/membership.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Organization Membership Management
|
||||
*
|
||||
* Shared helpers for adding and removing users from organizations.
|
||||
* Used by both regular routes and admin routes to ensure consistent business logic.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
subscription as subscriptionTable,
|
||||
user,
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationMembership')
|
||||
|
||||
export interface AddMemberParams {
|
||||
userId: string
|
||||
organizationId: string
|
||||
role: 'admin' | 'member' | 'owner'
|
||||
/** Skip Pro snapshot/cancellation logic (default: false) */
|
||||
skipBillingLogic?: boolean
|
||||
/** Skip seat validation (default: false) */
|
||||
skipSeatValidation?: boolean
|
||||
}
|
||||
|
||||
export interface AddMemberResult {
|
||||
success: boolean
|
||||
memberId?: string
|
||||
error?: string
|
||||
billingActions: {
|
||||
proUsageSnapshotted: boolean
|
||||
proCancelledAtPeriodEnd: boolean
|
||||
/** If Pro was cancelled, contains info for Stripe update (caller can optionally call Stripe) */
|
||||
proSubscriptionToCancel?: {
|
||||
subscriptionId: string
|
||||
stripeSubscriptionId: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveMemberParams {
|
||||
userId: string
|
||||
organizationId: string
|
||||
memberId: string
|
||||
/** Skip departed usage capture and Pro restoration (default: false) */
|
||||
skipBillingLogic?: boolean
|
||||
}
|
||||
|
||||
export interface RemoveMemberResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
billingActions: {
|
||||
usageCaptured: number
|
||||
proRestored: boolean
|
||||
usageRestored: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface MembershipValidationResult {
|
||||
canAdd: boolean
|
||||
reason?: string
|
||||
existingOrgId?: string
|
||||
seatValidation?: {
|
||||
currentSeats: number
|
||||
maxSeats: number
|
||||
availableSeats: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a user can be added to an organization.
|
||||
* Checks single-org constraint and seat availability.
|
||||
*/
|
||||
export async function validateMembershipAddition(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<MembershipValidationResult> {
|
||||
const [userData] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return { canAdd: false, reason: 'User not found' }
|
||||
}
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return { canAdd: false, reason: 'Organization not found' }
|
||||
}
|
||||
|
||||
const existingMemberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
if (existingMemberships.length > 0) {
|
||||
const isAlreadyMemberOfThisOrg = existingMemberships.some(
|
||||
(m) => m.organizationId === organizationId
|
||||
)
|
||||
|
||||
if (isAlreadyMemberOfThisOrg) {
|
||||
return { canAdd: false, reason: 'User is already a member of this organization' }
|
||||
}
|
||||
|
||||
return {
|
||||
canAdd: false,
|
||||
reason:
|
||||
'User is already a member of another organization. Users can only belong to one organization at a time.',
|
||||
existingOrgId: existingMemberships[0].organizationId,
|
||||
}
|
||||
}
|
||||
|
||||
const seatValidation = await validateSeatAvailability(organizationId, 1)
|
||||
if (!seatValidation.canInvite) {
|
||||
return {
|
||||
canAdd: false,
|
||||
reason: seatValidation.reason || 'No seats available',
|
||||
seatValidation: {
|
||||
currentSeats: seatValidation.currentSeats,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canAdd: true,
|
||||
seatValidation: {
|
||||
currentSeats: seatValidation.currentSeats,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to an organization with full billing logic.
|
||||
*
|
||||
* Handles:
|
||||
* - Single organization constraint validation
|
||||
* - Seat availability validation
|
||||
* - Member record creation
|
||||
* - Pro usage snapshot when joining paid team
|
||||
* - Pro subscription cancellation at period end
|
||||
* - Usage limit sync
|
||||
*/
|
||||
export async function addUserToOrganization(params: AddMemberParams): Promise<AddMemberResult> {
|
||||
const {
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
skipBillingLogic = false,
|
||||
skipSeatValidation = false,
|
||||
} = params
|
||||
|
||||
const billingActions: AddMemberResult['billingActions'] = {
|
||||
proUsageSnapshotted: false,
|
||||
proCancelledAtPeriodEnd: false,
|
||||
}
|
||||
|
||||
try {
|
||||
if (!skipSeatValidation) {
|
||||
const validation = await validateMembershipAddition(userId, organizationId)
|
||||
if (!validation.canAdd) {
|
||||
return { success: false, error: validation.reason, billingActions }
|
||||
}
|
||||
} else {
|
||||
const existingMemberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
if (existingMemberships.length > 0) {
|
||||
const isAlreadyMemberOfThisOrg = existingMemberships.some(
|
||||
(m) => m.organizationId === organizationId
|
||||
)
|
||||
|
||||
if (isAlreadyMemberOfThisOrg) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User is already a member of this organization',
|
||||
billingActions,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'User is already a member of another organization. Users can only belong to one organization at a time.',
|
||||
billingActions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [orgSub] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, organizationId),
|
||||
eq(subscriptionTable.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
|
||||
|
||||
let memberId = ''
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
memberId = randomUUID()
|
||||
await tx.insert(member).values({
|
||||
id: memberId,
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Handle Pro subscription if org is paid and we're not skipping billing logic
|
||||
if (orgIsPaid && !skipBillingLogic) {
|
||||
// Find user's active personal Pro subscription
|
||||
const [personalPro] = await tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (personalPro) {
|
||||
// Snapshot the current Pro usage before resetting
|
||||
const [userStatsRow] = await tx
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRow) {
|
||||
const currentProUsage = userStatsRow.currentPeriodCost || '0'
|
||||
|
||||
// Snapshot Pro usage and reset currentPeriodCost so new usage goes to team
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
proPeriodCostSnapshot: currentProUsage,
|
||||
currentPeriodCost: '0',
|
||||
currentPeriodCopilotCost: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
billingActions.proUsageSnapshotted = true
|
||||
|
||||
logger.info('Snapshotted Pro usage when adding to team', {
|
||||
userId,
|
||||
proUsageSnapshot: currentProUsage,
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
|
||||
// Mark Pro for cancellation at period end
|
||||
if (!personalPro.cancelAtPeriodEnd) {
|
||||
await tx
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: true })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
billingActions.proCancelledAtPeriodEnd = true
|
||||
billingActions.proSubscriptionToCancel = {
|
||||
subscriptionId: personalPro.id,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
}
|
||||
|
||||
logger.info('Marked personal Pro for cancellation at period end', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Added user to organization', {
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
memberId,
|
||||
billingActions,
|
||||
})
|
||||
|
||||
return { success: true, memberId, billingActions }
|
||||
} catch (error) {
|
||||
logger.error('Failed to add user to organization', { userId, organizationId, error })
|
||||
return { success: false, error: 'Failed to add user to organization', billingActions }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user from an organization with full billing logic.
|
||||
*
|
||||
* Handles:
|
||||
* - Owner removal prevention
|
||||
* - Departed member usage capture
|
||||
* - Member record deletion
|
||||
* - Pro subscription restoration when leaving a paid team
|
||||
* - Pro usage restoration from snapshot
|
||||
*
|
||||
* Note: Users can only belong to one organization at a time.
|
||||
*/
|
||||
export async function removeUserFromOrganization(
|
||||
params: RemoveMemberParams
|
||||
): Promise<RemoveMemberResult> {
|
||||
const { userId, organizationId, memberId, skipBillingLogic = false } = params
|
||||
|
||||
const billingActions = {
|
||||
usageCaptured: 0,
|
||||
proRestored: false,
|
||||
usageRestored: false,
|
||||
}
|
||||
|
||||
try {
|
||||
// Check member exists and get their details
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return { success: false, error: 'Member not found', billingActions }
|
||||
}
|
||||
|
||||
// Prevent removing owner
|
||||
if (existingMember.role === 'owner') {
|
||||
return { success: false, error: 'Cannot remove organization owner', billingActions }
|
||||
}
|
||||
|
||||
// STEP 1: Capture departed member's usage (add to org's departedMemberUsage)
|
||||
if (!skipBillingLogic) {
|
||||
try {
|
||||
const [departingUserStats] = await db
|
||||
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (departingUserStats?.currentPeriodCost) {
|
||||
const usage = Number.parseFloat(departingUserStats.currentPeriodCost)
|
||||
if (usage > 0) {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({
|
||||
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ currentPeriodCost: '0' })
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
billingActions.usageCaptured = usage
|
||||
|
||||
logger.info('Captured departed member usage', {
|
||||
organizationId,
|
||||
userId,
|
||||
usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (usageCaptureError) {
|
||||
logger.error('Failed to capture departed member usage', {
|
||||
organizationId,
|
||||
userId,
|
||||
error: usageCaptureError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Delete the member record
|
||||
await db.delete(member).where(eq(member.id, memberId))
|
||||
|
||||
logger.info('Removed member from organization', {
|
||||
organizationId,
|
||||
userId,
|
||||
memberId,
|
||||
})
|
||||
|
||||
// STEP 3: Restore personal Pro if user has no remaining paid team memberships
|
||||
if (!skipBillingLogic) {
|
||||
try {
|
||||
// Check for remaining paid team memberships
|
||||
const remainingPaidTeams = await db
|
||||
.select({ orgId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
let hasAnyPaidTeam = false
|
||||
if (remainingPaidTeams.length > 0) {
|
||||
const orgIds = remainingPaidTeams.map((m) => m.orgId)
|
||||
const orgPaidSubs = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.status, 'active'))
|
||||
|
||||
hasAnyPaidTeam = orgPaidSubs.some(
|
||||
(s) => orgIds.includes(s.referenceId) && ['team', 'enterprise'].includes(s.plan ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
// If no remaining paid teams, try to restore personal Pro
|
||||
if (!hasAnyPaidTeam) {
|
||||
const [personalPro] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists
|
||||
if (
|
||||
personalPro &&
|
||||
personalPro.cancelAtPeriodEnd === true &&
|
||||
personalPro.stripeSubscriptionId
|
||||
) {
|
||||
// Call Stripe API first (separate try/catch so failure doesn't prevent DB update)
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
// Update DB (separate try/catch)
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
billingActions.proRestored = true
|
||||
|
||||
logger.info('Restored personal Pro after leaving last paid team', {
|
||||
userId,
|
||||
personalSubscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
// Restore snapshotted Pro usage (separate try/catch)
|
||||
try {
|
||||
const [stats] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (stats) {
|
||||
const currentUsage = stats.currentPeriodCost || '0'
|
||||
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
|
||||
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
billingActions.usageRestored = true
|
||||
|
||||
logger.info('Restored Pro usage after leaving team', {
|
||||
userId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage: snapshotUsage,
|
||||
restoredUsage: restoredUsage,
|
||||
})
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage after leaving team', {
|
||||
userId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (postRemoveError) {
|
||||
logger.error('Post-removal personal Pro restore check failed', {
|
||||
organizationId,
|
||||
userId,
|
||||
error: postRemoveError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, billingActions }
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove user from organization', {
|
||||
userId,
|
||||
organizationId,
|
||||
memberId,
|
||||
error,
|
||||
})
|
||||
return { success: false, error: 'Failed to remove user from organization', billingActions }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of a specific organization.
|
||||
*/
|
||||
export async function isUserMemberOfOrganization(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<{ isMember: boolean; role?: string; memberId?: string }> {
|
||||
const [memberRecord] = await db
|
||||
.select({ id: member.id, role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (memberRecord) {
|
||||
return { isMember: true, role: memberRecord.role, memberId: memberRecord.id }
|
||||
}
|
||||
|
||||
return { isMember: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current organization membership (if any).
|
||||
*/
|
||||
export async function getUserOrganization(
|
||||
userId: string
|
||||
): Promise<{ organizationId: string; role: string; memberId: string } | null> {
|
||||
const [memberRecord] = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
memberId: member.id,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
return memberRecord || null
|
||||
}
|
||||
Reference in New Issue
Block a user