Compare commits

...

4 Commits

Author SHA1 Message Date
Siddharth Ganesan
896d6b5529 Remove console logs 2026-01-23 13:02:37 -08:00
Siddharth Ganesan
fefcb61f8b Fix actions mapping 2026-01-23 12:28:23 -08:00
Siddharth Ganesan
24173bb008 Improvements 2026-01-23 12:10:29 -08:00
Waleed
64efeaa2e6 feat(admin): add credits endpoint to issue credits to users (#2954)
* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings
2026-01-23 11:33:13 -08:00
6 changed files with 268 additions and 11 deletions

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

View File

@@ -63,6 +63,9 @@
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* 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):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

View File

@@ -1425,10 +1425,7 @@ function RunSkipButtons({
setIsProcessing(true)
setButtonsHidden(true)
try {
// Add to auto-allowed list - this also executes all pending integration tools of this type
await addAutoAllowedTool(toolCall.name)
// For client tools with interrupts (not integration tools), we still need to call handleRun
// since executeIntegrationTool only works for server-side tools
if (!isIntegrationTool(toolCall.name)) {
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
}
@@ -1526,7 +1523,11 @@ export function ToolCall({
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
toolCall.name === 'plan_respond' ||
toolCall.name === 'research_respond' ||
toolCall.name === 'info_respond' ||
toolCall.name === 'deploy_respond' ||
toolCall.name === 'superagent_respond'
)
return null

View File

@@ -209,9 +209,20 @@ export interface SlashCommand {
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
{ id: 'actions', label: 'Actions' },
] as const
/**
* Maps UI command IDs to API command IDs.
* Some commands have different IDs for display vs API (e.g., "actions" -> "superagent")
*/
export function getApiCommandId(uiCommandId: string): string {
const commandMapping: Record<string, string> = {
actions: 'superagent',
}
return commandMapping[uiCommandId] || uiCommandId
}
export const WEB_COMMANDS: readonly SlashCommand[] = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },

View File

@@ -688,7 +688,7 @@ export function AccessControl() {
)}
</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]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Auto-add new members
@@ -705,7 +705,7 @@ export function AccessControl() {
</div>
<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'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Members

View File

@@ -2116,6 +2116,24 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
})
})
}
} else {
// Check if this is an integration tool (server-side) that should be auto-executed
const isIntegrationTool = !CLASS_TOOL_METADATA[name]
if (isIntegrationTool && isSubAgentAutoAllowed) {
logger.info('[SubAgent] Auto-executing integration tool (auto-allowed)', {
id,
name,
})
// Execute integration tool via the store method
const { executeIntegrationTool } = get()
executeIntegrationTool(id).catch((err) => {
logger.error('[SubAgent] Integration tool auto-execution failed', {
id,
name,
error: err?.message || err,
})
})
}
}
}
} catch (e: any) {
@@ -2797,9 +2815,14 @@ export const useCopilotStore = create<CopilotStore>()(
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
// Extract slash commands from contexts (lowercase) and filter them out from contexts
// Map UI command IDs to API command IDs (e.g., "actions" -> "superagent")
const uiToApiCommandMap: Record<string, string> = { actions: 'superagent' }
const commands = contexts
?.filter((c) => c.kind === 'slash_command' && 'command' in c)
.map((c) => (c as any).command.toLowerCase()) as string[] | undefined
.map((c) => {
const uiCommand = (c as any).command.toLowerCase()
return uiToApiCommandMap[uiCommand] || uiCommand
}) as string[] | undefined
const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command')
const result = await sendStreamingMessage({
@@ -3923,11 +3946,16 @@ export const useCopilotStore = create<CopilotStore>()(
loadAutoAllowedTools: async () => {
try {
logger.info('[AutoAllowedTools] Loading from API...')
const res = await fetch('/api/copilot/auto-allowed-tools')
logger.info('[AutoAllowedTools] Load response', { status: res.status, ok: res.ok })
if (res.ok) {
const data = await res.json()
set({ autoAllowedTools: data.autoAllowedTools || [] })
logger.info('[AutoAllowedTools] Loaded', { tools: data.autoAllowedTools })
const tools = data.autoAllowedTools || []
set({ autoAllowedTools: tools })
logger.info('[AutoAllowedTools] Loaded successfully', { count: tools.length, tools })
} else {
logger.warn('[AutoAllowedTools] Load failed with status', { status: res.status })
}
} catch (err) {
logger.error('[AutoAllowedTools] Failed to load', { error: err })
@@ -3936,15 +3964,18 @@ export const useCopilotStore = create<CopilotStore>()(
addAutoAllowedTool: async (toolId: string) => {
try {
logger.info('[AutoAllowedTools] Adding tool...', { toolId })
const res = await fetch('/api/copilot/auto-allowed-tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolId }),
})
logger.info('[AutoAllowedTools] API response', { toolId, status: res.status, ok: res.ok })
if (res.ok) {
const data = await res.json()
logger.info('[AutoAllowedTools] API returned', { toolId, tools: data.autoAllowedTools })
set({ autoAllowedTools: data.autoAllowedTools || [] })
logger.info('[AutoAllowedTools] Added tool', { toolId })
logger.info('[AutoAllowedTools] Added tool to store', { toolId })
// Auto-execute all pending tools of the same type
const { toolCallsById, executeIntegrationTool } = get()