mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-03 11:14:58 -05:00
Compare commits
5 Commits
feat/api-e
...
fix/billin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011401a833 | ||
|
|
9c5fbbedde | ||
|
|
334faa17a7 | ||
|
|
5ba322f8f2 | ||
|
|
f11e1cf5de |
@@ -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://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
|
https://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://api.sim.ai/api/v1/logs?${params}`,
|
`https://sim.ai/api/v1/logs?${params}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': 'YOUR_API_KEY'
|
'x-api-key': 'YOUR_API_KEY'
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/users/me/usage-limits
|
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
|
||||||
```
|
```
|
||||||
|
|
||||||
**Beispielantwort:**
|
**Beispielantwort:**
|
||||||
|
|||||||
@@ -647,7 +647,7 @@ def stream_workflow():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
|
'https://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')
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
|
https://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://api.sim.ai/api/v1/logs?${params}`,
|
`https://sim.ai/api/v1/logs?${params}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': 'YOUR_API_KEY'
|
'x-api-key': 'YOUR_API_KEY'
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/users/me/usage-limits
|
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example Response:**
|
**Example Response:**
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/form/your-identifier \
|
curl -X POST https://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://api.sim.ai/api/form/your-identifier \
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="TypeScript">
|
<Tab value="TypeScript">
|
||||||
```typescript
|
```typescript
|
||||||
const response = await fetch('https://api.sim.ai/api/form/your-identifier', {
|
const response = await fetch('https://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://api.sim.ai/api/form/your-identifier \
|
curl -X POST https://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://api.sim.ai/api/form/your-identifier \
|
curl -X POST https://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" } }'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -655,7 +655,7 @@ def stream_workflow():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
|
'https://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')
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
|
https://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://api.sim.ai/api/v1/logs?${params}`,
|
`https://sim.ai/api/v1/logs?${params}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': 'YOUR_API_KEY'
|
'x-api-key': 'YOUR_API_KEY'
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/users/me/usage-limits
|
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
|
||||||
```
|
```
|
||||||
|
|
||||||
**Respuesta de ejemplo:**
|
**Respuesta de ejemplo:**
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ def stream_workflow():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
|
'https://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')
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
|
https://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://api.sim.ai/api/v1/logs?${params}`,
|
`https://sim.ai/api/v1/logs?${params}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': 'YOUR_API_KEY'
|
'x-api-key': 'YOUR_API_KEY'
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/users/me/usage-limits
|
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exemple de réponse :**
|
**Exemple de réponse :**
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ def stream_workflow():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
|
'https://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')
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Simは、ワークフローの実行ログを照会したり、ワークフロ
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "x-api-key: YOUR_API_KEY" \
|
curl -H "x-api-key: YOUR_API_KEY" \
|
||||||
https://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
|
https://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://api.sim.ai/api/v1/logs?${params}`,
|
`https://sim.ai/api/v1/logs?${params}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': 'YOUR_API_KEY'
|
'x-api-key': 'YOUR_API_KEY'
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/users/me/usage-limits
|
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
|
||||||
```
|
```
|
||||||
|
|
||||||
**レスポンス例:**
|
**レスポンス例:**
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ def stream_workflow():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
|
'https://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')
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/v1/logs?workspaceId=YOUR_WORKSPACE_ID
|
https://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://api.sim.ai/api/v1/logs?${params}`,
|
`https://sim.ai/api/v1/logs?${params}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': 'YOUR_API_KEY'
|
'x-api-key': 'YOUR_API_KEY'
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/users/me/usage-limits
|
curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" https://sim.ai/api/users/me/usage-limits
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应示例:**
|
**响应示例:**
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ def stream_workflow():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.sim.ai/api/workflows/WORKFLOW_ID/execute',
|
'https://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')
|
||||||
|
|||||||
@@ -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://api.sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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'
|
||||||
@@ -501,6 +502,18 @@ 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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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')
|
||||||
|
|
||||||
@@ -88,6 +89,14 @@ 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 })
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
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'
|
||||||
@@ -8,6 +6,7 @@ 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'
|
||||||
@@ -75,12 +74,31 @@ 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()
|
||||||
|
|
||||||
const [workflowRecord] = await db
|
// Run preprocessing checks (billing, rate limits, usage limits)
|
||||||
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
const preprocessResult = await preprocessExecution({
|
||||||
.from(workflowTable)
|
workflowId,
|
||||||
.where(eq(workflowTable.id, workflowId))
|
userId,
|
||||||
.limit(1)
|
triggerType: 'manual',
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
@@ -92,6 +110,7 @@ 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)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ModalTabsList,
|
ModalTabsList,
|
||||||
ModalTabsTrigger,
|
ModalTabsTrigger,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { getApiUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } 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 = `${getApiUrl()}/api/workflows/${workflowId}/execute`
|
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
|
||||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||||
const placeholderKey = getApiHeaderPlaceholder()
|
const placeholderKey = getApiHeaderPlaceholder()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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'
|
||||||
@@ -42,8 +41,7 @@ export async function executeWorkflowWithFullLogging(
|
|||||||
isClientSession: true,
|
isClientSession: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = getApiUrl()
|
const response = await fetch(`/api/workflows/${activeWorkflowId}/execute`, {
|
||||||
const response = await fetch(`${apiUrl}/api/workflows/${activeWorkflowId}/execute`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -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://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.
|
- 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.
|
||||||
`,
|
`,
|
||||||
category: 'triggers',
|
category: 'triggers',
|
||||||
hideFromToolbar: true,
|
hideFromToolbar: true,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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,
|
||||||
@@ -152,8 +151,7 @@ export function useExecutionStream() {
|
|||||||
currentExecutionRef.current = null
|
currentExecutionRef.current = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = getApiUrl()
|
const response = await fetch(`/api/workflows/${workflowId}/execute`, {
|
||||||
const response = await fetch(`${apiUrl}/api/workflows/${workflowId}/execute`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -213,8 +211,7 @@ export function useExecutionStream() {
|
|||||||
currentExecutionRef.current = null
|
currentExecutionRef.current = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = getApiUrl()
|
const response = await fetch(`/api/workflows/${workflowId}/execute-from-block`, {
|
||||||
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',
|
||||||
@@ -269,13 +266,9 @@ export function useExecutionStream() {
|
|||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
const execution = currentExecutionRef.current
|
const execution = currentExecutionRef.current
|
||||||
if (execution) {
|
if (execution) {
|
||||||
const apiUrl = getApiUrl()
|
fetch(`/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`, {
|
||||||
fetch(
|
method: 'POST',
|
||||||
`${apiUrl}/api/workflows/${execution.workflowId}/executions/${execution.executionId}/cancel`,
|
}).catch(() => {})
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
}
|
|
||||||
).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
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
|
// User can always manage their own subscriptions (Pro upgrades, etc.)
|
||||||
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()
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ 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()
|
||||||
const activePersonalSub = listResult.data?.find(
|
allSubscriptions = listResult.data || []
|
||||||
|
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
|
||||||
@@ -50,6 +52,25 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { member, subscription } from '@sim/db/schema'
|
import { member, organization, 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,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) {
|
|||||||
|
|
||||||
let orgSubs: typeof personalSubs = []
|
let orgSubs: typeof personalSubs = []
|
||||||
if (orgIds.length > 0) {
|
if (orgIds.length > 0) {
|
||||||
orgSubs = await db
|
// Verify orgs exist to filter out orphaned subscriptions
|
||||||
.select()
|
const existingOrgs = await db
|
||||||
.from(subscription)
|
.select({ id: organization.id })
|
||||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
.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'))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSubs = [...personalSubs, ...orgSubs]
|
const allSubs = [...personalSubs, ...orgSubs]
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ 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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
getHighestPrioritySubscription as getActiveSubscription,
|
getHighestPrioritySubscription as getActiveSubscription,
|
||||||
getUserSubscriptionState as getSubscriptionState,
|
getUserSubscriptionState as getSubscriptionState,
|
||||||
hasAccessControlAccess,
|
hasAccessControlAccess,
|
||||||
|
hasActiveSubscription,
|
||||||
hasCredentialSetsAccess,
|
hasCredentialSetsAccess,
|
||||||
hasSSOAccess,
|
hasSSOAccess,
|
||||||
isEnterpriseOrgAdminOrOwner,
|
isEnterpriseOrgAdminOrOwner,
|
||||||
@@ -32,6 +33,11 @@ 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'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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'
|
||||||
|
|
||||||
@@ -159,6 +160,16 @@ 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,
|
||||||
|
|||||||
@@ -15,13 +15,86 @@ import {
|
|||||||
userStats,
|
userStats,
|
||||||
} from '@sim/db/schema'
|
} from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, sql } from 'drizzle-orm'
|
import { and, eq, inArray, isNull, ne, or, 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
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
import { 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')
|
||||||
@@ -57,36 +58,34 @@ 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')
|
||||||
|
|
||||||
const owners = await db
|
if (memberCount > 0) {
|
||||||
.select({ userId: member.userId })
|
logger.warn('Blocked all org members due to dispute', {
|
||||||
.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
|
* 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)
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
|
||||||
if (dispute.status !== 'won') {
|
// Only unblock if we won or the warning was closed without a full dispute
|
||||||
logger.info('Dispute not won, user remains blocked', {
|
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
|
||||||
|
|
||||||
|
if (!shouldUnblock) {
|
||||||
|
logger.info('Dispute resolved against us, user remains blocked', {
|
||||||
disputeId: dispute.id,
|
disputeId: dispute.id,
|
||||||
status: dispute.status,
|
status: dispute.status,
|
||||||
})
|
})
|
||||||
@@ -98,7 +97,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and unblock user (Pro plans)
|
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({ id: user.id })
|
.select({ id: user.id })
|
||||||
.from(user)
|
.from(user)
|
||||||
@@ -109,16 +108,17 @@ 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(eq(userStats.userId, users[0].id))
|
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
|
||||||
|
|
||||||
logger.info('Unblocked user after winning dispute', {
|
logger.info('Unblocked user after dispute resolved in our favor', {
|
||||||
disputeId: dispute.id,
|
disputeId: dispute.id,
|
||||||
userId: users[0].id,
|
userId: users[0].id,
|
||||||
|
status: dispute.status,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and unblock org owner (Team/Enterprise)
|
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
|
||||||
const subs = await db
|
const subs = await db
|
||||||
.select({ referenceId: subscription.referenceId })
|
.select({ referenceId: subscription.referenceId })
|
||||||
.from(subscription)
|
.from(subscription)
|
||||||
@@ -127,24 +127,13 @@ 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')
|
||||||
|
|
||||||
const owners = await db
|
logger.info('Unblocked all org members after dispute resolved in our favor', {
|
||||||
.select({ userId: member.userId })
|
disputeId: dispute.id,
|
||||||
.from(member)
|
organizationId: orgId,
|
||||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
memberCount,
|
||||||
.limit(1)
|
status: dispute.status,
|
||||||
|
})
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,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 } from 'drizzle-orm'
|
import { and, eq, inArray, isNull, ne, or } 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'
|
||||||
@@ -502,24 +503,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||||
const members = await db
|
await unblockOrgMembers(sub.referenceId, 'payment_failed')
|
||||||
.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
|
||||||
@@ -616,28 +600,26 @@ 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 members = await db
|
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
|
||||||
.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: members.length,
|
memberCount,
|
||||||
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(eq(userStats.userId, sub.referenceId))
|
.where(
|
||||||
|
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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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'
|
||||||
@@ -52,14 +53,37 @@ 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
|
* - Deletes the organization (only if no other active subs)
|
||||||
* - 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 })
|
||||||
@@ -75,7 +99,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
|
|||||||
await syncUsageLimitsFromSubscription(m.userId)
|
await syncUsageLimitsFromSubscription(m.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { restoredProCount, membersSynced: memberUserIds.length }
|
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,15 +196,14 @@ export async function handleSubscriptionDeleted(subscription: {
|
|||||||
referenceId: subscription.referenceId,
|
referenceId: subscription.referenceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
const { restoredProCount, membersSynced, organizationDeleted } =
|
||||||
subscription.referenceId
|
await cleanupOrganizationSubscription(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: true,
|
organizationDeleted,
|
||||||
membersSynced,
|
membersSynced,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -297,7 +320,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 = true
|
organizationDeleted = cleanup.organizationDeleted
|
||||||
} else if (subscription.plan === 'pro') {
|
} else if (subscription.plan === 'pro') {
|
||||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||||
membersSynced = 1
|
membersSynced = 1
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 {
|
||||||
@@ -104,12 +103,11 @@ 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 ? `${apiUrl}/api/workflows/${workflowId}/execute` : null,
|
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
|
||||||
apiKey: apiDeploy?.apiKey || null,
|
apiKey: apiDeploy?.apiKey || null,
|
||||||
needsRedeployment: apiDeploy?.needsRedeployment === true,
|
needsRedeployment: apiDeploy?.needsRedeployment === true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { getApiUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } 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 apiUrl = getApiUrl()
|
const appUrl = getBaseUrl()
|
||||||
const apiEndpoint = `${apiUrl}/api/workflows/${workflowId}/execute`
|
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
|
||||||
const apiKeyPlaceholder = '$SIM_API_KEY'
|
const apiKeyPlaceholder = '$SIM_API_KEY'
|
||||||
|
|
||||||
const inputExample = getInputFormatExample(false)
|
const inputExample = getInputFormatExample(false)
|
||||||
|
|||||||
@@ -307,7 +307,6 @@ 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
|
||||||
@@ -358,7 +357,6 @@ 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,
|
||||||
|
|||||||
@@ -54,22 +54,3 @@ 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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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
|
||||||
@@ -503,7 +504,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the workflow record to get the userId
|
// Get the workflow record to get workspace and fallback userId
|
||||||
const [workflowRecord] = await db
|
const [workflowRecord] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
@@ -515,7 +516,12 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = workflowRecord.userId
|
let billingUserId: string | null = null
|
||||||
|
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))
|
||||||
|
|||||||
@@ -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://api.sim.ai" # optional, defaults to https://api.sim.ai
|
base_url="https://sim.ai" # optional, defaults to https://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://api.sim.ai")
|
SimStudioClient(api_key: str, base_url: str = "https://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://api.sim.ai`)
|
- `base_url` (str, optional): Base URL for the Sim API (defaults to `https://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://api.sim.ai")
|
base_url=os.getenv("SIM_BASE_URL", "https://sim.ai")
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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://api.sim.ai)
|
base_url: Base URL for the Sim API (defaults to https://sim.ai)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, api_key: str, base_url: str = "https://api.sim.ai"):
|
def __init__(self, api_key: str, base_url: str = "https://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()
|
||||||
|
|||||||
@@ -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://api.sim.ai"
|
assert client.base_url == "https://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://api.sim.ai/api/workflows/test-workflow-id/status")
|
mock_get.assert_called_once_with("https://sim.ai/api/workflows/test-workflow-id/status")
|
||||||
|
|
||||||
|
|
||||||
def test_simstudio_error():
|
def test_simstudio_error():
|
||||||
|
|||||||
@@ -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://api.sim.ai' // optional, defaults to https://api.sim.ai
|
baseUrl: 'https://sim.ai' // optional, defaults to https://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://api.sim.ai`)
|
- `config.baseUrl` (string, optional): Base URL for the Sim API (defaults to `https://sim.ai`)
|
||||||
|
|
||||||
#### Methods
|
#### Methods
|
||||||
|
|
||||||
|
|||||||
@@ -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://api.sim.ai',
|
baseUrl: 'https://sim.ai',
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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://api.sim.ai')
|
this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user