Skip to content

Commit d717338

Browse files
committed
Merge branch 'staging' into fix/preflight-env-vars
2 parents f6a4b8e + efef91e commit d717338

File tree

6 files changed

+268
-11
lines changed

6 files changed

+268
-11
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* POST /api/v1/admin/credits
3+
*
4+
* Issue credits to a user by user ID or email.
5+
*
6+
* Body:
7+
* - userId?: string - The user ID to issue credits to
8+
* - email?: string - The user email to issue credits to (alternative to userId)
9+
* - amount: number - The amount of credits to issue (in dollars)
10+
* - reason?: string - Reason for issuing credits (for audit logging)
11+
*
12+
* Response: AdminSingleResponse<{
13+
* success: true,
14+
* entityType: 'user' | 'organization',
15+
* entityId: string,
16+
* amount: number,
17+
* newCreditBalance: number,
18+
* newUsageLimit: number,
19+
* }>
20+
*
21+
* For Pro users: credits are added to user_stats.credit_balance
22+
* For Team users: credits are added to organization.credit_balance
23+
* Usage limits are updated accordingly to allow spending the credits.
24+
*/
25+
26+
import { db } from '@sim/db'
27+
import { organization, subscription, user, userStats } from '@sim/db/schema'
28+
import { createLogger } from '@sim/logger'
29+
import { and, eq } from 'drizzle-orm'
30+
import { nanoid } from 'nanoid'
31+
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
32+
import { addCredits } from '@/lib/billing/credits/balance'
33+
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
34+
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
35+
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
36+
import {
37+
badRequestResponse,
38+
internalErrorResponse,
39+
notFoundResponse,
40+
singleResponse,
41+
} from '@/app/api/v1/admin/responses'
42+
43+
const logger = createLogger('AdminCreditsAPI')
44+
45+
export const POST = withAdminAuth(async (request) => {
46+
try {
47+
const body = await request.json()
48+
const { userId, email, amount, reason } = body
49+
50+
if (!userId && !email) {
51+
return badRequestResponse('Either userId or email is required')
52+
}
53+
54+
if (userId && typeof userId !== 'string') {
55+
return badRequestResponse('userId must be a string')
56+
}
57+
58+
if (email && typeof email !== 'string') {
59+
return badRequestResponse('email must be a string')
60+
}
61+
62+
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
63+
return badRequestResponse('amount must be a positive number')
64+
}
65+
66+
let resolvedUserId: string
67+
let userEmail: string | null = null
68+
69+
if (userId) {
70+
const [userData] = await db
71+
.select({ id: user.id, email: user.email })
72+
.from(user)
73+
.where(eq(user.id, userId))
74+
.limit(1)
75+
76+
if (!userData) {
77+
return notFoundResponse('User')
78+
}
79+
resolvedUserId = userData.id
80+
userEmail = userData.email
81+
} else {
82+
const normalizedEmail = email.toLowerCase().trim()
83+
const [userData] = await db
84+
.select({ id: user.id, email: user.email })
85+
.from(user)
86+
.where(eq(user.email, normalizedEmail))
87+
.limit(1)
88+
89+
if (!userData) {
90+
return notFoundResponse('User with email')
91+
}
92+
resolvedUserId = userData.id
93+
userEmail = userData.email
94+
}
95+
96+
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
97+
98+
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
99+
return badRequestResponse(
100+
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
101+
)
102+
}
103+
104+
let entityType: 'user' | 'organization'
105+
let entityId: string
106+
const plan = userSubscription.plan
107+
let seats: number | null = null
108+
109+
if (plan === 'team' || plan === 'enterprise') {
110+
entityType = 'organization'
111+
entityId = userSubscription.referenceId
112+
113+
const [orgExists] = await db
114+
.select({ id: organization.id })
115+
.from(organization)
116+
.where(eq(organization.id, entityId))
117+
.limit(1)
118+
119+
if (!orgExists) {
120+
return notFoundResponse('Organization')
121+
}
122+
123+
const [subData] = await db
124+
.select()
125+
.from(subscription)
126+
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
127+
.limit(1)
128+
129+
seats = getEffectiveSeats(subData)
130+
} else {
131+
entityType = 'user'
132+
entityId = resolvedUserId
133+
134+
const [existingStats] = await db
135+
.select({ id: userStats.id })
136+
.from(userStats)
137+
.where(eq(userStats.userId, entityId))
138+
.limit(1)
139+
140+
if (!existingStats) {
141+
await db.insert(userStats).values({
142+
id: nanoid(),
143+
userId: entityId,
144+
})
145+
}
146+
}
147+
148+
await addCredits(entityType, entityId, amount)
149+
150+
let newCreditBalance: number
151+
if (entityType === 'organization') {
152+
const [orgData] = await db
153+
.select({ creditBalance: organization.creditBalance })
154+
.from(organization)
155+
.where(eq(organization.id, entityId))
156+
.limit(1)
157+
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
158+
} else {
159+
const [stats] = await db
160+
.select({ creditBalance: userStats.creditBalance })
161+
.from(userStats)
162+
.where(eq(userStats.userId, entityId))
163+
.limit(1)
164+
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
165+
}
166+
167+
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
168+
169+
let newUsageLimit: number
170+
if (entityType === 'organization') {
171+
const [orgData] = await db
172+
.select({ orgUsageLimit: organization.orgUsageLimit })
173+
.from(organization)
174+
.where(eq(organization.id, entityId))
175+
.limit(1)
176+
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
177+
} else {
178+
const [stats] = await db
179+
.select({ currentUsageLimit: userStats.currentUsageLimit })
180+
.from(userStats)
181+
.where(eq(userStats.userId, entityId))
182+
.limit(1)
183+
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
184+
}
185+
186+
logger.info('Admin API: Issued credits', {
187+
resolvedUserId,
188+
userEmail,
189+
entityType,
190+
entityId,
191+
amount,
192+
newCreditBalance,
193+
newUsageLimit,
194+
reason: reason || 'No reason provided',
195+
})
196+
197+
return singleResponse({
198+
success: true,
199+
userId: resolvedUserId,
200+
userEmail,
201+
entityType,
202+
entityId,
203+
amount,
204+
newCreditBalance,
205+
newUsageLimit,
206+
})
207+
} catch (error) {
208+
logger.error('Admin API: Failed to issue credits', { error })
209+
return internalErrorResponse('Failed to issue credits')
210+
}
211+
})

apps/sim/app/api/v1/admin/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
* GET /api/v1/admin/subscriptions/:id - Get subscription details
6464
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
6565
*
66+
* Credits:
67+
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
68+
*
6669
* Access Control (Permission Groups):
6770
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
6871
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,10 +1425,7 @@ function RunSkipButtons({
14251425
setIsProcessing(true)
14261426
setButtonsHidden(true)
14271427
try {
1428-
// Add to auto-allowed list - this also executes all pending integration tools of this type
14291428
await addAutoAllowedTool(toolCall.name)
1430-
// For client tools with interrupts (not integration tools), we still need to call handleRun
1431-
// since executeIntegrationTool only works for server-side tools
14321429
if (!isIntegrationTool(toolCall.name)) {
14331430
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
14341431
}
@@ -1526,7 +1523,11 @@ export function ToolCall({
15261523
toolCall.name === 'user_memory' ||
15271524
toolCall.name === 'edit_respond' ||
15281525
toolCall.name === 'debug_respond' ||
1529-
toolCall.name === 'plan_respond'
1526+
toolCall.name === 'plan_respond' ||
1527+
toolCall.name === 'research_respond' ||
1528+
toolCall.name === 'info_respond' ||
1529+
toolCall.name === 'deploy_respond' ||
1530+
toolCall.name === 'superagent_respond'
15301531
)
15311532
return null
15321533

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,20 @@ export interface SlashCommand {
209209
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
210210
{ id: 'fast', label: 'Fast' },
211211
{ id: 'research', label: 'Research' },
212-
{ id: 'superagent', label: 'Actions' },
212+
{ id: 'actions', label: 'Actions' },
213213
] as const
214214

215+
/**
216+
* Maps UI command IDs to API command IDs.
217+
* Some commands have different IDs for display vs API (e.g., "actions" -> "superagent")
218+
*/
219+
export function getApiCommandId(uiCommandId: string): string {
220+
const commandMapping: Record<string, string> = {
221+
actions: 'superagent',
222+
}
223+
return commandMapping[uiCommandId] || uiCommandId
224+
}
225+
215226
export const WEB_COMMANDS: readonly SlashCommand[] = [
216227
{ id: 'search', label: 'Search' },
217228
{ id: 'read', label: 'Read' },

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ export function AccessControl() {
688688
)}
689689
</div>
690690

691-
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] px-[12px] py-[10px]'>
691+
<div className='flex items-center justify-between'>
692692
<div className='flex flex-col gap-[2px]'>
693693
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
694694
Auto-add new members
@@ -705,7 +705,7 @@ export function AccessControl() {
705705
</div>
706706

707707
<div className='min-h-0 flex-1 overflow-y-auto'>
708-
<div className='flex flex-col gap-[16px]'>
708+
<div className='flex flex-col gap-[8px]'>
709709
<div className='flex items-center justify-between'>
710710
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
711711
Members

apps/sim/stores/panel/copilot/store.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2116,6 +2116,24 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
21162116
})
21172117
})
21182118
}
2119+
} else {
2120+
// Check if this is an integration tool (server-side) that should be auto-executed
2121+
const isIntegrationTool = !CLASS_TOOL_METADATA[name]
2122+
if (isIntegrationTool && isSubAgentAutoAllowed) {
2123+
logger.info('[SubAgent] Auto-executing integration tool (auto-allowed)', {
2124+
id,
2125+
name,
2126+
})
2127+
// Execute integration tool via the store method
2128+
const { executeIntegrationTool } = get()
2129+
executeIntegrationTool(id).catch((err) => {
2130+
logger.error('[SubAgent] Integration tool auto-execution failed', {
2131+
id,
2132+
name,
2133+
error: err?.message || err,
2134+
})
2135+
})
2136+
}
21192137
}
21202138
}
21212139
} catch (e: any) {
@@ -2797,9 +2815,14 @@ export const useCopilotStore = create<CopilotStore>()(
27972815
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
27982816

27992817
// Extract slash commands from contexts (lowercase) and filter them out from contexts
2818+
// Map UI command IDs to API command IDs (e.g., "actions" -> "superagent")
2819+
const uiToApiCommandMap: Record<string, string> = { actions: 'superagent' }
28002820
const commands = contexts
28012821
?.filter((c) => c.kind === 'slash_command' && 'command' in c)
2802-
.map((c) => (c as any).command.toLowerCase()) as string[] | undefined
2822+
.map((c) => {
2823+
const uiCommand = (c as any).command.toLowerCase()
2824+
return uiToApiCommandMap[uiCommand] || uiCommand
2825+
}) as string[] | undefined
28032826
const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command')
28042827

28052828
const result = await sendStreamingMessage({
@@ -3923,11 +3946,16 @@ export const useCopilotStore = create<CopilotStore>()(
39233946

39243947
loadAutoAllowedTools: async () => {
39253948
try {
3949+
logger.info('[AutoAllowedTools] Loading from API...')
39263950
const res = await fetch('/api/copilot/auto-allowed-tools')
3951+
logger.info('[AutoAllowedTools] Load response', { status: res.status, ok: res.ok })
39273952
if (res.ok) {
39283953
const data = await res.json()
3929-
set({ autoAllowedTools: data.autoAllowedTools || [] })
3930-
logger.info('[AutoAllowedTools] Loaded', { tools: data.autoAllowedTools })
3954+
const tools = data.autoAllowedTools || []
3955+
set({ autoAllowedTools: tools })
3956+
logger.info('[AutoAllowedTools] Loaded successfully', { count: tools.length, tools })
3957+
} else {
3958+
logger.warn('[AutoAllowedTools] Load failed with status', { status: res.status })
39313959
}
39323960
} catch (err) {
39333961
logger.error('[AutoAllowedTools] Failed to load', { error: err })
@@ -3936,15 +3964,18 @@ export const useCopilotStore = create<CopilotStore>()(
39363964

39373965
addAutoAllowedTool: async (toolId: string) => {
39383966
try {
3967+
logger.info('[AutoAllowedTools] Adding tool...', { toolId })
39393968
const res = await fetch('/api/copilot/auto-allowed-tools', {
39403969
method: 'POST',
39413970
headers: { 'Content-Type': 'application/json' },
39423971
body: JSON.stringify({ toolId }),
39433972
})
3973+
logger.info('[AutoAllowedTools] API response', { toolId, status: res.status, ok: res.ok })
39443974
if (res.ok) {
39453975
const data = await res.json()
3976+
logger.info('[AutoAllowedTools] API returned', { toolId, tools: data.autoAllowedTools })
39463977
set({ autoAllowedTools: data.autoAllowedTools || [] })
3947-
logger.info('[AutoAllowedTools] Added tool', { toolId })
3978+
logger.info('[AutoAllowedTools] Added tool to store', { toolId })
39483979

39493980
// Auto-execute all pending tools of the same type
39503981
const { toolCallsById, executeIntegrationTool } = get()

0 commit comments

Comments
 (0)