mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-24 22:38:00 -05:00
Compare commits
5 Commits
fix/ci
...
feat/creds
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932aef3c1b | ||
|
|
aed0766207 | ||
|
|
4f09d07383 | ||
|
|
3bb85f2218 | ||
|
|
1e89d147ed |
211
apps/sim/app/api/v1/admin/credits/route.ts
Normal file
211
apps/sim/app/api/v1/admin/credits/route.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/v1/admin/credits
|
||||||
|
*
|
||||||
|
* Issue credits to a user by user ID or email.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - userId?: string - The user ID to issue credits to
|
||||||
|
* - email?: string - The user email to issue credits to (alternative to userId)
|
||||||
|
* - amount: number - The amount of credits to issue (in dollars)
|
||||||
|
* - reason?: string - Reason for issuing credits (for audit logging)
|
||||||
|
*
|
||||||
|
* Response: AdminSingleResponse<{
|
||||||
|
* success: true,
|
||||||
|
* entityType: 'user' | 'organization',
|
||||||
|
* entityId: string,
|
||||||
|
* amount: number,
|
||||||
|
* newCreditBalance: number,
|
||||||
|
* newUsageLimit: number,
|
||||||
|
* }>
|
||||||
|
*
|
||||||
|
* For Pro users: credits are added to user_stats.credit_balance
|
||||||
|
* For Team users: credits are added to organization.credit_balance
|
||||||
|
* Usage limits are updated accordingly to allow spending the credits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { organization, subscription, user, userStats } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||||
|
import { addCredits } from '@/lib/billing/credits/balance'
|
||||||
|
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||||
|
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
||||||
|
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||||
|
import {
|
||||||
|
badRequestResponse,
|
||||||
|
internalErrorResponse,
|
||||||
|
notFoundResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '@/app/api/v1/admin/responses'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminCreditsAPI')
|
||||||
|
|
||||||
|
export const POST = withAdminAuth(async (request) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { userId, email, amount, reason } = body
|
||||||
|
|
||||||
|
if (!userId && !email) {
|
||||||
|
return badRequestResponse('Either userId or email is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId && typeof userId !== 'string') {
|
||||||
|
return badRequestResponse('userId must be a string')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email && typeof email !== 'string') {
|
||||||
|
return badRequestResponse('email must be a string')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
return badRequestResponse('amount must be a positive number')
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedUserId: string
|
||||||
|
let userEmail: string | null = null
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const [userData] = await db
|
||||||
|
.select({ id: user.id, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
return notFoundResponse('User')
|
||||||
|
}
|
||||||
|
resolvedUserId = userData.id
|
||||||
|
userEmail = userData.email
|
||||||
|
} else {
|
||||||
|
const normalizedEmail = email.toLowerCase().trim()
|
||||||
|
const [userData] = await db
|
||||||
|
.select({ id: user.id, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, normalizedEmail))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
return notFoundResponse('User with email')
|
||||||
|
}
|
||||||
|
resolvedUserId = userData.id
|
||||||
|
userEmail = userData.email
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
|
||||||
|
|
||||||
|
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
|
||||||
|
return badRequestResponse(
|
||||||
|
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let entityType: 'user' | 'organization'
|
||||||
|
let entityId: string
|
||||||
|
const plan = userSubscription.plan
|
||||||
|
let seats: number | null = null
|
||||||
|
|
||||||
|
if (plan === 'team' || plan === 'enterprise') {
|
||||||
|
entityType = 'organization'
|
||||||
|
entityId = userSubscription.referenceId
|
||||||
|
|
||||||
|
const [orgExists] = await db
|
||||||
|
.select({ id: organization.id })
|
||||||
|
.from(organization)
|
||||||
|
.where(eq(organization.id, entityId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!orgExists) {
|
||||||
|
return notFoundResponse('Organization')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [subData] = await db
|
||||||
|
.select()
|
||||||
|
.from(subscription)
|
||||||
|
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
seats = getEffectiveSeats(subData)
|
||||||
|
} else {
|
||||||
|
entityType = 'user'
|
||||||
|
entityId = resolvedUserId
|
||||||
|
|
||||||
|
const [existingStats] = await db
|
||||||
|
.select({ id: userStats.id })
|
||||||
|
.from(userStats)
|
||||||
|
.where(eq(userStats.userId, entityId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existingStats) {
|
||||||
|
await db.insert(userStats).values({
|
||||||
|
id: nanoid(),
|
||||||
|
userId: entityId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await addCredits(entityType, entityId, amount)
|
||||||
|
|
||||||
|
let newCreditBalance: number
|
||||||
|
if (entityType === 'organization') {
|
||||||
|
const [orgData] = await db
|
||||||
|
.select({ creditBalance: organization.creditBalance })
|
||||||
|
.from(organization)
|
||||||
|
.where(eq(organization.id, entityId))
|
||||||
|
.limit(1)
|
||||||
|
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
|
||||||
|
} else {
|
||||||
|
const [stats] = await db
|
||||||
|
.select({ creditBalance: userStats.creditBalance })
|
||||||
|
.from(userStats)
|
||||||
|
.where(eq(userStats.userId, entityId))
|
||||||
|
.limit(1)
|
||||||
|
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
|
||||||
|
|
||||||
|
let newUsageLimit: number
|
||||||
|
if (entityType === 'organization') {
|
||||||
|
const [orgData] = await db
|
||||||
|
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||||
|
.from(organization)
|
||||||
|
.where(eq(organization.id, entityId))
|
||||||
|
.limit(1)
|
||||||
|
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
|
||||||
|
} else {
|
||||||
|
const [stats] = await db
|
||||||
|
.select({ currentUsageLimit: userStats.currentUsageLimit })
|
||||||
|
.from(userStats)
|
||||||
|
.where(eq(userStats.userId, entityId))
|
||||||
|
.limit(1)
|
||||||
|
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Admin API: Issued credits', {
|
||||||
|
resolvedUserId,
|
||||||
|
userEmail,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
amount,
|
||||||
|
newCreditBalance,
|
||||||
|
newUsageLimit,
|
||||||
|
reason: reason || 'No reason provided',
|
||||||
|
})
|
||||||
|
|
||||||
|
return singleResponse({
|
||||||
|
success: true,
|
||||||
|
userId: resolvedUserId,
|
||||||
|
userEmail,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
amount,
|
||||||
|
newCreditBalance,
|
||||||
|
newUsageLimit,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to issue credits', { error })
|
||||||
|
return internalErrorResponse('Failed to issue credits')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -63,6 +63,9 @@
|
|||||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||||
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
||||||
*
|
*
|
||||||
|
* Credits:
|
||||||
|
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
|
||||||
|
*
|
||||||
* Access Control (Permission Groups):
|
* Access Control (Permission Groups):
|
||||||
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
|
||||||
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ export function AccessControl() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] px-[12px] py-[10px]'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex flex-col gap-[2px]'>
|
<div className='flex flex-col gap-[2px]'>
|
||||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
Auto-add new members
|
Auto-add new members
|
||||||
@@ -705,7 +705,7 @@ export function AccessControl() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
<div className='flex flex-col gap-[16px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||||
Members
|
Members
|
||||||
|
|||||||
Reference in New Issue
Block a user