Compare commits

..

1 Commits

Author SHA1 Message Date
waleed
2b29c8c258 feat(api): add dedicated api endpoint, update docs, update deploy modal 2026-02-01 20:15:54 -08:00
57 changed files with 282 additions and 410 deletions

View File

@@ -14,7 +14,7 @@ Alle API-Anfragen erfordern einen API-Schlüssel, der im Header `x-api-key` übe
```bash
curl -H "x-api-key: YOUR_API_KEY" \
https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
```
Sie können API-Schlüssel in Ihren Benutzereinstellungen im Sim-Dashboard generieren.
@@ -528,7 +528,7 @@ async function pollLogs() {
}
const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`,
`https://api.sim.ai/api/v1/logs?${params}`,
{
headers: {
'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**Beispielanfrage:**
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://api.sim.ai/api/users/me/usage-limits
```
**Beispielantwort:**

View File

@@ -647,7 +647,7 @@ def stream_workflow():
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY')

View File

@@ -965,7 +965,7 @@ function StreamingWorkflow() {
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
const response = await fetch('https://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -14,7 +14,7 @@ All API requests require an API key passed in the `x-api-key` header:
```bash
curl -H "x-api-key: YOUR_API_KEY" \
https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
```
You can generate API keys from your user settings in the Sim dashboard.
@@ -513,7 +513,7 @@ async function pollLogs() {
}
const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`,
`https://api.sim.ai/api/v1/logs?${params}`,
{
headers: {
'x-api-key': 'YOUR_API_KEY'

View File

@@ -160,7 +160,7 @@ GET /api/users/me/usage-limits
**Example Request:**
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://api.sim.ai/api/users/me/usage-limits
```
**Example Response:**

View File

@@ -82,7 +82,7 @@ Submit forms programmatically:
<Tabs items={['cURL', 'TypeScript']}>
<Tab value="cURL">
```bash
curl -X POST https://sim.ai/api/form/your-identifier \
curl -X POST https://api.sim.ai/api/form/your-identifier \
-H "Content-Type: application/json" \
-d '{
"formData": {
@@ -94,7 +94,7 @@ curl -X POST https://sim.ai/api/form/your-identifier \
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://sim.ai/api/form/your-identifier', {
const response = await fetch('https://api.sim.ai/api/form/your-identifier', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -115,14 +115,14 @@ const result = await response.json();
For password-protected forms:
```bash
curl -X POST https://sim.ai/api/form/your-identifier \
curl -X POST https://api.sim.ai/api/form/your-identifier \
-H "Content-Type: application/json" \
-d '{ "password": "secret", "formData": { "name": "John" } }'
```
For email-protected forms:
```bash
curl -X POST https://sim.ai/api/form/your-identifier \
curl -X POST https://api.sim.ai/api/form/your-identifier \
-H "Content-Type: application/json" \
-d '{ "email": "allowed@example.com", "formData": { "name": "John" } }'
```

View File

@@ -655,7 +655,7 @@ def stream_workflow():
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY')

View File

@@ -948,7 +948,7 @@ function StreamingWorkflow() {
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
const response = await fetch('https://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -14,7 +14,7 @@ Todas las solicitudes a la API requieren una clave de API pasada en el encabezad
```bash
curl -H "x-api-key: YOUR_API_KEY" \
https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
```
Puedes generar claves de API desde la configuración de usuario en el panel de control de Sim.
@@ -528,7 +528,7 @@ async function pollLogs() {
}
const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`,
`https://api.sim.ai/api/v1/logs?${params}`,
{
headers: {
'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**Solicitud de ejemplo:**
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://api.sim.ai/api/users/me/usage-limits
```
**Respuesta de ejemplo:**

View File

@@ -656,7 +656,7 @@ def stream_workflow():
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY')

View File

@@ -965,7 +965,7 @@ function StreamingWorkflow() {
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
const response = await fetch('https://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -14,7 +14,7 @@ Toutes les requêtes API nécessitent une clé API transmise dans l'en-tête `x-
```bash
curl -H "x-api-key: YOUR_API_KEY" \
https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
```
Vous pouvez générer des clés API depuis vos paramètres utilisateur dans le tableau de bord Sim.
@@ -528,7 +528,7 @@ async function pollLogs() {
}
const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`,
`https://api.sim.ai/api/v1/logs?${params}`,
{
headers: {
'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**Exemple de requête :**
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://api.sim.ai/api/users/me/usage-limits
```
**Exemple de réponse :**

View File

@@ -656,7 +656,7 @@ def stream_workflow():
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY')

View File

@@ -965,7 +965,7 @@ function StreamingWorkflow() {
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
const response = await fetch('https://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -14,7 +14,7 @@ Simは、ワークフローの実行ログを照会したり、ワークフロ
```bash
curl -H "x-api-key: YOUR_API_KEY" \
https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
```
SimダッシュボードのユーザーセッティングからAPIキーを生成できます。
@@ -528,7 +528,7 @@ async function pollLogs() {
}
const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`,
`https://api.sim.ai/api/v1/logs?${params}`,
{
headers: {
'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**リクエスト例:**
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://api.sim.ai/api/users/me/usage-limits
```
**レスポンス例:**

View File

@@ -656,7 +656,7 @@ def stream_workflow():
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY')

View File

@@ -965,7 +965,7 @@ function StreamingWorkflow() {
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
const response = await fetch('https://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -14,7 +14,7 @@ Sim 提供了一个全面的外部 API用于查询工作流执行日志
```bash
curl -H "x-api-key: YOUR_API_KEY" \
https://sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
```
您可以在 Sim 仪表板的用户设置中生成 API 密钥。
@@ -528,7 +528,7 @@ async function pollLogs() {
}
const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`,
`https://api.sim.ai/api/v1/logs?${params}`,
{
headers: {
'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**请求示例:**
```bash
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://api.sim.ai/api/users/me/usage-limits
```
**响应示例:**

View File

@@ -656,7 +656,7 @@ def stream_workflow():
def generate():
response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute',
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY')

View File

@@ -965,7 +965,7 @@ function StreamingWorkflow() {
// IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
const response = await fetch('https://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -8,7 +8,6 @@ import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30
const MAX_INT32 = 2_147_483_647
export async function GET(request: NextRequest) {
try {
@@ -46,14 +45,13 @@ export async function GET(request: NextRequest) {
try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
await db
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: new Date(),
totalDurationMs,
totalDurationMs: staleDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],

View File

@@ -284,7 +284,7 @@ async function handleToolsCall(
content: [
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
],
isError: executeResult.success === false,
isError: !executeResult.success,
}
return NextResponse.json(createResponse(id, result))

View File

@@ -20,7 +20,6 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -502,18 +501,6 @@ export async function PUT(
}
}
if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: session.user.id,
organizationId,
error: syncError,
})
}
}
logger.info(`Organization invitation ${status}`, {
organizationId,
invitationId,

View File

@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI')
@@ -89,14 +88,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
)
}
await db
.update(subscription)
.set({ referenceId: organizationId })

View File

@@ -203,10 +203,6 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
}
updateData.billingBlocked = body.billingBlocked
// Clear the reason when unblocking
if (body.billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked')
}

View File

@@ -1,4 +1,6 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
@@ -6,7 +8,6 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
@@ -74,31 +75,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4()
// Run preprocessing checks (billing, rate limits, usage limits)
const preprocessResult = await preprocessExecution({
workflowId,
userId,
triggerType: 'manual',
executionId,
requestId,
checkRateLimit: false, // Manual executions don't rate limit
checkDeployment: false, // Run-from-block doesn't require deployment
})
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!preprocessResult.success) {
const { error } = preprocessResult
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
workflowId,
error: error?.message,
statusCode: error?.statusCode,
})
return NextResponse.json(
{ error: error?.message || 'Execution blocked' },
{ status: error?.statusCode || 500 }
)
}
const workflowRecord = preprocessResult.workflowRecord
if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
}
@@ -110,7 +92,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length,
billingActorUserId: preprocessResult.actorUserId,
})
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)

View File

@@ -16,7 +16,7 @@ import {
ModalTabsList,
ModalTabsTrigger,
} from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getApiUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
@@ -201,7 +201,7 @@ export function DeployModal({
return null
}
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const endpoint = `${getApiUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()

View File

@@ -50,12 +50,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/** Shared style for dashed divider lines */
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
/**
* Icon component for rendering block icons.
*
@@ -95,23 +89,31 @@ export function Editor() {
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const title = currentBlock?.name || 'Editor'
// Check if selected block is a subflow (loop or parallel)
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams()
const workspaceId = params.workspaceId as string
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null)
// Get user permissions
const userPermissions = useUserPermissionsContext()
// Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get block properties (advanced/trigger modes)
const { advancedMode, triggerMode } = useEditorBlockProperties(
currentBlockId,
currentWorkflow.isSnapshotView
@@ -143,9 +145,10 @@ export function Editor() {
[subBlocksForCanonical]
)
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = useMemo(
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
const advancedValuesPresent = hasAdvancedValues(
subBlocksForCanonical,
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
@@ -153,9 +156,11 @@ export function Editor() {
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
if (subBlock.mode !== 'advanced') continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
// Check condition - skip if condition not met for current values
if (
subBlock.condition &&
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
@@ -168,6 +173,7 @@ export function Editor() {
return false
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any),
currentBlockId || '',
@@ -200,34 +206,31 @@ export function Editor() {
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
// Connections resize hook
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
subBlocksRef,
})
// Collaborative actions
const {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
} = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
/**
* Ref callback that auto-selects the input text when mounted.
*/
const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => {
if (element) {
element.select()
}
}, [])
const nameInputRef = useRef<HTMLInputElement>(null)
/**
* Handles starting the rename process.
@@ -248,6 +251,7 @@ export function Editor() {
if (trimmedName && trimmedName !== currentBlock?.name) {
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
if (!result.success) {
// Keep rename mode open on error so user can correct the name
return
}
}
@@ -262,6 +266,14 @@ export function Editor() {
setEditedName('')
}, [])
// Focus input when entering rename mode
useEffect(() => {
if (isRenaming && nameInputRef.current) {
nameInputRef.current.select()
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock) {
handleStartRename()
@@ -272,13 +284,17 @@ export function Editor() {
/**
* Handles opening documentation link in a new secure tab.
*/
const handleOpenDocs = useCallback(() => {
const handleOpenDocs = () => {
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
if (docsLink) {
window.open(docsLink, '_blank', 'noopener,noreferrer')
}
}
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId)
@@ -291,6 +307,7 @@ export function Editor() {
}
}, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35
return (
@@ -311,7 +328,7 @@ export function Editor() {
)}
{isRenaming ? (
<input
ref={nameInputRefCallback}
ref={nameInputRef}
type='text'
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
@@ -382,21 +399,23 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)} */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleOpenDocs}
aria-label='Open documentation'
>
<BookOpen className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Open docs</p>
</Tooltip.Content>
</Tooltip.Root>
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleOpenDocs}
aria-label='Open documentation'
>
<BookOpen className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Open docs</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
@@ -476,7 +495,13 @@ export function Editor() {
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</>
)}
@@ -541,7 +566,13 @@ export function Editor() {
/>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>
@@ -550,7 +581,13 @@ export function Editor() {
{hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button
type='button'
onClick={handleToggleAdvancedMode}
@@ -563,7 +600,13 @@ export function Editor() {
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/>
</button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
@@ -587,7 +630,13 @@ export function Editor() {
/>
{index < advancedOnlySubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { v4 as uuidv4 } from 'uuid'
import { getApiUrl } from '@/lib/core/utils/urls'
import type { ExecutionResult, StreamingExecution } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution'
import { useTerminalConsoleStore } from '@/stores/terminal'
@@ -41,7 +42,8 @@ export async function executeWorkflowWithFullLogging(
isClientSession: true,
}
const response = await fetch(`/api/workflows/${activeWorkflowId}/execute`, {
const apiUrl = getApiUrl()
const response = await fetch(`${apiUrl}/api/workflows/${activeWorkflowId}/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -11,7 +11,7 @@ export const ApiTriggerBlock: BlockConfig = {
bestPractices: `
- Can run the workflow manually to test implementation when this is the trigger point.
- The input format determines variables accesssible in the following blocks. E.g. <api1.paramName>. You can set the value in the input format to test the workflow manually.
- In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://www.staging.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key.
- In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://api.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key.
`,
category: 'triggers',
hideFromToolbar: true,

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { getApiUrl } from '@/lib/core/utils/urls'
import type {
BlockCompletedData,
BlockErrorData,
@@ -151,7 +152,8 @@ export function useExecutionStream() {
currentExecutionRef.current = null
try {
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
const apiUrl = getApiUrl()
const response = await fetch(`${apiUrl}/api/workflows/${workflowId}/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -211,7 +213,8 @@ export function useExecutionStream() {
currentExecutionRef.current = null
try {
const response = await fetch(`/api/workflows/${workflowId}/execute-from-block`, {
const apiUrl = getApiUrl()
const response = await fetch(`${apiUrl}/api/workflows/${workflowId}/execute-from-block`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -266,9 +269,13 @@ export function useExecutionStream() {
const cancel = useCallback(() => {
const execution = currentExecutionRef.current
if (execution) {
fetch(`/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`, {
method: 'POST',
}).catch(() => {})
const apiUrl = getApiUrl()
fetch(
`${apiUrl}/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`,
{
method: 'POST',
}
).catch(() => {})
}
if (abortControllerRef.current) {

View File

@@ -1,37 +1,20 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('BillingAuthorization')
/**
* Check if a user is authorized to manage billing for a given reference ID
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
*
* This function also performs duplicate subscription validation for organizations:
* - Rejects if an organization already has an active subscription (prevents duplicates)
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string
): Promise<boolean> {
// User can always manage their own subscriptions (Pro upgrades, etc.)
// User can always manage their own subscriptions
if (referenceId === userId) {
return true
}
// For organizations: check for existing active subscriptions to prevent duplicates
if (await hasActiveSubscription(referenceId)) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
})
return false
}
// Check if referenceId is an organizationId the user has admin rights to
const members = await db
.select()

View File

@@ -25,11 +25,9 @@ export function useSubscriptionUpgrade() {
}
let currentSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try {
const listResult = await client.subscription.list()
allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
const activePersonalSub = listResult.data?.find(
(sub: any) => sub.status === 'active' && sub.referenceId === userId
)
currentSubscriptionId = activePersonalSub?.id
@@ -52,25 +50,6 @@ export function useSubscriptionUpgrade() {
)
if (existingOrg) {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
sub.status === 'active' &&
sub.referenceId === existingOrg.id &&
(sub.plan === 'team' || sub.plan === 'enterprise')
)
if (existingTeamSub) {
logger.warn('Organization already has an active team subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingTeamSub.id,
})
throw new Error(
'This organization already has an active team subscription. Please manage it from the billing settings.'
)
}
logger.info('Using existing organization for team plan upgrade', {
userId,
organizationId: existingOrg.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
@@ -26,22 +26,10 @@ export async function getHighestPrioritySubscription(userId: string) {
let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
// Verify orgs exist to filter out orphaned subscriptions
const existingOrgs = await db
.select({ id: organization.id })
.from(organization)
.where(inArray(organization.id, orgIds))
const validOrgIds = existingOrgs.map((o) => o.id)
if (validOrgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
)
}
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]

View File

@@ -25,28 +25,6 @@ const logger = createLogger('SubscriptionCore')
export { getHighestPrioritySubscription }
/**
* Check if a referenceId (user ID or org ID) has an active subscription
* Used for duplicate subscription prevention
*
* Fails closed: returns true on error to prevent duplicate creation
*/
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
.limit(1)
return !!activeSub
} catch (error) {
logger.error('Error checking active subscription', { error, referenceId })
// Fail closed: assume subscription exists to prevent duplicate creation
return true
}
}
/**
* Check if user is on Pro plan (direct or via organization)
*/

View File

@@ -11,7 +11,6 @@ export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess,
hasActiveSubscription,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
@@ -33,11 +32,6 @@ export {
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase'
export {
blockOrgMembers,
getOrgMemberIds,
unblockOrgMembers,
} from '@/lib/billing/organizations/membership'
export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'

View File

@@ -8,7 +8,6 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -160,16 +159,6 @@ export async function ensureOrganizationForTeamSubscription(
if (existingMembership.length > 0) {
const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') {
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(membership.organizationId)) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}
logger.info('User already owns/admins an org, using it', {
userId,
organizationId: membership.organizationId,

View File

@@ -15,86 +15,13 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { and, eq, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
const logger = createLogger('OrganizationMembership')
export type BillingBlockReason = 'payment_failed' | 'dispute'
/**
* Get all member user IDs for an organization
*/
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
return members.map((m) => m.userId)
}
/**
* Block all members of an organization for billing reasons
* Returns the number of members actually blocked
*
* Reason priority: dispute > payment_failed
* A payment_failed block won't overwrite an existing dispute block
*/
export async function blockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
const whereClause =
reason === 'payment_failed'
? and(
inArray(userStats.userId, memberIds),
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
)
: inArray(userStats.userId, memberIds)
const result = await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: reason })
.where(whereClause)
.returning({ userId: userStats.userId })
return result.length
}
/**
* Unblock all members of an organization blocked for a specific reason
* Only unblocks members blocked for the specified reason (not other reasons)
* Returns the number of members actually unblocked
*/
export async function unblockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
const result = await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
.returning({ userId: userStats.userId })
return result.length
}
export interface RestoreProResult {
restored: boolean
usageRestored: boolean

View File

@@ -1,9 +1,8 @@
import { db } from '@sim/db'
import { subscription, user, userStats } from '@sim/db/schema'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
const logger = createLogger('DisputeWebhooks')
@@ -58,34 +57,36 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
if (memberCount > 0) {
logger.warn('Blocked all org members due to dispute', {
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, owners[0].userId))
logger.warn('Blocked org owner due to dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
memberCount,
})
}
}
}
/**
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
*
* Status meanings:
* - 'won': Merchant won, customer's chargeback denied → unblock
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
* Handles charge.dispute.closed - unblocks user if dispute was won
*/
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
// Only unblock if we won or the warning was closed without a full dispute
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
if (!shouldUnblock) {
logger.info('Dispute resolved against us, user remains blocked', {
if (dispute.status !== 'won') {
logger.info('Dispute not won, user remains blocked', {
disputeId: dispute.id,
status: dispute.status,
})
@@ -97,7 +98,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
return
}
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
// Find and unblock user (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
@@ -108,17 +109,16 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
.where(eq(userStats.userId, users[0].id))
logger.info('Unblocked user after dispute resolved in our favor', {
logger.info('Unblocked user after winning dispute', {
disputeId: dispute.id,
userId: users[0].id,
status: dispute.status,
})
return
}
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
// Find and unblock org owner (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
@@ -127,13 +127,24 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await unblockOrgMembers(orgId, 'dispute')
logger.info('Unblocked all org members after dispute resolved in our favor', {
disputeId: dispute.id,
organizationId: orgId,
memberCount,
status: dispute.status,
})
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, owners[0].userId))
logger.info('Unblocked org owner after winning dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
})
}
}
}

View File

@@ -8,13 +8,12 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -503,7 +502,24 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
}
if (sub.plan === 'team' || sub.plan === 'enterprise') {
await unblockOrgMembers(sub.referenceId, 'payment_failed')
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
inArray(userStats.userId, memberIds),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
}
} else {
// Only unblock users blocked for payment_failed, not disputes
await db
@@ -600,26 +616,28 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) {
const sub = records[0]
if (sub.plan === 'team' || sub.plan === 'enterprise') {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(inArray(userStats.userId, memberIds))
}
logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId,
memberCount,
memberCount: members.length,
isOverageInvoice,
})
} else {
// Don't overwrite dispute blocks (dispute > payment_failed priority)
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
)
)
.where(eq(userStats.userId, sub.referenceId))
logger.info('Blocked user due to payment failure', {
userId: sub.referenceId,
isOverageInvoice,

View File

@@ -3,7 +3,6 @@ import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -53,37 +52,14 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
/**
* Cleanup organization when team/enterprise subscription is deleted.
* - Checks if other active subscriptions point to this org (skip deletion if so)
* - Restores member Pro subscriptions
* - Deletes the organization (only if no other active subs)
* - Deletes the organization
* - Syncs usage limits for former members (resets to free or Pro tier)
*/
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
restoredProCount: number
membersSynced: number
organizationDeleted: boolean
}> {
// Check if other active subscriptions still point to this org
// Note: The subscription being deleted is already marked as 'canceled' by better-auth
// before this handler runs, so we only find truly active ones
if (await hasActiveSubscription(organizationId)) {
logger.info('Skipping organization deletion - other active subscriptions exist', {
organizationId,
})
// Still sync limits for members since this subscription was deleted
const memberUserIds = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false }
}
// Get member userIds before deletion (needed for limit syncing after org deletion)
const memberUserIds = await db
.select({ userId: member.userId })
@@ -99,7 +75,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
return { restoredProCount, membersSynced: memberUserIds.length }
}
/**
@@ -196,14 +172,15 @@ export async function handleSubscriptionDeleted(subscription: {
referenceId: subscription.referenceId,
})
const { restoredProCount, membersSynced, organizationDeleted } =
await cleanupOrganizationSubscription(subscription.referenceId)
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
subscription.referenceId
)
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
restoredProCount,
organizationDeleted,
organizationDeleted: true,
membersSynced,
})
return
@@ -320,7 +297,7 @@ export async function handleSubscriptionDeleted(subscription: {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted
organizationDeleted = true
} else if (subscription.plan === 'pro') {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1

View File

@@ -5,6 +5,7 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { getApiUrl } from '@/lib/core/utils/urls'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CheckDeploymentStatusArgs {
@@ -103,11 +104,12 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
// API deployment details
const isApiDeployed = apiDeploy?.isDeployed || false
const apiUrl = getApiUrl()
const appUrl = typeof window !== 'undefined' ? window.location.origin : ''
const apiDetails: ApiDeploymentDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy?.deployedAt || null,
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
endpoint: isApiDeployed ? `${apiUrl}/api/workflows/${workflowId}/execute` : null,
apiKey: apiDeploy?.apiKey || null,
needsRedeployment: apiDeploy?.needsRedeployment === true,
}

View File

@@ -6,7 +6,7 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getApiUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -230,8 +230,8 @@ export class DeployApiClientTool extends BaseClientTool {
}
if (action === 'deploy') {
const appUrl = getBaseUrl()
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
const apiUrl = getApiUrl()
const apiEndpoint = `${apiUrl}/api/workflows/${workflowId}/execute`
const apiKeyPlaceholder = '$SIM_API_KEY'
const inputExample = getInputFormatExample(false)

View File

@@ -307,6 +307,7 @@ export const env = createEnv({
client: {
// Core Application URLs - Required for frontend functionality
NEXT_PUBLIC_APP_URL: z.string().url(), // Base URL of the application (e.g., https://www.sim.ai)
NEXT_PUBLIC_API_URL: z.string().url().optional(), // API URL for workflow executions (e.g., https://api.sim.ai)
// Client-side Services
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features
@@ -357,6 +358,7 @@ export const env = createEnv({
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL,
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME,

View File

@@ -54,3 +54,22 @@ export function getEmailDomain(): string {
return isProd ? 'sim.ai' : 'localhost:3000'
}
}
/**
* Returns the API URL for workflow executions.
* Uses NEXT_PUBLIC_API_URL if configured, otherwise falls back to NEXT_PUBLIC_APP_URL.
* @returns The API URL string (e.g., 'https://api.sim.ai' or 'https://example.com')
*/
export function getApiUrl(): string {
const apiUrl = getEnv('NEXT_PUBLIC_API_URL')
if (apiUrl) {
if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) {
return apiUrl
}
const protocol = isProd ? 'https://' : 'http://'
return `${protocol}${apiUrl}`
}
return getBaseUrl()
}

View File

@@ -33,7 +33,6 @@ import type {
WorkflowExecutionSnapshot,
WorkflowState,
} from '@/lib/logs/types'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
export interface ToolCall {
name: string
@@ -504,7 +503,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
}
try {
// Get the workflow record to get workspace and fallback userId
// Get the workflow record to get the userId
const [workflowRecord] = await db
.select()
.from(workflow)
@@ -516,12 +515,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
return
}
let billingUserId: string | null = null
if (workflowRecord.workspaceId) {
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
}
const userId = billingUserId || workflowRecord.userId
const userId = workflowRecord.userId
const costToStore = costSummary.totalCost
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))

View File

@@ -17,7 +17,7 @@ from simstudio import SimStudioClient
# Initialize the client
client = SimStudioClient(
api_key=os.getenv("SIM_API_KEY", "your-api-key-here"),
base_url="https://sim.ai" # optional, defaults to https://sim.ai
base_url="https://api.sim.ai" # optional, defaults to https://api.sim.ai
)
# Execute a workflow
@@ -35,11 +35,11 @@ except Exception as error:
#### Constructor
```python
SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
SimStudioClient(api_key: str, base_url: str = "https://api.sim.ai")
```
- `api_key` (str): Your Sim API key
- `base_url` (str, optional): Base URL for the Sim API (defaults to `https://sim.ai`)
- `base_url` (str, optional): Base URL for the Sim API (defaults to `https://api.sim.ai`)
#### Methods
@@ -364,7 +364,7 @@ from simstudio import SimStudioClient
# Using environment variables
client = SimStudioClient(
api_key=os.getenv("SIM_API_KEY"),
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
base_url=os.getenv("SIM_BASE_URL", "https://api.sim.ai")
)
```

View File

@@ -87,10 +87,10 @@ class SimStudioClient:
Args:
api_key: Your Sim API key
base_url: Base URL for the Sim API (defaults to https://sim.ai)
base_url: Base URL for the Sim API (defaults to https://api.sim.ai)
"""
def __init__(self, api_key: str, base_url: str = "https://sim.ai"):
def __init__(self, api_key: str, base_url: str = "https://api.sim.ai"):
self.api_key = api_key
self.base_url = base_url.rstrip('/')
self._session = requests.Session()

View File

@@ -18,7 +18,7 @@ def test_simstudio_client_default_base_url():
"""Test SimStudioClient with default base URL."""
client = SimStudioClient(api_key="test-api-key")
assert client.api_key == "test-api-key"
assert client.base_url == "https://sim.ai"
assert client.base_url == "https://api.sim.ai"
def test_set_api_key():
@@ -51,7 +51,7 @@ def test_validate_workflow_returns_false_on_error(mock_get):
result = client.validate_workflow("test-workflow-id")
assert result is False
mock_get.assert_called_once_with("https://sim.ai/api/workflows/test-workflow-id/status")
mock_get.assert_called_once_with("https://api.sim.ai/api/workflows/test-workflow-id/status")
def test_simstudio_error():

View File

@@ -20,7 +20,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
// Initialize the client
const client = new SimStudioClient({
apiKey: 'your-api-key-here',
baseUrl: 'https://sim.ai' // optional, defaults to https://sim.ai
baseUrl: 'https://api.sim.ai' // optional, defaults to https://api.sim.ai
});
// Execute a workflow
@@ -43,7 +43,7 @@ new SimStudioClient(config: SimStudioConfig)
```
- `config.apiKey` (string): Your Sim API key
- `config.baseUrl` (string, optional): Base URL for the Sim API (defaults to `https://sim.ai`)
- `config.baseUrl` (string, optional): Base URL for the Sim API (defaults to `https://api.sim.ai`)
#### Methods

View File

@@ -4,7 +4,7 @@ import { SimStudioClient, SimStudioError } from '../src/index'
async function basicExample() {
const client = new SimStudioClient({
apiKey: process.env.SIM_API_KEY!,
baseUrl: 'https://sim.ai',
baseUrl: 'https://api.sim.ai',
})
try {

View File

@@ -113,7 +113,7 @@ export class SimStudioClient {
constructor(config: SimStudioConfig) {
this.apiKey = config.apiKey
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai')
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://api.sim.ai')
}
/**