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
54 changed files with 189 additions and 364 deletions

View File

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

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**Beispielanfrage:** **Beispielanfrage:**
```bash ```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:** **Beispielantwort:**

View File

@@ -647,7 +647,7 @@ def stream_workflow():
def generate(): def generate():
response = requests.post( response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute', 'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY') '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 // IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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 ```bash
curl -H "x-api-key: YOUR_API_KEY" \ 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. You can generate API keys from your user settings in the Sim dashboard.
@@ -513,7 +513,7 @@ async function pollLogs() {
} }
const response = await fetch( const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`, `https://api.sim.ai/api/v1/logs?${params}`,
{ {
headers: { headers: {
'x-api-key': 'YOUR_API_KEY' 'x-api-key': 'YOUR_API_KEY'

View File

@@ -160,7 +160,7 @@ GET /api/users/me/usage-limits
**Example Request:** **Example Request:**
```bash ```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:** **Example Response:**

View File

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

View File

@@ -655,7 +655,7 @@ def stream_workflow():
def generate(): def generate():
response = requests.post( response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute', 'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY') '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 // IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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 ```bash
curl -H "x-api-key: YOUR_API_KEY" \ 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. 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( const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`, `https://api.sim.ai/api/v1/logs?${params}`,
{ {
headers: { headers: {
'x-api-key': 'YOUR_API_KEY' 'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**Solicitud de ejemplo:** **Solicitud de ejemplo:**
```bash ```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:** **Respuesta de ejemplo:**

View File

@@ -656,7 +656,7 @@ def stream_workflow():
def generate(): def generate():
response = requests.post( response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute', 'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY') '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 // IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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 ```bash
curl -H "x-api-key: YOUR_API_KEY" \ 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. 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( const response = await fetch(
`https://sim.ai/api/v1/logs?${params}`, `https://api.sim.ai/api/v1/logs?${params}`,
{ {
headers: { headers: {
'x-api-key': 'YOUR_API_KEY' 'x-api-key': 'YOUR_API_KEY'

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**Exemple de requête :** **Exemple de requête :**
```bash ```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 :** **Exemple de réponse :**

View File

@@ -656,7 +656,7 @@ def stream_workflow():
def generate(): def generate():
response = requests.post( response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute', 'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY') '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 // IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

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

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**リクエスト例:** **リクエスト例:**
```bash ```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(): def generate():
response = requests.post( response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute', 'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY') '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 // IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

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

View File

@@ -142,7 +142,7 @@ GET /api/users/me/usage-limits
**请求示例:** **请求示例:**
```bash ```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(): def generate():
response = requests.post( response = requests.post(
'https://sim.ai/api/workflows/WORKFLOW_ID/execute', 'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': os.getenv('SIM_API_KEY') '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 // IMPORTANT: Make this API call from your backend server, not the browser
// Never expose your API key in client-side code // 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -20,7 +20,6 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing' import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' 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}`, { logger.info(`Organization invitation ${status}`, {
organizationId, organizationId,
invitationId, invitationId,

View File

@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI') 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 await db
.update(subscription) .update(subscription)
.set({ referenceId: organizationId }) .set({ referenceId: organizationId })

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import {
ModalTabsList, ModalTabsList,
ModalTabsTrigger, ModalTabsTrigger,
} from '@/components/emcn' } 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 { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' 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' 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 return null
} }
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` const endpoint = `${getApiUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder() const placeholderKey = getApiHeaderPlaceholder()

View File

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

View File

@@ -11,7 +11,7 @@ export const ApiTriggerBlock: BlockConfig = {
bestPractices: ` bestPractices: `
- Can run the workflow manually to test implementation when this is the trigger point. - 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. - 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', category: 'triggers',
hideFromToolbar: true, hideFromToolbar: true,

View File

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

View File

@@ -1,37 +1,20 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import * as schema from '@sim/db/schema' import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' 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 * 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) * 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( export async function authorizeSubscriptionReference(
userId: string, userId: string,
referenceId: string referenceId: string
): Promise<boolean> { ): Promise<boolean> {
// User can always manage their own subscriptions (Pro upgrades, etc.) // User can always manage their own subscriptions
if (referenceId === userId) { if (referenceId === userId) {
return true 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 // Check if referenceId is an organizationId the user has admin rights to
const members = await db const members = await db
.select() .select()

View File

@@ -25,11 +25,9 @@ export function useSubscriptionUpgrade() {
} }
let currentSubscriptionId: string | undefined let currentSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try { try {
const listResult = await client.subscription.list() const listResult = await client.subscription.list()
allSubscriptions = listResult.data || [] const activePersonalSub = listResult.data?.find(
const activePersonalSub = allSubscriptions.find(
(sub: any) => sub.status === 'active' && sub.referenceId === userId (sub: any) => sub.status === 'active' && sub.referenceId === userId
) )
currentSubscriptionId = activePersonalSub?.id currentSubscriptionId = activePersonalSub?.id
@@ -52,25 +50,6 @@ export function useSubscriptionUpgrade() {
) )
if (existingOrg) { 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', { logger.info('Using existing organization for team plan upgrade', {
userId, userId,
organizationId: existingOrg.id, organizationId: existingOrg.id,

View File

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

View File

@@ -25,28 +25,6 @@ const logger = createLogger('SubscriptionCore')
export { getHighestPrioritySubscription } 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) * Check if user is on Pro plan (direct or via organization)
*/ */

View File

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

View File

@@ -8,7 +8,6 @@ import {
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing' import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -160,16 +159,6 @@ export async function ensureOrganizationForTeamSubscription(
if (existingMembership.length > 0) { if (existingMembership.length > 0) {
const membership = existingMembership[0] const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') { 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', { logger.info('User already owns/admins an org, using it', {
userId, userId,
organizationId: membership.organizationId, organizationId: membership.organizationId,

View File

@@ -15,86 +15,13 @@ import {
userStats, userStats,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' 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 { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
const logger = createLogger('OrganizationMembership') 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 { export interface RestoreProResult {
restored: boolean restored: boolean
usageRestored: boolean usageRestored: boolean

View File

@@ -1,9 +1,8 @@
import { db } from '@sim/db' 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 { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe' import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
const logger = createLogger('DisputeWebhooks') const logger = createLogger('DisputeWebhooks')
@@ -58,34 +57,36 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
if (subs.length > 0) { if (subs.length > 0) {
const orgId = subs[0].referenceId const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
if (memberCount > 0) { const owners = await db
logger.warn('Blocked all org members due to dispute', { .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, disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId, organizationId: orgId,
memberCount,
}) })
} }
} }
} }
/** /**
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed * Handles charge.dispute.closed - unblocks user if dispute was won
*
* 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)
*/ */
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> { export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute const dispute = event.data.object as Stripe.Dispute
// Only unblock if we won or the warning was closed without a full dispute if (dispute.status !== 'won') {
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed' logger.info('Dispute not won, user remains blocked', {
if (!shouldUnblock) {
logger.info('Dispute resolved against us, user remains blocked', {
disputeId: dispute.id, disputeId: dispute.id,
status: dispute.status, status: dispute.status,
}) })
@@ -97,7 +98,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
return 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 const users = await db
.select({ id: user.id }) .select({ id: user.id })
.from(user) .from(user)
@@ -108,17 +109,16 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
await db await db
.update(userStats) .update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null }) .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, disputeId: dispute.id,
userId: users[0].id, userId: users[0].id,
status: dispute.status,
}) })
return return
} }
// Find and unblock all org members (Team/Enterprise) - consistent with payment success // Find and unblock org owner (Team/Enterprise)
const subs = await db const subs = await db
.select({ referenceId: subscription.referenceId }) .select({ referenceId: subscription.referenceId })
.from(subscription) .from(subscription)
@@ -127,13 +127,24 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
if (subs.length > 0) { if (subs.length > 0) {
const orgId = subs[0].referenceId const orgId = subs[0].referenceId
const memberCount = await unblockOrgMembers(orgId, 'dispute')
logger.info('Unblocked all org members after dispute resolved in our favor', { const owners = await db
disputeId: dispute.id, .select({ userId: member.userId })
organizationId: orgId, .from(member)
memberCount, .where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
status: dispute.status, .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, userStats,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' 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 type Stripe from 'stripe'
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance' import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' 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') { 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 { } else {
// Only unblock users blocked for payment_failed, not disputes // Only unblock users blocked for payment_failed, not disputes
await db await db
@@ -600,26 +616,28 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) { if (records.length > 0) {
const sub = records[0] const sub = records[0]
if (sub.plan === 'team' || sub.plan === 'enterprise') { 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', { logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId, organizationId: sub.referenceId,
memberCount, memberCount: members.length,
isOverageInvoice, isOverageInvoice,
}) })
} else { } else {
// Don't overwrite dispute blocks (dispute > payment_failed priority)
await db await db
.update(userStats) .update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where( .where(eq(userStats.userId, sub.referenceId))
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
)
)
logger.info('Blocked user due to payment failure', { logger.info('Blocked user due to payment failure', {
userId: sub.referenceId, userId: sub.referenceId,
isOverageInvoice, isOverageInvoice,

View File

@@ -3,7 +3,6 @@ import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, ne } from 'drizzle-orm' import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client' 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. * 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 * - 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) * - Syncs usage limits for former members (resets to free or Pro tier)
*/ */
async function cleanupOrganizationSubscription(organizationId: string): Promise<{ async function cleanupOrganizationSubscription(organizationId: string): Promise<{
restoredProCount: number restoredProCount: number
membersSynced: 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) // Get member userIds before deletion (needed for limit syncing after org deletion)
const memberUserIds = await db const memberUserIds = await db
.select({ userId: member.userId }) .select({ userId: member.userId })
@@ -99,7 +75,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
await syncUsageLimitsFromSubscription(m.userId) 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, referenceId: subscription.referenceId,
}) })
const { restoredProCount, membersSynced, organizationDeleted } = const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
await cleanupOrganizationSubscription(subscription.referenceId) subscription.referenceId
)
logger.info('Successfully processed enterprise subscription cancellation', { logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id, subscriptionId: subscription.id,
stripeSubscriptionId, stripeSubscriptionId,
restoredProCount, restoredProCount,
organizationDeleted, organizationDeleted: true,
membersSynced, membersSynced,
}) })
return return
@@ -320,7 +297,7 @@ export async function handleSubscriptionDeleted(subscription: {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted organizationDeleted = true
} else if (subscription.plan === 'pro') { } else if (subscription.plan === 'pro') {
await syncUsageLimitsFromSubscription(subscription.referenceId) await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1 membersSynced = 1

View File

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

View File

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

View File

@@ -307,6 +307,7 @@ export const env = createEnv({
client: { client: {
// Core Application URLs - Required for frontend functionality // 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_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 // Client-side Services
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features 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: { experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 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_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL, NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL,
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME, 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' 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, WorkflowExecutionSnapshot,
WorkflowState, WorkflowState,
} from '@/lib/logs/types' } from '@/lib/logs/types'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
export interface ToolCall { export interface ToolCall {
name: string name: string
@@ -504,7 +503,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
} }
try { try {
// Get the workflow record to get workspace and fallback userId // Get the workflow record to get the userId
const [workflowRecord] = await db const [workflowRecord] = await db
.select() .select()
.from(workflow) .from(workflow)
@@ -516,12 +515,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
return return
} }
let billingUserId: string | null = null const userId = workflowRecord.userId
if (workflowRecord.workspaceId) {
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
}
const userId = billingUserId || workflowRecord.userId
const costToStore = costSummary.totalCost const costToStore = costSummary.totalCost
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId)) 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 # Initialize the client
client = SimStudioClient( client = SimStudioClient(
api_key=os.getenv("SIM_API_KEY", "your-api-key-here"), 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 # Execute a workflow
@@ -35,11 +35,11 @@ except Exception as error:
#### Constructor #### Constructor
```python ```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 - `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 #### Methods
@@ -364,7 +364,7 @@ from simstudio import SimStudioClient
# Using environment variables # Using environment variables
client = SimStudioClient( client = SimStudioClient(
api_key=os.getenv("SIM_API_KEY"), 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: Args:
api_key: Your Sim API key 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.api_key = api_key
self.base_url = base_url.rstrip('/') self.base_url = base_url.rstrip('/')
self._session = requests.Session() self._session = requests.Session()

View File

@@ -18,7 +18,7 @@ def test_simstudio_client_default_base_url():
"""Test SimStudioClient with default base URL.""" """Test SimStudioClient with default base URL."""
client = SimStudioClient(api_key="test-api-key") client = SimStudioClient(api_key="test-api-key")
assert client.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(): 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") result = client.validate_workflow("test-workflow-id")
assert result is False 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(): def test_simstudio_error():

View File

@@ -20,7 +20,7 @@ import { SimStudioClient } from 'simstudio-ts-sdk';
// Initialize the client // Initialize the client
const client = new SimStudioClient({ const client = new SimStudioClient({
apiKey: 'your-api-key-here', 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 // Execute a workflow
@@ -43,7 +43,7 @@ new SimStudioClient(config: SimStudioConfig)
``` ```
- `config.apiKey` (string): Your Sim API key - `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 #### Methods

View File

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

View File

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