From 65e861822c62c2c1e28693176d06460668d3f1d2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 1 Sep 2025 21:19:12 -0700 Subject: [PATCH 1/3] fix(ui): dark mode styling for switch, trigger modal UI, signup/login improvements with auto-submit for OTP (#1214) * fix(ui): fix dark mode styling for switch, fix trigger modal UI * auto-submit OTP when characters are entered * trim leading and trailing whitespace from name on signup, throw more informative error messages on reset pass --- apps/sim/app/(auth)/login/login-form.tsx | 48 +++++++++++++++---- .../app/(auth)/signup/signup-form.test.tsx | 21 ++++---- apps/sim/app/(auth)/signup/signup-form.tsx | 29 +++-------- .../sim/app/(auth)/verify/use-verification.ts | 14 ++++-- .../credential-selector.tsx | 5 +- .../components/trigger-config-section.tsx | 12 +++-- apps/sim/components/ui/switch.tsx | 2 +- 7 files changed, 82 insertions(+), 49 deletions(-) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 16298b4204..ffd6c3515b 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -304,6 +304,15 @@ export default function LoginPage({ return } + const emailValidation = quickValidateEmail(forgotPasswordEmail.trim().toLowerCase()) + if (!emailValidation.isValid) { + setResetStatus({ + type: 'error', + message: 'Please enter a valid email address', + }) + return + } + try { setIsSubmittingReset(true) setResetStatus({ type: null, message: '' }) @@ -321,7 +330,23 @@ export default function LoginPage({ if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.message || 'Failed to request password reset') + let errorMessage = errorData.message || 'Failed to request password reset' + + if ( + errorMessage.includes('Invalid body parameters') || + errorMessage.includes('invalid email') + ) { + errorMessage = 'Please enter a valid email address' + } else if (errorMessage.includes('Email is required')) { + errorMessage = 'Please enter your email address' + } else if ( + errorMessage.includes('user not found') || + errorMessage.includes('User not found') + ) { + errorMessage = 'No account found with this email address' + } + + throw new Error(errorMessage) } setResetStatus({ @@ -497,7 +522,8 @@ export default function LoginPage({ Reset Password - Enter your email address and we'll send you a link to reset your password. + Enter your email address and we'll send you a link to reset your password if your + account exists.
@@ -512,14 +538,20 @@ export default function LoginPage({ placeholder='Enter your email' required type='email' - className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20' + className={cn( + 'border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20', + resetStatus.type === 'error' && 'border-red-500 focus-visible:ring-red-500' + )} /> + {resetStatus.type === 'error' && ( +
+

{resetStatus.message}

+
+ )}
- {resetStatus.type && ( -
- {resetStatus.message} + {resetStatus.type === 'success' && ( +
+

{resetStatus.message}

)} - - - + + + e.stopPropagation()} + > {availableOptions.length === 0 ? 'No options available. Please select credentials first.' diff --git a/apps/sim/components/ui/switch.tsx b/apps/sim/components/ui/switch.tsx index 7ea9f2a74d..f07fc75f05 100644 --- a/apps/sim/components/ui/switch.tsx +++ b/apps/sim/components/ui/switch.tsx @@ -10,7 +10,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( Date: Mon, 1 Sep 2025 21:22:23 -0700 Subject: [PATCH 2/3] improvement(hygiene): refactored routes to be more restful, reduced code surface area and removed redundant code (#1217) * improvement(invitations): consolidate invite-error and invite pages, made API endpoints more restful and reduced code surface area for invitations by 50% * refactored logs API routes * refactor rate limit api route, consolidate usage check api endpoint * refactored chat page and invitations page * consolidate ollama and openrouter stores to just providers store * removed unused route * removed legacy envvar methods * remove dead, legacy routes for invitations PUT and workflow SYNC * improvement(copilot): improve context inputs and fix some bugs (#1216) * Add logs v1 * Update * Updates * Updates * Fixes * Fix current workflow in context * Fix mentions * Error handling * Fix chat loading * Hide current workflow from context * Run workflow fix * Lint * updated invitation log * styling for invitation pages --------- Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> --- apps/sim/app/api/environment/route.ts | 7 - .../app/api/environment/variables/route.ts | 223 - .../sim/app/api/files/presigned/route.test.ts | 12 +- .../app/api/logs/{by-id => }/[id]/route.ts | 0 .../[executionId]}/route.ts | 10 +- .../[id]/invitations/[invitationId]/route.ts | 198 + .../organizations/[id]/invitations/route.ts | 44 +- .../api/organizations/[id]/members/route.ts | 2 +- .../organizations/invitations/accept/route.ts | 359 - apps/sim/app/api/usage/check/route.ts | 38 - .../app/api/{usage-limits => usage}/route.ts | 6 +- .../api/users/{ => me}/rate-limit/route.ts | 4 - apps/sim/app/api/workflows/route.ts | 174 +- apps/sim/app/api/workflows/sync/route.ts | 167 - apps/sim/app/api/workflows/utils.ts | 24 + .../workspaces/invitations/[id]/route.test.ts | 241 - .../api/workspaces/invitations/[id]/route.ts | 55 - .../invitations/[invitationId]/route.test.ts | 405 ++ .../invitations/[invitationId]/route.ts | 236 + .../workspaces/invitations/accept/route.ts | 181 - .../workspaces/invitations/details/route.ts | 58 - .../app/api/workspaces/invitations/route.ts | 12 +- .../[subdomain]/{chat-client.css => chat.css} | 0 .../[subdomain]/{chat-client.tsx => chat.tsx} | 4 +- apps/sim/app/chat/[subdomain]/layout.tsx | 2 +- apps/sim/app/chat/[subdomain]/page.tsx | 2 +- .../components/auth/email/email-auth.tsx | 0 .../auth/password/password-auth.tsx | 0 .../components/header-links/header-links.tsx | 0 .../markdown-renderer/markdown-renderer.tsx | 0 .../components/error-state/error-state.tsx | 0 .../components/header/header.tsx | 0 .../{[subdomain] => }/components/index.ts | 0 .../components/input/input.tsx | 2 +- .../components/input/voice-input.tsx | 0 .../loading-state/loading-state.tsx | 0 .../message-container/message-container.tsx | 5 +- .../message/components/markdown-renderer.tsx | 0 .../components/message/message.tsx | 0 .../voice-interface/components/particles.tsx | 0 .../voice-interface/voice-interface.tsx | 2 +- .../app/chat/{[subdomain] => }/hooks/index.ts | 0 .../hooks/use-audio-streaming.ts | 0 .../hooks/use-chat-streaming.ts | 2 +- apps/sim/app/invite/[id]/invite.tsx | 391 +- apps/sim/app/invite/[id]/utils.ts | 28 + apps/sim/app/invite/components/index.ts | 2 + apps/sim/app/invite/components/layout.tsx | 56 + .../sim/app/invite/components/status-card.tsx | 121 + .../app/invite/invite-error/invite-error.tsx | 116 - apps/sim/app/invite/invite-error/page.tsx | 7 - apps/sim/app/unsubscribe/page.tsx | 402 +- apps/sim/app/unsubscribe/unsubscribe.tsx | 401 ++ .../filters/components/workflow.tsx | 2 +- .../frozen-canvas/frozen-canvas.tsx | 2 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 6 +- .../example-command/example-command.tsx | 4 +- .../components/control-bar/control-bar.tsx | 2 +- .../components/user-input/user-input.tsx | 2 +- .../components/environment/environment.tsx | 13 +- .../components/usage-limit/usage-limit.tsx | 2 +- apps/sim/blocks/blocks/agent.ts | 10 +- apps/sim/blocks/blocks/evaluator.ts | 4 +- apps/sim/blocks/blocks/router.ts | 4 +- .../emails/workspace-invitation.tsx | 7 +- .../0083_ambiguous_dreadnoughts.sql | 3 + .../sim/db/migrations/meta/0083_snapshot.json | 6049 +++++++++++++++++ apps/sim/db/migrations/meta/_journal.json | 7 + apps/sim/db/schema.ts | 12 +- apps/sim/hooks/use-subscription-state.ts | 4 +- .../lib/billing/validation/seat-management.ts | 22 - .../client/workflow/list-user-workflows.ts | 2 +- apps/sim/providers/ollama/index.ts | 6 +- apps/sim/providers/utils.ts | 5 +- apps/sim/stores/constants.ts | 6 - apps/sim/stores/ollama/store.ts | 76 - apps/sim/stores/ollama/types.ts | 6 - apps/sim/stores/openrouter/store.ts | 68 - apps/sim/stores/openrouter/types.ts | 6 - apps/sim/stores/organization/types.ts | 1 - apps/sim/stores/providers/store.ts | 126 + apps/sim/stores/providers/types.ts | 19 + apps/sim/stores/settings/environment/store.ts | 17 - apps/sim/stores/settings/environment/types.ts | 7 - apps/sim/stores/subscription/store.ts | 6 +- apps/sim/stores/workflows/registry/store.ts | 4 +- 86 files changed, 7958 insertions(+), 2551 deletions(-) delete mode 100644 apps/sim/app/api/environment/variables/route.ts rename apps/sim/app/api/logs/{by-id => }/[id]/route.ts (100%) rename apps/sim/app/api/logs/{[executionId]/frozen-canvas => execution/[executionId]}/route.ts (82%) create mode 100644 apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts delete mode 100644 apps/sim/app/api/organizations/invitations/accept/route.ts delete mode 100644 apps/sim/app/api/usage/check/route.ts rename apps/sim/app/api/{usage-limits => usage}/route.ts (96%) rename apps/sim/app/api/users/{ => me}/rate-limit/route.ts (94%) delete mode 100644 apps/sim/app/api/workflows/sync/route.ts delete mode 100644 apps/sim/app/api/workspaces/invitations/[id]/route.test.ts delete mode 100644 apps/sim/app/api/workspaces/invitations/[id]/route.ts create mode 100644 apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts create mode 100644 apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts delete mode 100644 apps/sim/app/api/workspaces/invitations/accept/route.ts delete mode 100644 apps/sim/app/api/workspaces/invitations/details/route.ts rename apps/sim/app/chat/[subdomain]/{chat-client.css => chat.css} (100%) rename apps/sim/app/chat/[subdomain]/{chat-client.tsx => chat.tsx} (99%) rename apps/sim/app/chat/{[subdomain] => }/components/auth/email/email-auth.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/auth/password/password-auth.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/components/header-links/header-links.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/components/markdown-renderer/markdown-renderer.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/error-state/error-state.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/header/header.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/index.ts (100%) rename apps/sim/app/chat/{[subdomain] => }/components/input/input.tsx (99%) rename apps/sim/app/chat/{[subdomain] => }/components/input/voice-input.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/loading-state/loading-state.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/message-container/message-container.tsx (96%) rename apps/sim/app/chat/{[subdomain] => }/components/message/components/markdown-renderer.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/message/message.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/voice-interface/components/particles.tsx (100%) rename apps/sim/app/chat/{[subdomain] => }/components/voice-interface/voice-interface.tsx (99%) rename apps/sim/app/chat/{[subdomain] => }/hooks/index.ts (100%) rename apps/sim/app/chat/{[subdomain] => }/hooks/use-audio-streaming.ts (100%) rename apps/sim/app/chat/{[subdomain] => }/hooks/use-chat-streaming.ts (99%) create mode 100644 apps/sim/app/invite/[id]/utils.ts create mode 100644 apps/sim/app/invite/components/index.ts create mode 100644 apps/sim/app/invite/components/layout.tsx create mode 100644 apps/sim/app/invite/components/status-card.tsx delete mode 100644 apps/sim/app/invite/invite-error/invite-error.tsx delete mode 100644 apps/sim/app/invite/invite-error/page.tsx create mode 100644 apps/sim/app/unsubscribe/unsubscribe.tsx create mode 100644 apps/sim/db/migrations/0083_ambiguous_dreadnoughts.sql create mode 100644 apps/sim/db/migrations/meta/0083_snapshot.json delete mode 100644 apps/sim/stores/ollama/store.ts delete mode 100644 apps/sim/stores/ollama/types.ts delete mode 100644 apps/sim/stores/openrouter/store.ts delete mode 100644 apps/sim/stores/openrouter/types.ts create mode 100644 apps/sim/stores/providers/store.ts create mode 100644 apps/sim/stores/providers/types.ts diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index cb44796865..31663e34b8 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -10,7 +10,6 @@ import type { EnvironmentVariable } from '@/stores/settings/environment/types' const logger = createLogger('EnvironmentAPI') -// Schema for environment variable updates const EnvVarSchema = z.object({ variables: z.record(z.string()), }) @@ -30,7 +29,6 @@ export async function POST(req: NextRequest) { try { const { variables } = EnvVarSchema.parse(body) - // Encrypt all variables const encryptedVariables = await Promise.all( Object.entries(variables).map(async ([key, value]) => { const { encrypted } = await encryptSecret(value) @@ -38,7 +36,6 @@ export async function POST(req: NextRequest) { }) ).then((entries) => Object.fromEntries(entries)) - // Replace all environment variables for user await db .insert(environment) .values({ @@ -78,7 +75,6 @@ export async function GET(request: Request) { const requestId = crypto.randomUUID().slice(0, 8) try { - // Get the session directly in the API route const session = await getSession() if (!session?.user?.id) { logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) @@ -97,18 +93,15 @@ export async function GET(request: Request) { return NextResponse.json({ data: {} }, { status: 200 }) } - // Decrypt the variables for client-side use const encryptedVariables = result[0].variables as Record const decryptedVariables: Record = {} - // Decrypt each variable for (const [key, encryptedValue] of Object.entries(encryptedVariables)) { try { const { decrypted } = await decryptSecret(encryptedValue) decryptedVariables[key] = { key, value: decrypted } } catch (error) { logger.error(`[${requestId}] Error decrypting variable ${key}`, error) - // If decryption fails, provide a placeholder decryptedVariables[key] = { key, value: '' } } } diff --git a/apps/sim/app/api/environment/variables/route.ts b/apps/sim/app/api/environment/variables/route.ts deleted file mode 100644 index d2a7efcdf9..0000000000 --- a/apps/sim/app/api/environment/variables/route.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getEnvironmentVariableKeys } from '@/lib/environment/utils' -import { createLogger } from '@/lib/logs/console/logger' -import { decryptSecret, encryptSecret } from '@/lib/utils' -import { getUserId } from '@/app/api/auth/oauth/utils' -import { db } from '@/db' -import { environment } from '@/db/schema' - -const logger = createLogger('EnvironmentVariablesAPI') - -// Schema for environment variable updates -const EnvVarSchema = z.object({ - variables: z.record(z.string()), -}) - -export async function GET(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - // For GET requests, check for workflowId in query params - const { searchParams } = new URL(request.url) - const workflowId = searchParams.get('workflowId') - - // Use dual authentication pattern like other copilot tools - const userId = await getUserId(requestId, workflowId || undefined) - - if (!userId) { - logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Get only the variable names (keys), not values - const result = await getEnvironmentVariableKeys(userId) - - return NextResponse.json( - { - success: true, - output: result, - }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Environment variables fetch error`, error) - return NextResponse.json( - { - success: false, - error: error.message || 'Failed to get environment variables', - }, - { status: 500 } - ) - } -} - -export async function PUT(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const body = await request.json() - const { workflowId, variables } = body - - // Use dual authentication pattern like other copilot tools - const userId = await getUserId(requestId, workflowId) - - if (!userId) { - logger.warn(`[${requestId}] Unauthorized environment variables set attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { variables: validatedVariables } = EnvVarSchema.parse({ variables }) - - // Get existing environment variables for this user - const existingData = await db - .select() - .from(environment) - .where(eq(environment.userId, userId)) - .limit(1) - - // Start with existing encrypted variables or empty object - const existingEncryptedVariables = - (existingData[0]?.variables as Record) || {} - - // Determine which variables are new or changed by comparing with decrypted existing values - const variablesToEncrypt: Record = {} - const addedVariables: string[] = [] - const updatedVariables: string[] = [] - - for (const [key, newValue] of Object.entries(validatedVariables)) { - if (!(key in existingEncryptedVariables)) { - // New variable - variablesToEncrypt[key] = newValue - addedVariables.push(key) - } else { - // Check if the value has actually changed by decrypting the existing value - try { - const { decrypted: existingValue } = await decryptSecret( - existingEncryptedVariables[key] - ) - - if (existingValue !== newValue) { - // Value changed, needs re-encryption - variablesToEncrypt[key] = newValue - updatedVariables.push(key) - } - // If values are the same, keep the existing encrypted value - } catch (decryptError) { - // If we can't decrypt the existing value, treat as changed and re-encrypt - logger.warn( - `[${requestId}] Could not decrypt existing variable ${key}, re-encrypting`, - { - error: decryptError, - } - ) - variablesToEncrypt[key] = newValue - updatedVariables.push(key) - } - } - } - - // Only encrypt the variables that are new or changed - const newlyEncryptedVariables = await Promise.all( - Object.entries(variablesToEncrypt).map(async ([key, value]) => { - const { encrypted } = await encryptSecret(value) - return [key, encrypted] as const - }) - ).then((entries) => Object.fromEntries(entries)) - - // Merge existing encrypted variables with newly encrypted ones - const finalEncryptedVariables = { ...existingEncryptedVariables, ...newlyEncryptedVariables } - - // Update or insert environment variables for user - await db - .insert(environment) - .values({ - id: crypto.randomUUID(), - userId: userId, - variables: finalEncryptedVariables, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { - variables: finalEncryptedVariables, - updatedAt: new Date(), - }, - }) - - return NextResponse.json( - { - success: true, - output: { - message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`, - variableCount: Object.keys(validatedVariables).length, - variableNames: Object.keys(validatedVariables), - totalVariableCount: Object.keys(finalEncryptedVariables).length, - addedVariables, - updatedVariables, - }, - }, - { status: 200 } - ) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid environment variables data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } - } catch (error: any) { - logger.error(`[${requestId}] Environment variables set error`, error) - return NextResponse.json( - { - success: false, - error: error.message || 'Failed to set environment variables', - }, - { status: 500 } - ) - } -} - -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const body = await request.json() - const { workflowId } = body - - // Use dual authentication pattern like other copilot tools - const userId = await getUserId(requestId, workflowId) - - if (!userId) { - logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Get only the variable names (keys), not values - const result = await getEnvironmentVariableKeys(userId) - - return NextResponse.json( - { - success: true, - output: result, - }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Environment variables fetch error`, error) - return NextResponse.json( - { - success: false, - error: error.message || 'Failed to get environment variables', - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 4702324d52..3fde4ca3ce 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -1,7 +1,13 @@ import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setupFileApiMocks } from '@/app/api/__test-utils__/utils' +/** + * Tests for file presigned API route + * + * @vitest-environment node + */ + describe('/api/files/presigned', () => { beforeEach(() => { vi.clearAllMocks() @@ -19,7 +25,7 @@ describe('/api/files/presigned', () => { }) describe('POST', () => { - test('should return error when cloud storage is not enabled', async () => { + it('should return error when cloud storage is not enabled', async () => { setupFileApiMocks({ cloudEnabled: false, storageProvider: 's3', @@ -39,7 +45,7 @@ describe('/api/files/presigned', () => { const response = await POST(request) const data = await response.json() - expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError) + expect(response.status).toBe(500) expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled') expect(data.code).toBe('STORAGE_CONFIG_ERROR') expect(data.directUploadSupported).toBe(false) diff --git a/apps/sim/app/api/logs/by-id/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts similarity index 100% rename from apps/sim/app/api/logs/by-id/[id]/route.ts rename to apps/sim/app/api/logs/[id]/route.ts diff --git a/apps/sim/app/api/logs/[executionId]/frozen-canvas/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts similarity index 82% rename from apps/sim/app/api/logs/[executionId]/frozen-canvas/route.ts rename to apps/sim/app/api/logs/execution/[executionId]/route.ts index be596d034b..decfeea953 100644 --- a/apps/sim/app/api/logs/[executionId]/frozen-canvas/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema' -const logger = createLogger('FrozenCanvasAPI') +const logger = createLogger('LogsByExecutionIdAPI') export async function GET( _request: NextRequest, @@ -13,7 +13,7 @@ export async function GET( try { const { executionId } = await params - logger.debug(`Fetching frozen canvas data for execution: ${executionId}`) + logger.debug(`Fetching execution data for: ${executionId}`) // Get the workflow execution log to find the snapshot const [workflowLog] = await db @@ -50,14 +50,14 @@ export async function GET( }, } - logger.debug(`Successfully fetched frozen canvas data for execution: ${executionId}`) + logger.debug(`Successfully fetched execution data for: ${executionId}`) logger.debug( `Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` ) return NextResponse.json(response) } catch (error) { - logger.error('Error fetching frozen canvas data:', error) - return NextResponse.json({ error: 'Failed to fetch frozen canvas data' }, { status: 500 }) + logger.error('Error fetching execution data:', error) + return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 }) } } diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts new file mode 100644 index 0000000000..236ffd3a91 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -0,0 +1,198 @@ +import { randomUUID } from 'crypto' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { + invitation, + member, + organization, + permissions, + user, + type WorkspaceInvitationStatus, + workspaceInvitation, +} from '@/db/schema' + +const logger = createLogger('OrganizationInvitation') + +// Get invitation details +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } +) { + const { id: organizationId, invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const orgInvitation = await db + .select() + .from(invitation) + .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) + .then((rows) => rows[0]) + + if (!orgInvitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + const org = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .then((rows) => rows[0]) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + return NextResponse.json({ + invitation: orgInvitation, + organization: org, + }) + } catch (error) { + logger.error('Error fetching organization invitation:', error) + return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) + } +} + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } +) { + const { id: organizationId, invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { status } = await req.json() + + if (!status || !['accepted', 'rejected', 'cancelled'].includes(status)) { + return NextResponse.json( + { error: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' }, + { status: 400 } + ) + } + + const orgInvitation = await db + .select() + .from(invitation) + .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) + .then((rows) => rows[0]) + + if (!orgInvitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (orgInvitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 }) + } + + if (status === 'accepted') { + const userData = await db + .select() + .from(user) + .where(eq(user.id, session.user.id)) + .then((rows) => rows[0]) + + if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) { + return NextResponse.json( + { error: 'Email mismatch. You can only accept invitations sent to your email address.' }, + { status: 403 } + ) + } + } + + if (status === 'cancelled') { + const isAdmin = await db + .select() + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + eq(member.userId, session.user.id), + eq(member.role, 'admin') + ) + ) + .then((rows) => rows.length > 0) + + if (!isAdmin) { + return NextResponse.json( + { error: 'Only organization admins can cancel invitations' }, + { status: 403 } + ) + } + } + + await db.transaction(async (tx) => { + await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) + + if (status === 'accepted') { + await tx.insert(member).values({ + id: randomUUID(), + userId: session.user.id, + organizationId, + role: orgInvitation.role, + createdAt: new Date(), + }) + + const linkedWorkspaceInvitations = await tx + .select() + .from(workspaceInvitation) + .where( + and( + eq(workspaceInvitation.orgInvitationId, invitationId), + eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus) + ) + ) + + for (const wsInvitation of linkedWorkspaceInvitations) { + await tx + .update(workspaceInvitation) + .set({ + status: 'accepted' as WorkspaceInvitationStatus, + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, wsInvitation.id)) + + await tx.insert(permissions).values({ + id: randomUUID(), + entityType: 'workspace', + entityId: wsInvitation.workspaceId, + userId: session.user.id, + permissionType: wsInvitation.permissions || 'read', + createdAt: new Date(), + updatedAt: new Date(), + }) + } + } else if (status === 'cancelled') { + await tx + .update(workspaceInvitation) + .set({ status: 'cancelled' as WorkspaceInvitationStatus }) + .where(eq(workspaceInvitation.orgInvitationId, invitationId)) + } + }) + + logger.info(`Organization invitation ${status}`, { + organizationId, + invitationId, + userId: session.user.id, + email: orgInvitation.email, + }) + + return NextResponse.json({ + success: true, + message: `Invitation ${status} successfully`, + invitation: { ...orgInvitation, status }, + }) + } catch (error) { + logger.error(`Error updating organization invitation:`, error) + return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 470b1d3e0a..07bc930759 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -17,9 +17,17 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { db } from '@/db' -import { invitation, member, organization, user, workspace, workspaceInvitation } from '@/db/schema' +import { + invitation, + member, + organization, + user, + type WorkspaceInvitationStatus, + workspace, + workspaceInvitation, +} from '@/db/schema' -const logger = createLogger('OrganizationInvitationsAPI') +const logger = createLogger('OrganizationInvitations') interface WorkspaceInvitation { workspaceId: string @@ -40,7 +48,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id: organizationId } = await params - // Verify user has access to this organization const memberEntry = await db .select() .from(member) @@ -61,7 +68,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Get all pending invitations for the organization const invitations = await db .select({ id: invitation.id, @@ -118,10 +124,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const body = await request.json() const { email, emails, role = 'member', workspaceInvitations } = body - // Handle single invitation vs batch const invitationEmails = email ? [email] : emails - // Validate input if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) { return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 }) } @@ -130,7 +134,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) } - // Verify user has admin access const memberEntry = await db .select() .from(member) @@ -148,7 +151,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Handle validation-only requests if (validateOnly) { const validationResult = await validateBulkInvitations(organizationId, invitationEmails) @@ -167,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }) } - // Validate seat availability const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length) if (!seatValidation.canInvite) { @@ -185,7 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - // Get organization details const organizationEntry = await db .select({ name: organization.name }) .from(organization) @@ -196,7 +196,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) } - // Validate and normalize emails const processedEmails = invitationEmails .map((email: string) => { const normalized = email.trim().toLowerCase() @@ -209,11 +208,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 }) } - // Handle batch workspace invitations if provided const validWorkspaceInvitations: WorkspaceInvitation[] = [] if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) { for (const wsInvitation of workspaceInvitations) { - // Check if user has admin permission on this workspace const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId) if (!canInvite) { @@ -229,7 +226,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } - // Check for existing members const existingMembers = await db .select({ userEmail: user.email }) .from(member) @@ -239,7 +235,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const existingEmails = existingMembers.map((m) => m.userEmail) const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email)) - // Check for existing pending invitations const existingInvitations = await db .select({ email: invitation.email }) .from(invitation) @@ -265,7 +260,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - // Create invitations const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days const invitationsToCreate = emailsToInvite.map((email: string) => ({ id: randomUUID(), @@ -280,7 +274,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ await db.insert(invitation).values(invitationsToCreate) - // Create workspace invitations if batch mode const workspaceInvitationIds: string[] = [] if (isBatch && validWorkspaceInvitations.length > 0) { for (const email of emailsToInvite) { @@ -309,7 +302,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } - // Send invitation emails const inviter = await db .select({ name: user.name }) .from(user) @@ -322,7 +314,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ let emailResult if (isBatch && validWorkspaceInvitations.length > 0) { - // Get workspace details for batch email const workspaceDetails = await db .select({ id: workspace.id, @@ -348,7 +339,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ organizationEntry[0]?.name || 'organization', role, workspaceInvitationsWithNames, - `${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}` + `${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}` ) emailResult = await sendEmail({ @@ -361,7 +352,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const emailHtml = await renderInvitationEmail( inviter[0]?.name || 'Someone', organizationEntry[0]?.name || 'organization', - `${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`, + `${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`, email ) @@ -448,7 +439,6 @@ export async function DELETE( ) } - // Verify user has admin access const memberEntry = await db .select() .from(member) @@ -466,7 +456,6 @@ export async function DELETE( return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Cancel the invitation const result = await db .update(invitation) .set({ status: 'cancelled' }) @@ -486,22 +475,19 @@ export async function DELETE( ) } - // Also cancel any linked workspace invitations created as part of the batch await db .update(workspaceInvitation) - .set({ status: 'cancelled' }) + .set({ status: 'cancelled' as WorkspaceInvitationStatus }) .where(eq(workspaceInvitation.orgInvitationId, invitationId)) - // Legacy fallback: cancel any pending workspace invitations for the same email - // that do not have an orgInvitationId and were created by the same inviter await db .update(workspaceInvitation) - .set({ status: 'cancelled' }) + .set({ status: 'cancelled' as WorkspaceInvitationStatus }) .where( and( isNull(workspaceInvitation.orgInvitationId), eq(workspaceInvitation.email, result[0].email), - eq(workspaceInvitation.status, 'pending'), + eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus), eq(workspaceInvitation.inviterId, session.user.id) ) ) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 9dda509a86..445539a001 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -260,7 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const emailHtml = await renderInvitationEmail( inviter[0]?.name || 'Someone', organizationEntry[0]?.name || 'organization', - `${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${invitationId}`, + `${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${invitationId}`, normalizedEmail ) diff --git a/apps/sim/app/api/organizations/invitations/accept/route.ts b/apps/sim/app/api/organizations/invitations/accept/route.ts deleted file mode 100644 index b42e44c239..0000000000 --- a/apps/sim/app/api/organizations/invitations/accept/route.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { randomUUID } from 'crypto' -import { and, eq, isNull, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { env } from '@/lib/env' -import { createLogger } from '@/lib/logs/console/logger' -import { db } from '@/db' -import { invitation, member, permissions, user, workspaceInvitation } from '@/db/schema' - -const logger = createLogger('OrganizationInvitationAcceptanceAPI') - -// Accept an organization invitation and any associated workspace invitations -export async function GET(req: NextRequest) { - const invitationId = req.nextUrl.searchParams.get('id') - - if (!invitationId) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=missing-invitation-id', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - const session = await getSession() - - if (!session?.user?.id) { - // Redirect to login, user will be redirected back after login - return NextResponse.redirect( - new URL( - `/invite/organization?id=${invitationId}`, - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - try { - // Find the organization invitation - const invitationResult = await db - .select() - .from(invitation) - .where(eq(invitation.id, invitationId)) - .limit(1) - - if (invitationResult.length === 0) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=invalid-invitation', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - const orgInvitation = invitationResult[0] - - // Check if invitation has expired - if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) { - return NextResponse.redirect( - new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai') - ) - } - - // Check if invitation is still pending - if (orgInvitation.status !== 'pending') { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=already-processed', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Get user data to check email verification status - const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) - - if (userData.length === 0) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=user-not-found', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Verify the email matches the current user - if (orgInvitation.email !== session.user.email) { - return NextResponse.redirect( - new URL( - `/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${orgInvitation.email}, but you're logged in as ${userData[0].email}`)}`, - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Check if user is already a member of the organization - const existingMember = await db - .select() - .from(member) - .where( - and( - eq(member.organizationId, orgInvitation.organizationId), - eq(member.userId, session.user.id) - ) - ) - .limit(1) - - if (existingMember.length > 0) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=already-member', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Start transaction to accept both organization and workspace invitations - await db.transaction(async (tx) => { - // Accept organization invitation - add user as member - await tx.insert(member).values({ - id: randomUUID(), - userId: session.user.id, - organizationId: orgInvitation.organizationId, - role: orgInvitation.role, - createdAt: new Date(), - }) - - // Mark organization invitation as accepted - await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId)) - - // Find and accept any pending workspace invitations linked to this org invite. - // For backward compatibility, also include legacy pending invites by email with no org link. - const workspaceInvitations = await tx - .select() - .from(workspaceInvitation) - .where( - and( - eq(workspaceInvitation.status, 'pending'), - or( - eq(workspaceInvitation.orgInvitationId, invitationId), - and( - isNull(workspaceInvitation.orgInvitationId), - eq(workspaceInvitation.email, orgInvitation.email) - ) - ) - ) - ) - - for (const wsInvitation of workspaceInvitations) { - // Check if invitation hasn't expired - if ( - wsInvitation.expiresAt && - new Date().toISOString() <= wsInvitation.expiresAt.toISOString() - ) { - // Check if user doesn't already have permissions on the workspace - const existingPermission = await tx - .select() - .from(permissions) - .where( - and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, wsInvitation.workspaceId) - ) - ) - .limit(1) - - if (existingPermission.length === 0) { - // Add workspace permissions - await tx.insert(permissions).values({ - id: randomUUID(), - userId: session.user.id, - entityType: 'workspace', - entityId: wsInvitation.workspaceId, - permissionType: wsInvitation.permissions, - createdAt: new Date(), - updatedAt: new Date(), - }) - - // Mark workspace invitation as accepted - await tx - .update(workspaceInvitation) - .set({ status: 'accepted' }) - .where(eq(workspaceInvitation.id, wsInvitation.id)) - - logger.info('Accepted workspace invitation', { - workspaceId: wsInvitation.workspaceId, - userId: session.user.id, - permission: wsInvitation.permissions, - }) - } - } - } - }) - - logger.info('Successfully accepted batch invitation', { - organizationId: orgInvitation.organizationId, - userId: session.user.id, - role: orgInvitation.role, - }) - - // Redirect to success page or main app - return NextResponse.redirect( - new URL('/workspaces?invite=accepted', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai') - ) - } catch (error) { - logger.error('Failed to accept organization invitation', { - invitationId, - userId: session.user.id, - error, - }) - - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=server-error', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } -} - -// POST endpoint for programmatic acceptance (for API use) -export async function POST(req: NextRequest) { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { invitationId } = await req.json() - - if (!invitationId) { - return NextResponse.json({ error: 'Missing invitationId' }, { status: 400 }) - } - - // Similar logic to GET but return JSON response - const invitationResult = await db - .select() - .from(invitation) - .where(eq(invitation.id, invitationId)) - .limit(1) - - if (invitationResult.length === 0) { - return NextResponse.json({ error: 'Invalid invitation' }, { status: 404 }) - } - - const orgInvitation = invitationResult[0] - - if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) { - return NextResponse.json({ error: 'Invitation expired' }, { status: 400 }) - } - - if (orgInvitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 }) - } - - // Get user data to check email verification status - const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) - - if (userData.length === 0) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }) - } - - if (orgInvitation.email !== session.user.email) { - return NextResponse.json({ error: 'Email mismatch' }, { status: 403 }) - } - - // Check if user is already a member - const existingMember = await db - .select() - .from(member) - .where( - and( - eq(member.organizationId, orgInvitation.organizationId), - eq(member.userId, session.user.id) - ) - ) - .limit(1) - - if (existingMember.length > 0) { - return NextResponse.json({ error: 'Already a member' }, { status: 400 }) - } - - let acceptedWorkspaces = 0 - - // Accept invitations in transaction - await db.transaction(async (tx) => { - // Accept organization invitation - await tx.insert(member).values({ - id: randomUUID(), - userId: session.user.id, - organizationId: orgInvitation.organizationId, - role: orgInvitation.role, - createdAt: new Date(), - }) - - await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId)) - - // Accept workspace invitations - const workspaceInvitations = await tx - .select() - .from(workspaceInvitation) - .where( - and( - eq(workspaceInvitation.email, orgInvitation.email), - eq(workspaceInvitation.status, 'pending') - ) - ) - - for (const wsInvitation of workspaceInvitations) { - if ( - wsInvitation.expiresAt && - new Date().toISOString() <= wsInvitation.expiresAt.toISOString() - ) { - const existingPermission = await tx - .select() - .from(permissions) - .where( - and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, wsInvitation.workspaceId) - ) - ) - .limit(1) - - if (existingPermission.length === 0) { - await tx.insert(permissions).values({ - id: randomUUID(), - userId: session.user.id, - entityType: 'workspace', - entityId: wsInvitation.workspaceId, - permissionType: wsInvitation.permissions, - createdAt: new Date(), - updatedAt: new Date(), - }) - - await tx - .update(workspaceInvitation) - .set({ status: 'accepted' }) - .where(eq(workspaceInvitation.id, wsInvitation.id)) - - acceptedWorkspaces++ - } - } - } - }) - - return NextResponse.json({ - success: true, - message: `Successfully joined organization and ${acceptedWorkspaces} workspace(s)`, - organizationId: orgInvitation.organizationId, - workspacesJoined: acceptedWorkspaces, - }) - } catch (error) { - logger.error('Failed to accept organization invitation via API', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/usage/check/route.ts b/apps/sim/app/api/usage/check/route.ts deleted file mode 100644 index e6bb2413ae..0000000000 --- a/apps/sim/app/api/usage/check/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { checkServerSideUsageLimits } from '@/lib/billing' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('UsageCheckAPI') - -export async function GET(_request: NextRequest) { - const session = await getSession() - try { - const userId = session?.user?.id - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const result = await checkServerSideUsageLimits(userId) - // Normalize to client usage shape - return NextResponse.json({ - success: true, - data: { - percentUsed: - result.limit > 0 - ? Math.min(Math.floor((result.currentUsage / result.limit) * 100), 100) - : 0, - isWarning: - result.limit > 0 - ? (result.currentUsage / result.limit) * 100 >= 80 && - (result.currentUsage / result.limit) * 100 < 100 - : false, - isExceeded: result.isExceeded, - currentUsage: result.currentUsage, - limit: result.limit, - message: result.message, - }, - }) - } catch (error) { - logger.error('Failed usage check', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/usage-limits/route.ts b/apps/sim/app/api/usage/route.ts similarity index 96% rename from apps/sim/app/api/usage-limits/route.ts rename to apps/sim/app/api/usage/route.ts index 5429cef362..9d9a04147d 100644 --- a/apps/sim/app/api/usage-limits/route.ts +++ b/apps/sim/app/api/usage/route.ts @@ -7,11 +7,11 @@ import { } from '@/lib/billing/core/organization' import { createLogger } from '@/lib/logs/console/logger' -const logger = createLogger('UnifiedUsageLimitsAPI') +const logger = createLogger('UnifiedUsageAPI') /** - * Unified Usage Limits Endpoint - * GET/PUT /api/usage-limits?context=user|organization&userId=&organizationId= + * Unified Usage Endpoint + * GET/PUT /api/usage?context=user|organization&userId=&organizationId= * */ export async function GET(request: NextRequest) { diff --git a/apps/sim/app/api/users/rate-limit/route.ts b/apps/sim/app/api/users/me/rate-limit/route.ts similarity index 94% rename from apps/sim/app/api/users/rate-limit/route.ts rename to apps/sim/app/api/users/me/rate-limit/route.ts index 06125793a5..904f37f298 100644 --- a/apps/sim/app/api/users/rate-limit/route.ts +++ b/apps/sim/app/api/users/me/rate-limit/route.ts @@ -11,15 +11,12 @@ const logger = createLogger('RateLimitAPI') export async function GET(request: NextRequest) { try { - // Try session auth first (for web UI) const session = await getSession() let authenticatedUserId: string | null = session?.user?.id || null - // If no session, check for API key auth if (!authenticatedUserId) { const apiKeyHeader = request.headers.get('x-api-key') if (apiKeyHeader) { - // Verify API key const [apiKeyRecord] = await db .select({ userId: apiKeyTable.userId }) .from(apiKeyTable) @@ -36,7 +33,6 @@ export async function GET(request: NextRequest) { return createErrorResponse('Authentication required', 401) } - // Get user subscription const [subscriptionRecord] = await db .select({ plan: subscription.plan }) .from(subscription) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index f10f50b246..831eada916 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,10 +1,12 @@ import crypto from 'crypto' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' -import { workflow, workflowBlocks } from '@/db/schema' +import { workflow, workflowBlocks, workspace } from '@/db/schema' +import { verifyWorkspaceMembership } from './utils' const logger = createLogger('WorkflowAPI') @@ -16,6 +18,68 @@ const CreateWorkflowSchema = z.object({ folderId: z.string().nullable().optional(), }) +// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId) +export async function GET(request: Request) { + const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() + const url = new URL(request.url) + const workspaceId = url.searchParams.get('workspaceId') + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workflow access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + if (workspaceId) { + const workspaceExists = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .then((rows) => rows.length > 0) + + if (!workspaceExists) { + logger.warn( + `[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}` + ) + return NextResponse.json( + { error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' }, + { status: 404 } + ) + } + + const userRole = await verifyWorkspaceMembership(userId, workspaceId) + + if (!userRole) { + logger.warn( + `[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership` + ) + return NextResponse.json( + { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, + { status: 403 } + ) + } + } + + let workflows + + if (workspaceId) { + workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId)) + } else { + workflows = await db.select().from(workflow).where(eq(workflow.userId, userId)) + } + + return NextResponse.json({ data: workflows }, { status: 200 }) + } catch (error: any) { + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} + // POST /api/workflows - Create a new workflow export async function POST(req: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) @@ -36,114 +100,7 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`) - // Create initial state with start block - const initialState = { - blocks: { - [starterId]: { - id: starterId, - type: 'starter', - name: 'Start', - position: { x: 100, y: 100 }, - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown', - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input', - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input', - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown', - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input', - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input', - value: '', - }, - hourlyMinute: { - id: 'hourlyMinute', - type: 'short-input', - value: '', - }, - dailyTime: { - id: 'dailyTime', - type: 'short-input', - value: '', - }, - weeklyDay: { - id: 'weeklyDay', - type: 'dropdown', - value: 'MON', - }, - weeklyDayTime: { - id: 'weeklyDayTime', - type: 'short-input', - value: '', - }, - monthlyDay: { - id: 'monthlyDay', - type: 'short-input', - value: '', - }, - monthlyTime: { - id: 'monthlyTime', - type: 'short-input', - value: '', - }, - cronExpression: { - id: 'cronExpression', - type: 'short-input', - value: '', - }, - timezone: { - id: 'timezone', - type: 'dropdown', - value: 'UTC', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - triggerMode: false, - height: 95, - }, - }, - edges: [], - subflows: {}, - variables: {}, - metadata: { - version: '1.0.0', - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - }, - } - - // Create the workflow and start block in a transaction await db.transaction(async (tx) => { - // Create the workflow await tx.insert(workflow).values({ id: workflowId, userId: session.user.id, @@ -163,7 +120,6 @@ export async function POST(req: NextRequest) { marketplaceData: null, }) - // Insert the start block into workflow_blocks table await tx.insert(workflowBlocks).values({ id: starterId, workflowId: workflowId, diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts deleted file mode 100644 index 987e6f372f..0000000000 --- a/apps/sim/app/api/workflows/sync/route.ts +++ /dev/null @@ -1,167 +0,0 @@ -import crypto from 'crypto' -import { and, eq, isNull } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { getUserEntityPermissions } from '@/lib/permissions/utils' -import { db } from '@/db' -import { workflow, workspace } from '@/db/schema' - -const logger = createLogger('WorkflowAPI') - -/** - * Verifies user's workspace permissions using the permissions table - * @param userId User ID to check - * @param workspaceId Workspace ID to check - * @returns Permission type if user has access, null otherwise - */ -async function verifyWorkspaceMembership( - userId: string, - workspaceId: string -): Promise { - try { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - - return permission - } catch (error) { - logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error) - return null - } -} - -export async function GET(request: Request) { - const requestId = crypto.randomUUID().slice(0, 8) - const startTime = Date.now() - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - - try { - // Get the session directly in the API route - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workflow access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - // If workspaceId is provided, verify it exists and user is a member - if (workspaceId) { - // Check workspace exists first - const workspaceExists = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .then((rows) => rows.length > 0) - - if (!workspaceExists) { - logger.warn( - `[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}` - ) - return NextResponse.json( - { error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' }, - { status: 404 } - ) - } - - // Verify the user is a member of the workspace using our optimized function - const userRole = await verifyWorkspaceMembership(userId, workspaceId) - - if (!userRole) { - logger.warn( - `[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership` - ) - return NextResponse.json( - { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, - { status: 403 } - ) - } - - // Migrate any orphaned workflows to this workspace (in background) - migrateOrphanedWorkflows(userId, workspaceId).catch((error) => { - logger.error(`[${requestId}] Error migrating orphaned workflows:`, error) - }) - } - - // Fetch workflows for the user - let workflows - - if (workspaceId) { - // Filter by workspace ID only, not user ID - // This allows sharing workflows across workspace members - workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId)) - } else { - // Filter by user ID only, including workflows without workspace IDs - workflows = await db.select().from(workflow).where(eq(workflow.userId, userId)) - } - - const elapsed = Date.now() - startTime - - // Return the workflows - return NextResponse.json({ data: workflows }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } -} - -// Helper function to migrate orphaned workflows to a workspace -async function migrateOrphanedWorkflows(userId: string, workspaceId: string) { - try { - // Find workflows without workspace IDs for this user - const orphanedWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) - - if (orphanedWorkflows.length === 0) { - return // No orphaned workflows to migrate - } - - logger.info( - `Migrating ${orphanedWorkflows.length} orphaned workflows to workspace ${workspaceId}` - ) - - // Update workflows in batch if possible - try { - // Batch update all orphaned workflows - await db - .update(workflow) - .set({ - workspaceId: workspaceId, - updatedAt: new Date(), - }) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) - - logger.info( - `Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}` - ) - } catch (batchError) { - logger.warn('Batch migration failed, falling back to individual updates:', batchError) - - // Fallback to individual updates if batch update fails - for (const { id } of orphanedWorkflows) { - try { - await db - .update(workflow) - .set({ - workspaceId: workspaceId, - updatedAt: new Date(), - }) - .where(eq(workflow.id, id)) - } catch (updateError) { - logger.error(`Failed to migrate workflow ${id}:`, updateError) - } - } - } - } catch (error) { - logger.error('Error migrating orphaned workflows:', error) - // Continue execution even if migration fails - } -} - -// POST method removed - workflow operations now handled by: -// - POST /api/workflows (create) -// - DELETE /api/workflows/[id] (delete) -// - Socket.IO collaborative operations (real-time updates) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index 75ee1ab977..10478bcfda 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -1,4 +1,8 @@ import { NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' + +const logger = createLogger('WorkflowUtils') export function createErrorResponse(error: string, status: number, code?: string) { return NextResponse.json( @@ -13,3 +17,23 @@ export function createErrorResponse(error: string, status: number, code?: string export function createSuccessResponse(data: any) { return NextResponse.json(data) } + +/** + * Verifies user's workspace permissions using the permissions table + * @param userId User ID to check + * @param workspaceId Workspace ID to check + * @returns Permission type if user has access, null otherwise + */ +export async function verifyWorkspaceMembership( + userId: string, + workspaceId: string +): Promise { + try { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + + return permission + } catch (error) { + logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error) + return null + } +} diff --git a/apps/sim/app/api/workspaces/invitations/[id]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[id]/route.test.ts deleted file mode 100644 index a4391b74ed..0000000000 --- a/apps/sim/app/api/workspaces/invitations/[id]/route.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getSession } from '@/lib/auth' -import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' -import { DELETE } from '@/app/api/workspaces/invitations/[id]/route' -import { db } from '@/db' -import { workspaceInvitation } from '@/db/schema' - -vi.mock('@/lib/auth', () => ({ - getSession: vi.fn(), -})) - -vi.mock('@/lib/permissions/utils', () => ({ - hasWorkspaceAdminAccess: vi.fn(), -})) - -vi.mock('@/db', () => ({ - db: { - select: vi.fn(), - delete: vi.fn(), - }, -})) - -vi.mock('@/db/schema', () => ({ - workspaceInvitation: { - id: 'id', - workspaceId: 'workspaceId', - email: 'email', - inviterId: 'inviterId', - status: 'status', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((a, b) => ({ type: 'eq', a, b })), -})) - -describe('DELETE /api/workspaces/invitations/[id]', () => { - const mockSession = { - user: { - id: 'user123', - email: 'user@example.com', - name: 'Test User', - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - image: null, - stripeCustomerId: null, - }, - session: { - id: 'session123', - token: 'token123', - userId: 'user123', - expiresAt: new Date(Date.now() + 86400000), // 1 day from now - createdAt: new Date(), - updatedAt: new Date(), - ipAddress: null, - userAgent: null, - activeOrganizationId: null, - }, - } - - const mockInvitation = { - id: 'invitation123', - workspaceId: 'workspace456', - email: 'invited@example.com', - inviterId: 'inviter789', - status: 'pending', - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return 401 when user is not authenticated', async () => { - vi.mocked(getSession).mockResolvedValue(null) - - const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { - method: 'DELETE', - }) - - const params = Promise.resolve({ id: 'invitation123' }) - const response = await DELETE(req, { params }) - - expect(response).toBeInstanceOf(NextResponse) - const data = await response.json() - expect(response.status).toBe(401) - expect(data).toEqual({ error: 'Unauthorized' }) - }) - - it('should return 404 when invitation does not exist', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession) - - // Mock invitation not found - const mockQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn((callback: (rows: any[]) => any) => { - // Simulate empty rows array - return Promise.resolve(callback([])) - }), - } - vi.mocked(db.select).mockReturnValue(mockQuery as any) - - const req = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', { - method: 'DELETE', - }) - - const params = Promise.resolve({ id: 'non-existent' }) - const response = await DELETE(req, { params }) - - expect(response).toBeInstanceOf(NextResponse) - const data = await response.json() - expect(response.status).toBe(404) - expect(data).toEqual({ error: 'Invitation not found' }) - }) - - it('should return 403 when user does not have admin access', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession) - - // Mock invitation found - const mockQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn((callback: (rows: any[]) => any) => { - // Return the first invitation from the array - return Promise.resolve(callback([mockInvitation])) - }), - } - vi.mocked(db.select).mockReturnValue(mockQuery as any) - - // Mock user does not have admin access - vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false) - - const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { - method: 'DELETE', - }) - - const params = Promise.resolve({ id: 'invitation123' }) - const response = await DELETE(req, { params }) - - expect(response).toBeInstanceOf(NextResponse) - const data = await response.json() - expect(response.status).toBe(403) - expect(data).toEqual({ error: 'Insufficient permissions' }) - expect(hasWorkspaceAdminAccess).toHaveBeenCalledWith('user123', 'workspace456') - }) - - it('should return 400 when trying to delete non-pending invitation', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession) - - // Mock invitation with accepted status - const acceptedInvitation = { ...mockInvitation, status: 'accepted' } - const mockQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn((callback: (rows: any[]) => any) => { - // Return the first invitation from the array - return Promise.resolve(callback([acceptedInvitation])) - }), - } - vi.mocked(db.select).mockReturnValue(mockQuery as any) - - // Mock user has admin access - vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true) - - const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { - method: 'DELETE', - }) - - const params = Promise.resolve({ id: 'invitation123' }) - const response = await DELETE(req, { params }) - - expect(response).toBeInstanceOf(NextResponse) - const data = await response.json() - expect(response.status).toBe(400) - expect(data).toEqual({ error: 'Can only delete pending invitations' }) - }) - - it('should successfully delete pending invitation when user has admin access', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession) - - // Mock invitation found - const mockQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn((callback: (rows: any[]) => any) => { - // Return the first invitation from the array - return Promise.resolve(callback([mockInvitation])) - }), - } - vi.mocked(db.select).mockReturnValue(mockQuery as any) - - // Mock user has admin access - vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true) - - // Mock successful deletion - const mockDelete = { - where: vi.fn().mockResolvedValue({ rowCount: 1 }), - } - vi.mocked(db.delete).mockReturnValue(mockDelete as any) - - const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { - method: 'DELETE', - }) - - const params = Promise.resolve({ id: 'invitation123' }) - const response = await DELETE(req, { params }) - - expect(response).toBeInstanceOf(NextResponse) - const data = await response.json() - expect(response.status).toBe(200) - expect(data).toEqual({ success: true }) - expect(db.delete).toHaveBeenCalledWith(workspaceInvitation) - expect(mockDelete.where).toHaveBeenCalled() - }) - - it('should return 500 when database error occurs', async () => { - vi.mocked(getSession).mockResolvedValue(mockSession) - - // Mock database error - const mockQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn().mockRejectedValue(new Error('Database connection failed')), - } - vi.mocked(db.select).mockReturnValue(mockQuery as any) - - const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', { - method: 'DELETE', - }) - - const params = Promise.resolve({ id: 'invitation123' }) - const response = await DELETE(req, { params }) - - expect(response).toBeInstanceOf(NextResponse) - const data = await response.json() - expect(response.status).toBe(500) - expect(data).toEqual({ error: 'Failed to delete invitation' }) - }) -}) diff --git a/apps/sim/app/api/workspaces/invitations/[id]/route.ts b/apps/sim/app/api/workspaces/invitations/[id]/route.ts deleted file mode 100644 index 27d0dae84b..0000000000 --- a/apps/sim/app/api/workspaces/invitations/[id]/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' -import { db } from '@/db' -import { workspaceInvitation } from '@/db/schema' - -// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // Get the invitation to delete - const invitation = await db - .select({ - id: workspaceInvitation.id, - workspaceId: workspaceInvitation.workspaceId, - email: workspaceInvitation.email, - inviterId: workspaceInvitation.inviterId, - status: workspaceInvitation.status, - }) - .from(workspaceInvitation) - .where(eq(workspaceInvitation.id, id)) - .then((rows) => rows[0]) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - // Check if current user has admin access to the workspace - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) - - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - // Only allow deleting pending invitations - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 }) - } - - // Delete the invitation - await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, id)) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error('Error deleting workspace invitation:', error) - return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts new file mode 100644 index 0000000000..fb6831a0f6 --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -0,0 +1,405 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils' + +/** + * Tests for workspace invitation by ID API route + * Tests GET (details + token acceptance), DELETE (cancellation) + * + * @vitest-environment node + */ + +describe('Workspace Invitation [invitationId] API Route', () => { + const mockUser = { + id: 'user-123', + email: 'test@example.com', + name: 'Test User', + } + + const mockWorkspace = { + id: 'workspace-456', + name: 'Test Workspace', + } + + const mockInvitation = { + id: 'invitation-789', + workspaceId: 'workspace-456', + email: 'invited@example.com', + inviterId: 'inviter-321', + status: 'pending', + token: 'token-abc123', + permissions: 'read', + expiresAt: new Date(Date.now() + 86400000), // 1 day from now + createdAt: new Date(), + updatedAt: new Date(), + } + + let mockDbResults: any[] = [] + let mockGetSession: any + let mockHasWorkspaceAdminAccess: any + let mockTransaction: any + + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + + mockDbResults = [] + mockConsoleLogger() + mockAuth(mockUser) + + vi.doMock('crypto', () => ({ + randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'), + })) + + mockGetSession = vi.fn() + vi.doMock('@/lib/auth', () => ({ + getSession: mockGetSession, + })) + + mockHasWorkspaceAdminAccess = vi.fn() + vi.doMock('@/lib/permissions/utils', () => ({ + hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, + })) + + vi.doMock('@/lib/env', () => ({ + env: { + NEXT_PUBLIC_APP_URL: 'https://test.sim.ai', + }, + })) + + mockTransaction = vi.fn() + const mockDbChain = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((callback: any) => { + const result = mockDbResults.shift() || [] + return callback ? callback(result) : Promise.resolve(result) + }), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + transaction: mockTransaction, + } + + vi.doMock('@/db', () => ({ + db: mockDbChain, + })) + + vi.doMock('@/db/schema', () => ({ + workspaceInvitation: { + id: 'id', + workspaceId: 'workspaceId', + email: 'email', + inviterId: 'inviterId', + status: 'status', + token: 'token', + permissions: 'permissions', + expiresAt: 'expiresAt', + }, + workspace: { + id: 'id', + name: 'name', + }, + user: { + id: 'id', + email: 'email', + }, + permissions: { + id: 'id', + entityType: 'entityType', + entityId: 'entityId', + userId: 'userId', + permissionType: 'permissionType', + }, + })) + + vi.doMock('drizzle-orm', () => ({ + eq: vi.fn((a, b) => ({ type: 'eq', a, b })), + and: vi.fn((...args) => ({ type: 'and', args })), + })) + }) + + describe('GET /api/workspaces/invitations/[invitationId]', () => { + it('should return invitation details when called without token', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue({ user: mockUser }) + + mockDbResults.push([mockInvitation]) + mockDbResults.push([mockWorkspace]) + + const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789') + const params = Promise.resolve({ invitationId: 'invitation-789' }) + + const response = await GET(request, { params }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toMatchObject({ + id: 'invitation-789', + email: 'invited@example.com', + status: 'pending', + workspaceName: 'Test Workspace', + }) + }) + + it('should redirect to login when unauthenticated with token', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue(null) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' + ) + const params = Promise.resolve({ invitationId: 'token-abc123' }) + + const response = await GET(request, { params }) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://test.sim.ai/invite/token-abc123?token=token-abc123' + ) + }) + + it('should accept invitation when called with valid token', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue({ + user: { ...mockUser, email: 'invited@example.com' }, + }) + + mockDbResults.push([mockInvitation]) + mockDbResults.push([mockWorkspace]) + mockDbResults.push([{ ...mockUser, email: 'invited@example.com' }]) + mockDbResults.push([]) + + mockTransaction.mockImplementation(async (callback: any) => { + await callback({ + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(undefined), + }) + }) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' + ) + const params = Promise.resolve({ invitationId: 'token-abc123' }) + + const response = await GET(request, { params }) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w') + }) + + it('should redirect to error page when invitation expired', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue({ + user: { ...mockUser, email: 'invited@example.com' }, + }) + + const expiredInvitation = { + ...mockInvitation, + expiresAt: new Date(Date.now() - 86400000), // 1 day ago + } + + mockDbResults.push([expiredInvitation]) + mockDbResults.push([mockWorkspace]) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' + ) + const params = Promise.resolve({ invitationId: 'token-abc123' }) + + const response = await GET(request, { params }) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://test.sim.ai/invite/invitation-789?error=expired' + ) + }) + + it('should redirect to error page when email mismatch', async () => { + const { GET } = await import('./route') + + mockGetSession.mockResolvedValue({ + user: { ...mockUser, email: 'wrong@example.com' }, + }) + + mockDbResults.push([mockInvitation]) + mockDbResults.push([mockWorkspace]) + mockDbResults.push([{ ...mockUser, email: 'wrong@example.com' }]) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' + ) + const params = Promise.resolve({ invitationId: 'token-abc123' }) + + const response = await GET(request, { params }) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://test.sim.ai/invite/invitation-789?error=email-mismatch' + ) + }) + }) + + describe('DELETE /api/workspaces/invitations/[invitationId]', () => { + it('should return 401 when user is not authenticated', async () => { + const { DELETE } = await import('./route') + + mockGetSession.mockResolvedValue(null) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/invitation-789', + { + method: 'DELETE', + } + ) + const params = Promise.resolve({ invitationId: 'invitation-789' }) + + const response = await DELETE(request, { params }) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + }) + + it('should return 404 when invitation does not exist', async () => { + const { DELETE } = await import('./route') + + mockGetSession.mockResolvedValue({ user: mockUser }) + + mockDbResults.push([]) + + const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', { + method: 'DELETE', + }) + const params = Promise.resolve({ invitationId: 'non-existent' }) + + const response = await DELETE(request, { params }) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data).toEqual({ error: 'Invitation not found' }) + }) + + it('should return 403 when user lacks admin access', async () => { + const { DELETE } = await import('./route') + + mockGetSession.mockResolvedValue({ user: mockUser }) + mockHasWorkspaceAdminAccess.mockResolvedValue(false) + + mockDbResults.push([mockInvitation]) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/invitation-789', + { + method: 'DELETE', + } + ) + const params = Promise.resolve({ invitationId: 'invitation-789' }) + + const response = await DELETE(request, { params }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toEqual({ error: 'Insufficient permissions' }) + expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-123', 'workspace-456') + }) + + it('should return 400 when trying to delete non-pending invitation', async () => { + const { DELETE } = await import('./route') + + mockGetSession.mockResolvedValue({ user: mockUser }) + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + + const acceptedInvitation = { ...mockInvitation, status: 'accepted' } + mockDbResults.push([acceptedInvitation]) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/invitation-789', + { + method: 'DELETE', + } + ) + const params = Promise.resolve({ invitationId: 'invitation-789' }) + + const response = await DELETE(request, { params }) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ error: 'Can only delete pending invitations' }) + }) + + it('should successfully delete pending invitation when user has admin access', async () => { + const { DELETE } = await import('./route') + + mockGetSession.mockResolvedValue({ user: mockUser }) + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + + mockDbResults.push([mockInvitation]) + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/invitation-789', + { + method: 'DELETE', + } + ) + const params = Promise.resolve({ invitationId: 'invitation-789' }) + + const response = await DELETE(request, { params }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ success: true }) + }) + + it('should return 500 when database error occurs', async () => { + vi.resetModules() + + const mockErrorDb = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockRejectedValue(new Error('Database connection failed')), + } + + vi.doMock('@/db', () => ({ db: mockErrorDb })) + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ user: mockUser }), + })) + vi.doMock('@/lib/permissions/utils', () => ({ + hasWorkspaceAdminAccess: vi.fn(), + })) + vi.doMock('@/db/schema', () => ({ + workspaceInvitation: { id: 'id' }, + })) + vi.doMock('drizzle-orm', () => ({ + eq: vi.fn(), + })) + + const { DELETE } = await import('./route') + + const request = new NextRequest( + 'http://localhost/api/workspaces/invitations/invitation-789', + { + method: 'DELETE', + } + ) + const params = Promise.resolve({ invitationId: 'invitation-789' }) + + const response = await DELETE(request, { params }) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data).toEqual({ error: 'Failed to delete invitation' }) + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts new file mode 100644 index 0000000000..8e0878809e --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -0,0 +1,236 @@ +import { randomUUID } from 'crypto' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/env' +import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' +import { db } from '@/db' +import { + permissions, + user, + type WorkspaceInvitationStatus, + workspace, + workspaceInvitation, +} from '@/db/schema' + +// GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ invitationId: string }> } +) { + const { invitationId } = await params + const session = await getSession() + const token = req.nextUrl.searchParams.get('token') + const isAcceptFlow = !!token // If token is provided, this is an acceptance flow + + if (!session?.user?.id) { + // For token-based acceptance flows, redirect to login + if (isAcceptFlow) { + return NextResponse.redirect( + new URL( + `/invite/${invitationId}?token=${token}`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const whereClause = token + ? eq(workspaceInvitation.token, token) + : eq(workspaceInvitation.id, invitationId) + + const invitation = await db + .select() + .from(workspaceInvitation) + .where(whereClause) + .then((rows) => rows[0]) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) + } + + if (new Date() > new Date(invitation.expiresAt)) { + if (isAcceptFlow) { + return NextResponse.redirect( + new URL( + `/invite/${invitation.id}?error=expired`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) + } + + const workspaceDetails = await db + .select() + .from(workspace) + .where(eq(workspace.id, invitation.workspaceId)) + .then((rows) => rows[0]) + + if (!workspaceDetails) { + if (isAcceptFlow) { + return NextResponse.redirect( + new URL( + `/invite/${invitation.id}?error=workspace-not-found`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (isAcceptFlow) { + if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { + return NextResponse.redirect( + new URL( + `/invite/${invitation.id}?error=already-processed`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + + const userEmail = session.user.email.toLowerCase() + const invitationEmail = invitation.email.toLowerCase() + + const userData = await db + .select() + .from(user) + .where(eq(user.id, session.user.id)) + .then((rows) => rows[0]) + + if (!userData) { + return NextResponse.redirect( + new URL( + `/invite/${invitation.id}?error=user-not-found`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + + const isValidMatch = userEmail === invitationEmail + + if (!isValidMatch) { + return NextResponse.redirect( + new URL( + `/invite/${invitation.id}?error=email-mismatch`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + + const existingPermission = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityId, invitation.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) + ) + ) + .then((rows) => rows[0]) + + if (existingPermission) { + await db + .update(workspaceInvitation) + .set({ + status: 'accepted' as WorkspaceInvitationStatus, + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) + + return NextResponse.redirect( + new URL( + `/workspace/${invitation.workspaceId}/w`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + + await db.transaction(async (tx) => { + await tx.insert(permissions).values({ + id: randomUUID(), + entityType: 'workspace' as const, + entityId: invitation.workspaceId, + userId: session.user.id, + permissionType: invitation.permissions || 'read', + createdAt: new Date(), + updatedAt: new Date(), + }) + + await tx + .update(workspaceInvitation) + .set({ + status: 'accepted' as WorkspaceInvitationStatus, + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) + }) + + return NextResponse.redirect( + new URL( + `/workspace/${invitation.workspaceId}/w`, + env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' + ) + ) + } + + return NextResponse.json({ + ...invitation, + workspaceName: workspaceDetails.name, + }) + } catch (error) { + console.error('Error fetching workspace invitation:', error) + return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) + } +} + +// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ invitationId: string }> } +) { + const { invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const invitation = await db + .select({ + id: workspaceInvitation.id, + workspaceId: workspaceInvitation.workspaceId, + email: workspaceInvitation.email, + inviterId: workspaceInvitation.inviterId, + status: workspaceInvitation.status, + }) + .from(workspaceInvitation) + .where(eq(workspaceInvitation.id, invitationId)) + .then((rows) => rows[0]) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) + + if (!hasAdminAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { + return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 }) + } + + await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId)) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting workspace invitation:', error) + return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts deleted file mode 100644 index 0903aca8c1..0000000000 --- a/apps/sim/app/api/workspaces/invitations/accept/route.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { randomUUID } from 'crypto' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { env } from '@/lib/env' -import { db } from '@/db' -import { permissions, user, workspace, workspaceInvitation } from '@/db/schema' - -// Accept an invitation via token -export async function GET(req: NextRequest) { - const token = req.nextUrl.searchParams.get('token') - - if (!token) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=missing-token', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - const session = await getSession() - - if (!session?.user?.id) { - // No need to encode API URL as callback, just redirect to invite page - // The middleware will handle proper login flow and return to invite page - return NextResponse.redirect( - new URL(`/invite/${token}?token=${token}`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai') - ) - } - - try { - // Find the invitation by token - const invitation = await db - .select() - .from(workspaceInvitation) - .where(eq(workspaceInvitation.token, token)) - .then((rows) => rows[0]) - - if (!invitation) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=invalid-token', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Check if invitation has expired - if (new Date() > new Date(invitation.expiresAt)) { - return NextResponse.redirect( - new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai') - ) - } - - // Check if invitation is already accepted - if (invitation.status !== 'pending') { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=already-processed', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Get the user's email from the session - const userEmail = session.user.email.toLowerCase() - const invitationEmail = invitation.email.toLowerCase() - - // Get user data to check email verification status and for error messages - const userData = await db - .select() - .from(user) - .where(eq(user.id, session.user.id)) - .then((rows) => rows[0]) - - if (!userData) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=user-not-found', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Check if the logged-in user's email matches the invitation - const isValidMatch = userEmail === invitationEmail - - if (!isValidMatch) { - return NextResponse.redirect( - new URL( - `/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData.email}`)}`, - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Get the workspace details - const workspaceDetails = await db - .select() - .from(workspace) - .where(eq(workspace.id, invitation.workspaceId)) - .then((rows) => rows[0]) - - if (!workspaceDetails) { - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=workspace-not-found', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Check if user already has permissions for this workspace - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, invitation.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - // User already has permissions, just mark the invitation as accepted and redirect - await db - .update(workspaceInvitation) - .set({ - status: 'accepted', - updatedAt: new Date(), - }) - .where(eq(workspaceInvitation.id, invitation.id)) - - return NextResponse.redirect( - new URL( - `/workspace/${invitation.workspaceId}/w`, - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } - - // Add user permissions and mark invitation as accepted in a transaction - await db.transaction(async (tx) => { - // Create permissions for the user - await tx.insert(permissions).values({ - id: randomUUID(), - entityType: 'workspace' as const, - entityId: invitation.workspaceId, - userId: session.user.id, - permissionType: invitation.permissions || 'read', - createdAt: new Date(), - updatedAt: new Date(), - }) - - // Mark invitation as accepted - await tx - .update(workspaceInvitation) - .set({ - status: 'accepted', - updatedAt: new Date(), - }) - .where(eq(workspaceInvitation.id, invitation.id)) - }) - - // Redirect to the workspace - return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/w`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai') - ) - } catch (error) { - console.error('Error accepting invitation:', error) - return NextResponse.redirect( - new URL( - '/invite/invite-error?reason=server-error', - env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - ) - ) - } -} diff --git a/apps/sim/app/api/workspaces/invitations/details/route.ts b/apps/sim/app/api/workspaces/invitations/details/route.ts deleted file mode 100644 index 971c732c4a..0000000000 --- a/apps/sim/app/api/workspaces/invitations/details/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { db } from '@/db' -import { workspace, workspaceInvitation } from '@/db/schema' - -// Get invitation details by token -export async function GET(req: NextRequest) { - const token = req.nextUrl.searchParams.get('token') - - if (!token) { - return NextResponse.json({ error: 'Token is required' }, { status: 400 }) - } - - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // Find the invitation by token - const invitation = await db - .select() - .from(workspaceInvitation) - .where(eq(workspaceInvitation.token, token)) - .then((rows) => rows[0]) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) - } - - // Check if invitation has expired - if (new Date() > new Date(invitation.expiresAt)) { - return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) - } - - // Get workspace details - const workspaceDetails = await db - .select() - .from(workspace) - .where(eq(workspace.id, invitation.workspaceId)) - .then((rows) => rows[0]) - - if (!workspaceDetails) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - // Return the invitation with workspace name - return NextResponse.json({ - ...invitation, - workspaceName: workspaceDetails.name, - }) - } catch (error) { - console.error('Error fetching workspace invitation:', error) - return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index a5ecbf338a..5889a431e6 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -13,6 +13,7 @@ import { permissions, type permissionTypeEnum, user, + type WorkspaceInvitationStatus, workspace, workspaceInvitation, } from '@/db/schema' @@ -162,7 +163,7 @@ export async function POST(req: NextRequest) { and( eq(workspaceInvitation.workspaceId, workspaceId), eq(workspaceInvitation.email, email), - eq(workspaceInvitation.status, 'pending') + eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus) ) ) .then((rows) => rows[0]) @@ -189,7 +190,7 @@ export async function POST(req: NextRequest) { email, inviterId: session.user.id, role, - status: 'pending', + status: 'pending' as WorkspaceInvitationStatus, token, permissions: permission, expiresAt, @@ -205,6 +206,7 @@ export async function POST(req: NextRequest) { to: email, inviterName: session.user.name || session.user.email || 'A user', workspaceName: workspaceDetails.name, + invitationId: invitationData.id, token: token, }) @@ -220,17 +222,19 @@ async function sendInvitationEmail({ to, inviterName, workspaceName, + invitationId, token, }: { to: string inviterName: string workspaceName: string + invitationId: string token: string }) { try { const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai' - // Always use the client-side invite route with token parameter - const invitationLink = `${baseUrl}/invite/${token}?token=${token}` + // Use invitation ID in path, token in query parameter for security + const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}` const emailHtml = await render( WorkspaceInvitationEmail({ diff --git a/apps/sim/app/chat/[subdomain]/chat-client.css b/apps/sim/app/chat/[subdomain]/chat.css similarity index 100% rename from apps/sim/app/chat/[subdomain]/chat-client.css rename to apps/sim/app/chat/[subdomain]/chat.css diff --git a/apps/sim/app/chat/[subdomain]/chat-client.tsx b/apps/sim/app/chat/[subdomain]/chat.tsx similarity index 99% rename from apps/sim/app/chat/[subdomain]/chat-client.tsx rename to apps/sim/app/chat/[subdomain]/chat.tsx index 482f9e3098..941b65ad29 100644 --- a/apps/sim/app/chat/[subdomain]/chat-client.tsx +++ b/apps/sim/app/chat/[subdomain]/chat.tsx @@ -15,8 +15,8 @@ import { EmailAuth, PasswordAuth, VoiceInterface, -} from '@/app/chat/[subdomain]/components' -import { useAudioStreaming, useChatStreaming } from '@/app/chat/[subdomain]/hooks' +} from '@/app/chat/components' +import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks' const logger = createLogger('ChatClient') diff --git a/apps/sim/app/chat/[subdomain]/layout.tsx b/apps/sim/app/chat/[subdomain]/layout.tsx index d16a72e852..de843b8d4a 100644 --- a/apps/sim/app/chat/[subdomain]/layout.tsx +++ b/apps/sim/app/chat/[subdomain]/layout.tsx @@ -1,7 +1,7 @@ 'use client' import { ThemeProvider } from 'next-themes' -import './chat-client.css' +import './chat.css' export default function ChatLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/sim/app/chat/[subdomain]/page.tsx b/apps/sim/app/chat/[subdomain]/page.tsx index 52162b2c9e..7a005a4dd5 100644 --- a/apps/sim/app/chat/[subdomain]/page.tsx +++ b/apps/sim/app/chat/[subdomain]/page.tsx @@ -1,4 +1,4 @@ -import ChatClient from '@/app/chat/[subdomain]/chat-client' +import ChatClient from '@/app/chat/[subdomain]/chat' export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) { const { subdomain } = await params diff --git a/apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx b/apps/sim/app/chat/components/auth/email/email-auth.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/auth/email/email-auth.tsx rename to apps/sim/app/chat/components/auth/email/email-auth.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx b/apps/sim/app/chat/components/auth/password/password-auth.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/auth/password/password-auth.tsx rename to apps/sim/app/chat/components/auth/password/password-auth.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/components/header-links/header-links.tsx b/apps/sim/app/chat/components/components/header-links/header-links.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/components/header-links/header-links.tsx rename to apps/sim/app/chat/components/components/header-links/header-links.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx b/apps/sim/app/chat/components/components/markdown-renderer/markdown-renderer.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/components/markdown-renderer/markdown-renderer.tsx rename to apps/sim/app/chat/components/components/markdown-renderer/markdown-renderer.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/error-state/error-state.tsx b/apps/sim/app/chat/components/error-state/error-state.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/error-state/error-state.tsx rename to apps/sim/app/chat/components/error-state/error-state.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/header/header.tsx b/apps/sim/app/chat/components/header/header.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/header/header.tsx rename to apps/sim/app/chat/components/header/header.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/index.ts b/apps/sim/app/chat/components/index.ts similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/index.ts rename to apps/sim/app/chat/components/index.ts diff --git a/apps/sim/app/chat/[subdomain]/components/input/input.tsx b/apps/sim/app/chat/components/input/input.tsx similarity index 99% rename from apps/sim/app/chat/[subdomain]/components/input/input.tsx rename to apps/sim/app/chat/components/input/input.tsx index ef0fab9742..7aff2baf47 100644 --- a/apps/sim/app/chat/[subdomain]/components/input/input.tsx +++ b/apps/sim/app/chat/components/input/input.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion' import { Send, Square } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { VoiceInput } from '@/app/chat/[subdomain]/components/input/voice-input' +import { VoiceInput } from '@/app/chat/components/input/voice-input' const PLACEHOLDER_MOBILE = 'Enter a message' const PLACEHOLDER_DESKTOP = 'Enter a message or click the mic to speak' diff --git a/apps/sim/app/chat/[subdomain]/components/input/voice-input.tsx b/apps/sim/app/chat/components/input/voice-input.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/input/voice-input.tsx rename to apps/sim/app/chat/components/input/voice-input.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/loading-state/loading-state.tsx b/apps/sim/app/chat/components/loading-state/loading-state.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/loading-state/loading-state.tsx rename to apps/sim/app/chat/components/loading-state/loading-state.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx b/apps/sim/app/chat/components/message-container/message-container.tsx similarity index 96% rename from apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx rename to apps/sim/app/chat/components/message-container/message-container.tsx index 286d98cc90..8695878e98 100644 --- a/apps/sim/app/chat/[subdomain]/components/message-container/message-container.tsx +++ b/apps/sim/app/chat/components/message-container/message-container.tsx @@ -3,10 +3,7 @@ import { memo, type RefObject } from 'react' import { ArrowDown } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - type ChatMessage, - ClientChatMessage, -} from '@/app/chat/[subdomain]/components/message/message' +import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message' interface ChatMessageContainerProps { messages: ChatMessage[] diff --git a/apps/sim/app/chat/[subdomain]/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/message/components/markdown-renderer.tsx rename to apps/sim/app/chat/components/message/components/markdown-renderer.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/message/message.tsx rename to apps/sim/app/chat/components/message/message.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/voice-interface/components/particles.tsx b/apps/sim/app/chat/components/voice-interface/components/particles.tsx similarity index 100% rename from apps/sim/app/chat/[subdomain]/components/voice-interface/components/particles.tsx rename to apps/sim/app/chat/components/voice-interface/components/particles.tsx diff --git a/apps/sim/app/chat/[subdomain]/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx similarity index 99% rename from apps/sim/app/chat/[subdomain]/components/voice-interface/voice-interface.tsx rename to apps/sim/app/chat/components/voice-interface/voice-interface.tsx index 15efe748fb..96c2300a10 100644 --- a/apps/sim/app/chat/[subdomain]/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx @@ -5,7 +5,7 @@ import { Mic, MicOff, Phone } from 'lucide-react' import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' -import { ParticlesVisualization } from '@/app/chat/[subdomain]/components/voice-interface/components/particles' +import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles' const logger = createLogger('VoiceInterface') diff --git a/apps/sim/app/chat/[subdomain]/hooks/index.ts b/apps/sim/app/chat/hooks/index.ts similarity index 100% rename from apps/sim/app/chat/[subdomain]/hooks/index.ts rename to apps/sim/app/chat/hooks/index.ts diff --git a/apps/sim/app/chat/[subdomain]/hooks/use-audio-streaming.ts b/apps/sim/app/chat/hooks/use-audio-streaming.ts similarity index 100% rename from apps/sim/app/chat/[subdomain]/hooks/use-audio-streaming.ts rename to apps/sim/app/chat/hooks/use-audio-streaming.ts diff --git a/apps/sim/app/chat/[subdomain]/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts similarity index 99% rename from apps/sim/app/chat/[subdomain]/hooks/use-chat-streaming.ts rename to apps/sim/app/chat/hooks/use-chat-streaming.ts index 9bad3adf1e..b8ad400a0d 100644 --- a/apps/sim/app/chat/[subdomain]/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' -import type { ChatMessage } from '@/app/chat/[subdomain]/components/message/message' +import type { ChatMessage } from '@/app/chat/components/message/message' // No longer need complex output extraction - backend handles this import type { ExecutionResult } from '@/executor/types' diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 54ac6d9a64..5afe3d625f 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -1,16 +1,13 @@ 'use client' import { useEffect, useState } from 'react' -import { AlertCircle, CheckCircle2, Mail, UserPlus, Users2 } from 'lucide-react' -import Image from 'next/image' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { LoadingAgent } from '@/components/ui/loading-agent' import { client, useSession } from '@/lib/auth-client' -import { useBrandConfig } from '@/lib/branding/branding' import { createLogger } from '@/lib/logs/console/logger' +import { getErrorMessage } from '@/app/invite/[id]/utils' +import { InviteLayout, InviteStatusCard } from '@/app/invite/components' -const logger = createLogger('InviteByIDAPI') +const logger = createLogger('InviteById') export default function Invite() { const router = useRouter() @@ -18,7 +15,6 @@ export default function Invite() { const inviteId = params.id as string const searchParams = useSearchParams() const { data: session, isPending } = useSession() - const brandConfig = useBrandConfig() const [invitationDetails, setInvitationDetails] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -28,12 +24,18 @@ export default function Invite() { const [token, setToken] = useState(null) const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace') - // Check if this is a new user vs. existing user and get token from query useEffect(() => { + const errorReason = searchParams.get('error') + + if (errorReason) { + setError(getErrorMessage(errorReason)) + setIsLoading(false) + return + } + const isNew = searchParams.get('new') === 'true' setIsNewUser(isNew) - // Get token from URL or use inviteId as token const tokenFromQuery = searchParams.get('token') const effectiveToken = tokenFromQuery || inviteId @@ -43,20 +45,16 @@ export default function Invite() { } }, [searchParams, inviteId]) - // Auto-fetch invitation details when logged in useEffect(() => { if (!session?.user || !token) return async function fetchInvitationDetails() { setIsLoading(true) try { - // First try to fetch workspace invitation details - const workspaceInviteResponse = await fetch( - `/api/workspaces/invitations/details?token=${token}`, - { - method: 'GET', - } - ) + // Fetch invitation details using the invitation ID from the URL path + const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, { + method: 'GET', + }) if (workspaceInviteResponse.ok) { const data = await workspaceInviteResponse.json() @@ -70,7 +68,6 @@ export default function Invite() { return } - // If workspace invitation not found, try organization invitation try { const { data } = await client.organization.getInvitation({ query: { id: inviteId }, @@ -84,7 +81,6 @@ export default function Invite() { name: data.organizationName || 'an organization', }) - // Get organization details if (data.organizationId) { const orgResponse = await client.organization.getFullOrganization({ query: { organizationId: data.organizationId }, @@ -101,7 +97,6 @@ export default function Invite() { throw new Error('Invitation not found or has expired') } } catch (_err) { - // If neither workspace nor organization invitation is found throw new Error('Invitation not found or has expired') } } catch (err: any) { @@ -115,22 +110,19 @@ export default function Invite() { fetchInvitationDetails() }, [session?.user, inviteId, token]) - // Handle invitation acceptance const handleAcceptInvitation = async () => { if (!session?.user) return setIsAccepting(true) if (invitationType === 'workspace') { - window.location.href = `/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}` + window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}` } else { try { - // For organization invites, use the client API const response = await client.organization.acceptInvitation({ invitationId: inviteId, }) - // Set the active organization to the one just joined const orgId = response.data?.invitation.organizationId || invitationDetails?.data?.organizationId @@ -142,7 +134,6 @@ export default function Invite() { setAccepted(true) - // Redirect to workspace after a brief delay setTimeout(() => { router.push('/workspace') }, 2000) @@ -155,281 +146,135 @@ export default function Invite() { } } - // Prepare the callback URL - this ensures after login, user returns to invite page const getCallbackUrl = () => { return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}` } - // Show login/signup prompt if not logged in if (!session?.user && !isPending) { const callbackUrl = encodeURIComponent(getCallbackUrl()) return ( -
-
- Sim Logo -
- -
-
- -
- -

- You've been invited! -

- -

- {isNewUser + + - -

- {isNewUser ? ( - <> - - - - ) : ( - <> - - - - )} - - -
-
- - -
+ : 'Sign in to your account to accept this invitation' + } + icon='userPlus' + actions={[ + ...(isNewUser + ? [ + { + label: 'Create an account', + onClick: () => + router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`), + }, + { + label: 'I already have an account', + onClick: () => + router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), + variant: 'outline' as const, + }, + ] + : [ + { + label: 'Sign in', + onClick: () => + router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), + }, + { + label: 'Create an account', + onClick: () => + router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`), + variant: 'outline' as const, + }, + ]), + { + label: 'Return to Home', + onClick: () => router.push('/'), + }, + ]} + /> + ) } - // Show loading state if (isLoading || isPending) { return ( -
-
- Sim Logo -
- -

Loading invitation...

- - -
+ + + ) } - // Show error state if (error) { + const errorReason = searchParams.get('error') + const isExpiredError = errorReason === 'expired' + return ( -
-
- Sim Logo -
-
-
- -
-

- Invitation Error -

-

{error}

- - -
- - -
+ + router.push('/'), + }, + ]} + /> + ) } - // Show success state if (accepted) { return ( -
-
- Sim Logo -
-
-
- -
-

Welcome!

-

- You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting - to your workspace... -

- - -
- - -
+ + router.push('/'), + }, + ]} + /> + ) } - // Show invitation details return ( -
-
- Sim Logo -
- -
-
- {invitationType === 'organization' ? ( - - ) : ( - - )} -
- -

- {invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'} -

- -

- You've been invited to join{' '} - - {invitationDetails?.name || `a ${invitationType}`} - - . Click accept below to join. -

- -
- - -
-
- - -
+ + router.push('/'), + variant: 'ghost', + }, + ]} + /> + ) } diff --git a/apps/sim/app/invite/[id]/utils.ts b/apps/sim/app/invite/[id]/utils.ts new file mode 100644 index 0000000000..61c90f5867 --- /dev/null +++ b/apps/sim/app/invite/[id]/utils.ts @@ -0,0 +1,28 @@ +export function getErrorMessage(reason: string): string { + switch (reason) { + case 'missing-token': + return 'The invitation link is invalid or missing a required parameter.' + case 'invalid-token': + return 'The invitation link is invalid or has already been used.' + case 'expired': + return 'This invitation has expired. Please ask for a new invitation.' + case 'already-processed': + return 'This invitation has already been accepted or declined.' + case 'email-mismatch': + return 'This invitation was sent to a different email address. Please log in with the correct account or contact the person who invited you.' + case 'workspace-not-found': + return 'The workspace associated with this invitation could not be found.' + case 'user-not-found': + return 'Your user account could not be found. Please try logging out and logging back in.' + case 'already-member': + return 'You are already a member of this organization or workspace.' + case 'invalid-invitation': + return 'This invitation is invalid or no longer exists.' + case 'missing-invitation-id': + return 'The invitation link is missing required information. Please use the original invitation link.' + case 'server-error': + return 'An unexpected error occurred while processing your invitation. Please try again later.' + default: + return 'An unknown error occurred while processing your invitation.' + } +} diff --git a/apps/sim/app/invite/components/index.ts b/apps/sim/app/invite/components/index.ts new file mode 100644 index 0000000000..e95425f803 --- /dev/null +++ b/apps/sim/app/invite/components/index.ts @@ -0,0 +1,2 @@ +export { InviteLayout } from './layout' +export { InviteStatusCard } from './status-card' diff --git a/apps/sim/app/invite/components/layout.tsx b/apps/sim/app/invite/components/layout.tsx new file mode 100644 index 0000000000..a3d01e34d0 --- /dev/null +++ b/apps/sim/app/invite/components/layout.tsx @@ -0,0 +1,56 @@ +'use client' + +import Image from 'next/image' +import { useBrandConfig } from '@/lib/branding/branding' +import { GridPattern } from '@/app/(landing)/components/grid-pattern' + +interface InviteLayoutProps { + children: React.ReactNode +} + +export function InviteLayout({ children }: InviteLayoutProps) { + const brandConfig = useBrandConfig() + + return ( +
+ {/* Background pattern */} +
+ ) +} diff --git a/apps/sim/app/invite/components/status-card.tsx b/apps/sim/app/invite/components/status-card.tsx new file mode 100644 index 0000000000..51f0ca691b --- /dev/null +++ b/apps/sim/app/invite/components/status-card.tsx @@ -0,0 +1,121 @@ +'use client' + +import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { LoadingAgent } from '@/components/ui/loading-agent' + +interface InviteStatusCardProps { + type: 'login' | 'loading' | 'error' | 'success' | 'invitation' + title: string + description: string | React.ReactNode + icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success' + actions?: Array<{ + label: string + onClick: () => void + variant?: 'default' | 'outline' | 'ghost' + disabled?: boolean + loading?: boolean + }> + isExpiredError?: boolean +} + +const iconMap = { + userPlus: UserPlus, + mail: Mail, + users: Users2, + error: ShieldX, + success: CheckCircle2, +} + +const iconColorMap = { + userPlus: 'text-[#701ffc]', + mail: 'text-[#701ffc]', + users: 'text-[#701ffc]', + error: 'text-red-500 dark:text-red-400', + success: 'text-green-500 dark:text-green-400', +} + +const iconBgMap = { + userPlus: 'bg-[#701ffc]/10', + mail: 'bg-[#701ffc]/10', + users: 'bg-[#701ffc]/10', + error: 'bg-red-50 dark:bg-red-950/20', + success: 'bg-green-50 dark:bg-green-950/20', +} + +export function InviteStatusCard({ + type, + title, + description, + icon, + actions = [], + isExpiredError = false, +}: InviteStatusCardProps) { + const router = useRouter() + + if (type === 'loading') { + return ( +
+ +

{description}

+
+ ) + } + + const IconComponent = icon ? iconMap[icon] : null + const iconColor = icon ? iconColorMap[icon] : '' + const iconBg = icon ? iconBgMap[icon] : '' + + return ( +
+ {IconComponent && ( +
+ +
+ )} + +

{title}

+ +

{description}

+ +
+ {isExpiredError && ( + + )} + + {actions.map((action, index) => ( + + ))} +
+
+ ) +} diff --git a/apps/sim/app/invite/invite-error/invite-error.tsx b/apps/sim/app/invite/invite-error/invite-error.tsx deleted file mode 100644 index 8169091f21..0000000000 --- a/apps/sim/app/invite/invite-error/invite-error.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { RotateCcw, ShieldX } from 'lucide-react' -import Image from 'next/image' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { useBrandConfig } from '@/lib/branding/branding' - -function getErrorMessage(reason: string, details?: string): string { - switch (reason) { - case 'missing-token': - return 'The invitation link is invalid or missing a required parameter.' - case 'invalid-token': - return 'The invitation link is invalid or has already been used.' - case 'expired': - return 'This invitation has expired. Please ask for a new invitation.' - case 'already-processed': - return 'This invitation has already been accepted or declined.' - case 'email-mismatch': - return details - ? details - : 'This invitation was sent to a different email address than the one you are logged in with.' - case 'workspace-not-found': - return 'The workspace associated with this invitation could not be found.' - case 'user-not-found': - return 'Your user account could not be found. Please try logging out and logging back in.' - case 'already-member': - return 'You are already a member of this organization or workspace.' - case 'invalid-invitation': - return 'This invitation is invalid or no longer exists.' - case 'missing-invitation-id': - return 'The invitation link is missing required information. Please use the original invitation link.' - case 'server-error': - return 'An unexpected error occurred while processing your invitation. Please try again later.' - default: - return 'An unknown error occurred while processing your invitation.' - } -} - -export default function InviteError() { - const searchParams = useSearchParams() - const reason = searchParams?.get('reason') || 'unknown' - const details = searchParams?.get('details') - const [errorMessage, setErrorMessage] = useState('') - const brandConfig = useBrandConfig() - - useEffect(() => { - // Only set the error message on the client side - setErrorMessage(getErrorMessage(reason, details || undefined)) - }, [reason, details]) - - // Provide a fallback message for SSR - const displayMessage = errorMessage || 'Loading error details...' - - const isExpiredError = reason === 'expired' - - return ( -
- {/* Logo */} -
- Sim Logo -
- -
-
- -
- -

Invitation Error

- -

- {displayMessage} -

- -
- {isExpiredError && ( - - )} - - -
-
- - -
- ) -} diff --git a/apps/sim/app/invite/invite-error/page.tsx b/apps/sim/app/invite/invite-error/page.tsx deleted file mode 100644 index 646f4a3d8e..0000000000 --- a/apps/sim/app/invite/invite-error/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import InviteError from '@/app/invite/invite-error/invite-error' - -export const dynamic = 'force-dynamic' - -export default function InviteErrorPage() { - return -} diff --git a/apps/sim/app/unsubscribe/page.tsx b/apps/sim/app/unsubscribe/page.tsx index 658de1ee5d..c9ca1f2693 100644 --- a/apps/sim/app/unsubscribe/page.tsx +++ b/apps/sim/app/unsubscribe/page.tsx @@ -1,401 +1,3 @@ -'use client' +import Unsubscribe from './unsubscribe' -import { Suspense, useEffect, useState } from 'react' -import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react' -import { useSearchParams } from 'next/navigation' -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { useBrandConfig } from '@/lib/branding/branding' - -interface UnsubscribeData { - success: boolean - email: string - token: string - emailType: string - isTransactional: boolean - currentPreferences: { - unsubscribeAll?: boolean - unsubscribeMarketing?: boolean - unsubscribeUpdates?: boolean - unsubscribeNotifications?: boolean - } -} - -function UnsubscribeContent() { - const searchParams = useSearchParams() - const [loading, setLoading] = useState(true) - const [data, setData] = useState(null) - const [error, setError] = useState(null) - const [processing, setProcessing] = useState(false) - const [unsubscribed, setUnsubscribed] = useState(false) - const brand = useBrandConfig() - - const email = searchParams.get('email') - const token = searchParams.get('token') - - useEffect(() => { - if (!email || !token) { - setError('Missing email or token in URL') - setLoading(false) - return - } - - // Validate the unsubscribe link - fetch( - `/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}` - ) - .then((res) => res.json()) - .then((data) => { - if (data.success) { - setData(data) - } else { - setError(data.error || 'Invalid unsubscribe link') - } - }) - .catch(() => { - setError('Failed to validate unsubscribe link') - }) - .finally(() => { - setLoading(false) - }) - }, [email, token]) - - const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => { - if (!email || !token) return - - setProcessing(true) - - try { - const response = await fetch('/api/users/me/settings/unsubscribe', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - token, - type, - }), - }) - - const result = await response.json() - - if (result.success) { - setUnsubscribed(true) - // Update the data to reflect the change - if (data) { - // Type-safe property construction with validation - const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const - if (validTypes.includes(type)) { - if (type === 'all') { - setData({ - ...data, - currentPreferences: { - ...data.currentPreferences, - unsubscribeAll: true, - }, - }) - } else { - const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as - | 'unsubscribeMarketing' - | 'unsubscribeUpdates' - | 'unsubscribeNotifications' - setData({ - ...data, - currentPreferences: { - ...data.currentPreferences, - [propertyKey]: true, - }, - }) - } - } - } - } else { - setError(result.error || 'Failed to unsubscribe') - } - } catch (error) { - setError('Failed to process unsubscribe request') - } finally { - setProcessing(false) - } - } - - if (loading) { - return ( -
- - - - - -
- ) - } - - if (error) { - return ( -
- - - - Invalid Unsubscribe Link - - This unsubscribe link is invalid or has expired - - - -
-

- Error: {error} -

-
- -
-

This could happen if:

-
    -
  • The link is missing required parameters
  • -
  • The link has expired or been used already
  • -
  • The link was copied incorrectly
  • -
-
- -
- - -
- -
-

- Need immediate help? Email us at{' '} - - {brand.supportEmail} - -

-
-
-
-
- ) - } - - // Handle transactional emails - if (data?.isTransactional) { - return ( -
- - - - Important Account Emails - - This email contains important information about your account - - - -
-

- Transactional emails like password resets, account confirmations, - and security alerts cannot be unsubscribed from as they contain essential - information for your account security and functionality. -

-
- -
-

- If you no longer wish to receive these emails, you can: -

-
    -
  • Close your account entirely
  • -
  • Contact our support team for assistance
  • -
-
- -
- - -
-
-
-
- ) - } - - if (unsubscribed) { - return ( -
- - - - Successfully Unsubscribed - - You have been unsubscribed from our emails. You will stop receiving emails within 48 - hours. - - - -

- If you change your mind, you can always update your email preferences in your account - settings or contact us at{' '} - - {brand.supportEmail} - -

-
-
-
- ) - } - - return ( -
- - - - We're sorry to see you go! - - We understand email preferences are personal. Choose which emails you'd like to - stop receiving from Sim. - -
-

- Email: {data?.email} -

-
-
- -
- - -
- or choose specific types: -
- - - - - - -
- -
-
-

- Note: You'll continue receiving important account emails like - password resets and security alerts. -

-
- -

- Questions? Contact us at{' '} - - {brand.supportEmail} - -

-
-
-
-
- ) -} - -export default function UnsubscribePage() { - return ( - - - - - - -
- } - > - - - ) -} +export default Unsubscribe diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx new file mode 100644 index 0000000000..58de6e18b0 --- /dev/null +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -0,0 +1,401 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react' +import { useSearchParams } from 'next/navigation' +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' +import { useBrandConfig } from '@/lib/branding/branding' + +interface UnsubscribeData { + success: boolean + email: string + token: string + emailType: string + isTransactional: boolean + currentPreferences: { + unsubscribeAll?: boolean + unsubscribeMarketing?: boolean + unsubscribeUpdates?: boolean + unsubscribeNotifications?: boolean + } +} + +function UnsubscribeContent() { + const searchParams = useSearchParams() + const [loading, setLoading] = useState(true) + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [processing, setProcessing] = useState(false) + const [unsubscribed, setUnsubscribed] = useState(false) + const brand = useBrandConfig() + + const email = searchParams.get('email') + const token = searchParams.get('token') + + useEffect(() => { + if (!email || !token) { + setError('Missing email or token in URL') + setLoading(false) + return + } + + // Validate the unsubscribe link + fetch( + `/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}` + ) + .then((res) => res.json()) + .then((data) => { + if (data.success) { + setData(data) + } else { + setError(data.error || 'Invalid unsubscribe link') + } + }) + .catch(() => { + setError('Failed to validate unsubscribe link') + }) + .finally(() => { + setLoading(false) + }) + }, [email, token]) + + const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => { + if (!email || !token) return + + setProcessing(true) + + try { + const response = await fetch('/api/users/me/settings/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + token, + type, + }), + }) + + const result = await response.json() + + if (result.success) { + setUnsubscribed(true) + // Update the data to reflect the change + if (data) { + // Type-safe property construction with validation + const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const + if (validTypes.includes(type)) { + if (type === 'all') { + setData({ + ...data, + currentPreferences: { + ...data.currentPreferences, + unsubscribeAll: true, + }, + }) + } else { + const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as + | 'unsubscribeMarketing' + | 'unsubscribeUpdates' + | 'unsubscribeNotifications' + setData({ + ...data, + currentPreferences: { + ...data.currentPreferences, + [propertyKey]: true, + }, + }) + } + } + } + } else { + setError(result.error || 'Failed to unsubscribe') + } + } catch (error) { + setError('Failed to process unsubscribe request') + } finally { + setProcessing(false) + } + } + + if (loading) { + return ( +
+ + + + + +
+ ) + } + + if (error) { + return ( +
+ + + + Invalid Unsubscribe Link + + This unsubscribe link is invalid or has expired + + + +
+

+ Error: {error} +

+
+ +
+

This could happen if:

+
    +
  • The link is missing required parameters
  • +
  • The link has expired or been used already
  • +
  • The link was copied incorrectly
  • +
+
+ +
+ + +
+ +
+

+ Need immediate help? Email us at{' '} + + {brand.supportEmail} + +

+
+
+
+
+ ) + } + + // Handle transactional emails + if (data?.isTransactional) { + return ( +
+ + + + Important Account Emails + + This email contains important information about your account + + + +
+

+ Transactional emails like password resets, account confirmations, + and security alerts cannot be unsubscribed from as they contain essential + information for your account security and functionality. +

+
+ +
+

+ If you no longer wish to receive these emails, you can: +

+
    +
  • Close your account entirely
  • +
  • Contact our support team for assistance
  • +
+
+ +
+ + +
+
+
+
+ ) + } + + if (unsubscribed) { + return ( +
+ + + + Successfully Unsubscribed + + You have been unsubscribed from our emails. You will stop receiving emails within 48 + hours. + + + +

+ If you change your mind, you can always update your email preferences in your account + settings or contact us at{' '} + + {brand.supportEmail} + +

+
+
+
+ ) + } + + return ( +
+ + + + We're sorry to see you go! + + We understand email preferences are personal. Choose which emails you'd like to + stop receiving from Sim. + +
+

+ Email: {data?.email} +

+
+
+ +
+ + +
+ or choose specific types: +
+ + + + + + +
+ +
+
+

+ Note: You'll continue receiving important account emails like + password resets and security alerts. +

+
+ +

+ Questions? Contact us at{' '} + + {brand.supportEmail} + +

+
+
+
+
+ ) +} + +export default function Unsubscribe() { + return ( + + + + + + + + } + > + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx index fbf4e5357e..90fa03a6df 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx @@ -26,7 +26,7 @@ export default function Workflow() { const fetchWorkflows = async () => { try { setLoading(true) - const response = await fetch('/api/workflows/sync') + const response = await fetch('/api/workflows') if (response.ok) { const { data } = await response.json() const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas.tsx index b506bfefac..9adb54cdf6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas.tsx @@ -502,7 +502,7 @@ export function FrozenCanvas({ setLoading(true) setError(null) - const response = await fetch(`/api/logs/${executionId}/frozen-canvas`) + const response = await fetch(`/api/logs/execution/${executionId}`) if (!response.ok) { throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 68a46a7d9e..aa51ac7ef0 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -161,7 +161,7 @@ export default function Logs() { Promise.all( idsToFetch.map(async ({ id, merge }) => { try { - const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal }) + const res = await fetch(`/api/logs/${id}`, { signal: controller.signal }) if (!res.ok) return const body = await res.json() const detailed = body?.data @@ -216,7 +216,7 @@ export default function Logs() { Promise.all( idsToFetch.map(async ({ id, merge }) => { try { - const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal }) + const res = await fetch(`/api/logs/${id}`, { signal: controller.signal }) if (!res.ok) return const body = await res.json() const detailed = body?.data @@ -274,7 +274,7 @@ export default function Logs() { Promise.all( idsToFetch.map(async ({ id, merge }) => { try { - const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal }) + const res = await fetch(`/api/logs/${id}`, { signal: controller.signal }) if (!res.ok) return const body = await res.json() const detailed = body?.data diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx index 62d220c9c8..fd2b7d677b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx @@ -79,7 +79,7 @@ export function ExampleCommand({ case 'rate-limits': { const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0] return `curl -H "X-API-Key: ${apiKey}" \\ - ${baseUrlForRateLimit}/api/users/rate-limit` + ${baseUrlForRateLimit}/api/users/me/rate-limit` } default: @@ -119,7 +119,7 @@ export function ExampleCommand({ case 'rate-limits': { const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0] return `curl -H "X-API-Key: SIM_API_KEY" \\ - ${baseUrlForRateLimit}/api/users/rate-limit` + ${baseUrlForRateLimit}/api/users/me/rate-limit` } default: diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index b453c54139..4eb3d8e46d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -321,7 +321,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { try { // Primary: call server-side usage check to mirror backend enforcement - const res = await fetch('/api/usage/check', { cache: 'no-store' }) + const res = await fetch('/api/usage?context=user', { cache: 'no-store' }) if (res.ok) { const payload = await res.json() const usage = payload?.data diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 756b7c8965..4e763ea3dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -385,7 +385,7 @@ const UserInput = forwardRef( if (isLoadingWorkflows || workflows.length > 0) return try { setIsLoadingWorkflows(true) - const resp = await fetch('/api/workflows/sync') + const resp = await fetch('/api/workflows') if (!resp.ok) throw new Error(`Failed to load workflows: ${resp.status}`) const data = await resp.json() const items = Array.isArray(data?.data) ? data.data : [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index 51e6a3cb9d..c991c07be0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -23,7 +23,6 @@ import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/s const logger = createLogger('EnvironmentVariables') -// Constants const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),88px] gap-4' const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' } @@ -66,7 +65,6 @@ export function EnvironmentVariables({ const pendingClose = useRef(false) const initialVarsRef = useRef([]) - // Filter environment variables based on search term const filteredEnvVars = useMemo(() => { if (!searchTerm.trim()) { return envVars.map((envVar, index) => ({ envVar, originalIndex: index })) @@ -77,7 +75,6 @@ export function EnvironmentVariables({ .filter(({ envVar }) => envVar.key.toLowerCase().includes(searchTerm.toLowerCase())) }, [envVars, searchTerm]) - // Derived state const hasChanges = useMemo(() => { const initialVars = initialVarsRef.current.filter((v) => v.key || v.value) const currentVars = envVars.filter((v) => v.key || v.value) @@ -96,7 +93,6 @@ export function EnvironmentVariables({ if (!currentMap.has(key)) return true } - // Workspace diffs const before = initialWorkspaceVarsRef.current const after = workspaceVars const beforeKeys = Object.keys(before) @@ -109,12 +105,10 @@ export function EnvironmentVariables({ return false }, [envVars, workspaceVars]) - // Check if there are any active conflicts const hasConflicts = useMemo(() => { return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)) }, [envVars, workspaceVars]) - // Intercept close attempts to check for unsaved changes const handleModalClose = (open: boolean) => { if (!open && hasChanges) { setShowUnsavedChanges(true) @@ -124,7 +118,6 @@ export function EnvironmentVariables({ } } - // Initialization effect useEffect(() => { const existingVars = Object.values(variables) const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR] @@ -158,14 +151,12 @@ export function EnvironmentVariables({ } }, [workspaceId, loadWorkspaceEnvironment]) - // Register close handler with parent useEffect(() => { if (registerCloseHandler) { registerCloseHandler(handleModalClose) } }, [registerCloseHandler, hasChanges]) - // Scroll effect - only when explicitly adding a new variable useEffect(() => { if (shouldScrollToBottom && scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ @@ -322,8 +313,7 @@ export function EnvironmentVariables({ }), {} ) - - useEnvironmentStore.getState().setVariables(validVariables) + await useEnvironmentStore.getState().saveEnvironmentVariables(validVariables) const before = initialWorkspaceVarsRef.current const after = workspaceVars @@ -354,7 +344,6 @@ export function EnvironmentVariables({ } } - // UI rendering const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => { const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx index 4f64d889bf..186cc874d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx @@ -113,7 +113,7 @@ export const UsageLimit = forwardRef( throw new Error('Organization ID is required') } - const response = await fetch('/api/usage-limits', { + const response = await fetch('/api/usage', { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 8dfc9bccce..3d52aa3d18 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -16,11 +16,10 @@ import { // Get current Ollama models dynamically const getCurrentOllamaModels = () => { - return useOllamaStore.getState().models + return useProvidersStore.getState().providers.ollama.models } -import { useOllamaStore } from '@/stores/ollama/store' -import { useOpenRouterStore } from '@/stores/openrouter/store' +import { useProvidersStore } from '@/stores/providers/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('AgentBlock') @@ -158,8 +157,9 @@ Create a system prompt appropriately detailed for the request, using clear langu placeholder: 'Type or select a model...', required: true, options: () => { - const ollamaModels = useOllamaStore.getState().models - const openrouterModels = useOpenRouterStore.getState().models + const providersState = useProvidersStore.getState() + const ollamaModels = providersState.providers.ollama.models + const openrouterModels = providersState.providers.openrouter.models const baseModels = Object.keys(getBaseModelProviders()) const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels])) diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 0f3e05ea81..52e0899ed9 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig, ParamType } from '@/blocks/types' import type { ProviderId } from '@/providers/types' import { getAllModelProviders, getBaseModelProviders, getHostedModels } from '@/providers/utils' -import { useOllamaStore } from '@/stores/ollama/store' +import { useProvidersStore } from '@/stores/providers/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('EvaluatorBlock') @@ -177,7 +177,7 @@ export const EvaluatorBlock: BlockConfig = { layout: 'half', required: true, options: () => { - const ollamaModels = useOllamaStore.getState().models + const ollamaModels = useProvidersStore.getState().providers.ollama.models const baseModels = Object.keys(getBaseModelProviders()) return [...baseModels, ...ollamaModels].map((model) => ({ label: model, diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 825dec5a03..7fe72de2f2 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -3,7 +3,7 @@ import { isHosted } from '@/lib/environment' import type { BlockConfig } from '@/blocks/types' import type { ProviderId } from '@/providers/types' import { getAllModelProviders, getBaseModelProviders, getHostedModels } from '@/providers/utils' -import { useOllamaStore } from '@/stores/ollama/store' +import { useProvidersStore } from '@/stores/providers/store' import type { ToolResponse } from '@/tools/types' interface RouterResponse extends ToolResponse { @@ -119,7 +119,7 @@ export const RouterBlock: BlockConfig = { type: 'dropdown', layout: 'half', options: () => { - const ollamaModels = useOllamaStore.getState().models + const ollamaModels = useProvidersStore.getState().providers.ollama.models const baseModels = Object.keys(getBaseModelProviders()) return [...baseModels, ...ollamaModels].map((model) => ({ label: model, diff --git a/apps/sim/components/emails/workspace-invitation.tsx b/apps/sim/components/emails/workspace-invitation.tsx index ce03cb8576..81834cecbd 100644 --- a/apps/sim/components/emails/workspace-invitation.tsx +++ b/apps/sim/components/emails/workspace-invitation.tsx @@ -39,8 +39,11 @@ export const WorkspaceInvitationEmail = ({ let enhancedLink = invitationLink try { - // If the link is pointing to the API endpoint directly, update it to use the client route - if (invitationLink.includes('/api/workspaces/invitations/accept')) { + // If the link is pointing to any API endpoint directly, update it to use the client route + if ( + invitationLink.includes('/api/workspaces/invitations/accept') || + invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/) + ) { const url = new URL(invitationLink) const token = url.searchParams.get('token') if (token) { diff --git a/apps/sim/db/migrations/0083_ambiguous_dreadnoughts.sql b/apps/sim/db/migrations/0083_ambiguous_dreadnoughts.sql new file mode 100644 index 0000000000..1fa1265ddd --- /dev/null +++ b/apps/sim/db/migrations/0083_ambiguous_dreadnoughts.sql @@ -0,0 +1,3 @@ +CREATE TYPE "public"."workspace_invitation_status" AS ENUM('pending', 'accepted', 'rejected', 'cancelled');--> statement-breakpoint +ALTER TABLE "workspace_invitation" ALTER COLUMN "status" SET DEFAULT 'pending'::"public"."workspace_invitation_status";--> statement-breakpoint +ALTER TABLE "workspace_invitation" ALTER COLUMN "status" SET DATA TYPE "public"."workspace_invitation_status" USING "status"::"public"."workspace_invitation_status"; \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0083_snapshot.json b/apps/sim/db/migrations/meta/0083_snapshot.json new file mode 100644 index 0000000000..e4f270a28a --- /dev/null +++ b/apps/sim/db/migrations/meta/0083_snapshot.json @@ -0,0 +1,6049 @@ +{ + "id": "f4d42c71-1238-4445-b29d-7cc74d1f748c", + "prevId": "5466d2f8-8b28-47d3-816f-07bbb3721d3d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subdomain_idx": { + "name": "subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_uploaded_at_idx": { + "name": "doc_kb_uploaded_at_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_fill_env_vars": { + "name": "auto_fill_env_vars", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_pan": { + "name": "auto_pan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "console_expanded_by_default": { + "name": "console_expanded_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'FileText'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workflow_id_idx": { + "name": "templates_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_id_idx": { + "name": "templates_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_idx": { + "name": "templates_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_views_idx": { + "name": "templates_category_views_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_stars_idx": { + "name": "templates_category_stars_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_category_idx": { + "name": "templates_user_category_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "templates_user_id_user_id_fk": { + "name": "templates_user_id_user_id_fk", + "tableFrom": "templates", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_rate_limits": { + "name": "user_rate_limits", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sync_api_requests": { + "name": "sync_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "async_api_requests": { + "name": "async_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_rate_limited": { + "name": "is_rate_limited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_reset_at": { + "name": "rate_limit_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_rate_limits_user_id_user_id_fk": { + "name": "user_rate_limits_user_id_user_id_fk", + "tableFrom": "user_rate_limits", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'10'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned_api_key": { + "name": "pinned_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extent": { + "name": "extent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_parent_id_idx": { + "name": "workflow_blocks_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_parent_idx": { + "name": "workflow_blocks_workflow_parent_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_type_idx": { + "name": "workflow_blocks_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_source_block_idx": { + "name": "workflow_edges_source_block_idx", + "columns": [ + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_target_block_idx": { + "name": "workflow_edges_target_block_idx", + "columns": [ + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_idx": { + "name": "workflow_execution_logs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_environment_workspace_id_idx": { + "name": "workspace_environment_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/sim/db/migrations/meta/_journal.json b/apps/sim/db/migrations/meta/_journal.json index 7de75ccfb1..18ceddc8dc 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.json @@ -575,6 +575,13 @@ "when": 1756767479124, "tag": "0082_light_blockbuster", "breakpoints": true + }, + { + "idx": 83, + "version": "7", + "when": 1756768177306, + "tag": "0083_ambiguous_dreadnoughts", + "breakpoints": true } ] } diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index f125f14c54..ef03599019 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -641,9 +641,17 @@ export const workspace = pgTable('workspace', { updatedAt: timestamp('updated_at').notNull().defaultNow(), }) -// Define the permission enum export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read']) +export const workspaceInvitationStatusEnum = pgEnum('workspace_invitation_status', [ + 'pending', + 'accepted', + 'rejected', + 'cancelled', +]) + +export type WorkspaceInvitationStatus = (typeof workspaceInvitationStatusEnum.enumValues)[number] + export const workspaceInvitation = pgTable('workspace_invitation', { id: text('id').primaryKey(), workspaceId: text('workspace_id') @@ -654,7 +662,7 @@ export const workspaceInvitation = pgTable('workspace_invitation', { .notNull() .references(() => user.id, { onDelete: 'cascade' }), role: text('role').notNull().default('member'), - status: text('status').notNull().default('pending'), + status: workspaceInvitationStatusEnum('status').notNull().default('pending'), token: text('token').notNull().unique(), permissions: permissionTypeEnum('permissions').notNull().default('admin'), orgInvitationId: text('org_invitation_id'), diff --git a/apps/sim/hooks/use-subscription-state.ts b/apps/sim/hooks/use-subscription-state.ts index 0eeddeb519..6ef937d14a 100644 --- a/apps/sim/hooks/use-subscription-state.ts +++ b/apps/sim/hooks/use-subscription-state.ts @@ -153,7 +153,7 @@ export function useUsageLimit() { setIsLoading(true) setError(null) - const response = await fetch('/api/usage-limits?context=user') + const response = await fetch('/api/usage?context=user') if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) @@ -180,7 +180,7 @@ export function useUsageLimit() { const updateLimit = async (newLimit: number) => { try { - const response = await fetch('/api/usage-limits?context=user', { + const response = await fetch('/api/usage?context=user', { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index d2c43755aa..74808ffe73 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -114,7 +114,6 @@ export async function getOrganizationSeatInfo( organizationId: string ): Promise { try { - // Get organization details const organizationData = await db .select({ id: organization.id, @@ -128,14 +127,12 @@ export async function getOrganizationSeatInfo( return null } - // Get organization subscription directly (referenceId = organizationId) const subscription = await getOrganizationSubscription(organizationId) if (!subscription) { return null } - // Get current member count const memberCount = await db .select({ count: count() }) .from(member) @@ -143,11 +140,8 @@ export async function getOrganizationSeatInfo( const currentSeats = memberCount[0]?.count || 0 - // Determine seat limits const maxSeats = subscription.seats || 1 - // Enterprise plans have fixed seats (can't self-serve changes) - // Team plans can add seats through Stripe const canAddSeats = subscription.plan !== 'enterprise' const availableSeats = Math.max(0, maxSeats - currentSeats) @@ -183,14 +177,12 @@ export async function validateBulkInvitations( validationResult: SeatValidationResult }> { try { - // Remove duplicates and validate email format const uniqueEmails = [...new Set(emailList)] const validEmails = uniqueEmails.filter( (email) => quickValidateEmail(email.trim().toLowerCase()).isValid ) const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index) - // Check for existing members const existingMembers = await db .select({ userEmail: user.email }) .from(member) @@ -200,7 +192,6 @@ export async function validateBulkInvitations( const existingEmails = existingMembers.map((m) => m.userEmail) const newEmails = validEmails.filter((email) => !existingEmails.includes(email)) - // Check for pending invitations const pendingInvitations = await db .select({ email: invitation.email }) .from(invitation) @@ -209,7 +200,6 @@ export async function validateBulkInvitations( const pendingEmails = pendingInvitations.map((i) => i.email) const finalEmailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email)) - // Validate seat availability const seatsNeeded = finalEmailsToInvite.length const validationResult = await validateSeatAvailability(organizationId, seatsNeeded) @@ -258,14 +248,12 @@ export async function updateOrganizationSeats( updatedBy: string ): Promise<{ success: boolean; error?: string }> { try { - // Get current organization subscription directly (referenceId = organizationId) const subscriptionRecord = await getOrganizationSubscription(organizationId) if (!subscriptionRecord) { return { success: false, error: 'No active subscription found' } } - // Validate minimum seat requirements const memberCount = await db .select({ count: count() }) .from(member) @@ -280,7 +268,6 @@ export async function updateOrganizationSeats( } } - // Update subscription seat count await db .update(subscription) .set({ @@ -320,7 +307,6 @@ export async function validateMemberRemoval( removedBy: string ): Promise<{ canRemove: boolean; reason?: string }> { try { - // Get member details const memberRecord = await db .select({ role: member.role }) .from(member) @@ -331,12 +317,10 @@ export async function validateMemberRemoval( return { canRemove: false, reason: 'Member not found in organization' } } - // Check if trying to remove the organization owner if (memberRecord[0].role === 'owner') { return { canRemove: false, reason: 'Cannot remove organization owner' } } - // Check if the person removing has sufficient permissions const removerMemberRecord = await db .select({ role: member.role }) .from(member) @@ -350,22 +334,18 @@ export async function validateMemberRemoval( const removerRole = removerMemberRecord[0].role const targetRole = memberRecord[0].role - // Permission hierarchy: owner > admin > member if (removerRole === 'owner') { - // Owners can remove anyone except themselves return userIdToRemove === removedBy ? { canRemove: false, reason: 'Cannot remove yourself as owner' } : { canRemove: true } } if (removerRole === 'admin') { - // Admins can remove members but not other admins or owners return targetRole === 'member' ? { canRemove: true } : { canRemove: false, reason: 'Insufficient permissions to remove this member' } } - // Members cannot remove other members return { canRemove: false, reason: 'Insufficient permissions' } } catch (error) { logger.error('Failed to validate member removal', { @@ -390,7 +370,6 @@ export async function getOrganizationSeatAnalytics(organizationId: string) { return null } - // Get member activity data const memberActivity = await db .select({ userId: member.userId, @@ -405,7 +384,6 @@ export async function getOrganizationSeatAnalytics(organizationId: string) { .leftJoin(userStats, eq(member.userId, userStats.userId)) .where(eq(member.organizationId, organizationId)) - // Calculate utilization metrics const utilizationRate = seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0 diff --git a/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts b/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts index 9094c2792d..c62f11e75e 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts @@ -31,7 +31,7 @@ export class ListUserWorkflowsClientTool extends BaseClientTool { try { this.setState(ClientToolCallState.executing) - const res = await fetch('/api/workflows/sync', { method: 'GET' }) + const res = await fetch('/api/workflows', { method: 'GET' }) if (!res.ok) { const text = await res.text().catch(() => '') await this.markToolComplete(res.status, text || 'Failed to fetch workflows') diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 3bf99217b9..c529ce0420 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -14,7 +14,7 @@ import { prepareToolsWithUsageControl, trackForcedToolUsage, } from '@/providers/utils' -import { useOllamaStore } from '@/stores/ollama/store' +import { useProvidersStore } from '@/stores/providers/store' import { executeTool } from '@/tools' const logger = createLogger('OllamaProvider') @@ -78,13 +78,13 @@ export const ollamaProvider: ProviderConfig = { try { const response = await fetch(`${OLLAMA_HOST}/api/tags`) if (!response.ok) { - useOllamaStore.getState().setModels([]) + useProvidersStore.getState().setModels('ollama', []) logger.warn('Ollama service is not available. The provider will be disabled.') return } const data = (await response.json()) as ModelsObject this.models = data.models.map((model) => model.name) - useOllamaStore.getState().setModels(this.models) + useProvidersStore.getState().setModels('ollama', this.models) } catch (error) { logger.warn('Ollama model instantiation failed. The provider will be disabled.', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 96240d8e03..5a71dd4cc6 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -30,7 +30,7 @@ import { openRouterProvider } from '@/providers/openrouter' import type { ProviderConfig, ProviderId, ProviderToolConfig } from '@/providers/types' import { xAIProvider } from '@/providers/xai' import { useCustomToolsStore } from '@/stores/custom-tools/store' -import { useOllamaStore } from '@/stores/ollama/store' +import { useProvidersStore } from '@/stores/providers/store' const logger = createLogger('ProviderUtils') @@ -576,7 +576,8 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const hasUserKey = !!userProvidedKey // Ollama models don't require API keys - they run locally - const isOllamaModel = provider === 'ollama' || useOllamaStore.getState().models.includes(model) + const isOllamaModel = + provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model) if (isOllamaModel) { return 'empty' // Ollama uses 'empty' as a placeholder API key } diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index eabae88e75..1438aee028 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -1,5 +1,4 @@ export const API_ENDPOINTS = { - SYNC: '/api/workflows/sync', ENVIRONMENT: '/api/environment', SCHEDULE: '/api/schedules', SETTINGS: '/api/settings', @@ -8,9 +7,6 @@ export const API_ENDPOINTS = { WORKSPACE_ENVIRONMENT: (id: string) => `/api/workspaces/${id}/environment`, } -// Removed SYNC_INTERVALS - Socket.IO handles real-time sync - -// Copilot tool display names - shared between client and server export const COPILOT_TOOL_DISPLAY_NAMES: Record = { search_documentation: 'Searching documentation', get_user_workflow: 'Analyzing your workflow', @@ -30,7 +26,6 @@ export const COPILOT_TOOL_DISPLAY_NAMES: Record = { reason: 'Reasoning about your workflow', } as const -// Past tense versions for completed tool calls export const COPILOT_TOOL_PAST_TENSE: Record = { search_documentation: 'Searched documentation', get_user_workflow: 'Analyzed your workflow', @@ -50,7 +45,6 @@ export const COPILOT_TOOL_PAST_TENSE: Record = { reason: 'Finished reasoning', } as const -// Error versions for failed tool calls export const COPILOT_TOOL_ERROR_NAMES: Record = { search_documentation: 'Errored searching documentation', get_user_workflow: 'Errored analyzing your workflow', diff --git a/apps/sim/stores/ollama/store.ts b/apps/sim/stores/ollama/store.ts deleted file mode 100644 index 4d52d15160..0000000000 --- a/apps/sim/stores/ollama/store.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { create } from 'zustand' -import { createLogger } from '@/lib/logs/console/logger' -import { updateOllamaProviderModels } from '@/providers/utils' -import type { OllamaStore } from '@/stores/ollama/types' - -const logger = createLogger('OllamaStore') - -// Fetch models from the server API when on client side -const fetchOllamaModels = async (): Promise => { - try { - const response = await fetch('/api/providers/ollama/models') - if (!response.ok) { - logger.warn('Failed to fetch Ollama models from API', { - status: response.status, - statusText: response.statusText, - }) - return [] - } - const data = await response.json() - return data.models || [] - } catch (error) { - logger.error('Error fetching Ollama models', { - error: error instanceof Error ? error.message : 'Unknown error', - }) - return [] - } -} - -export const useOllamaStore = create((set, get) => ({ - models: [], - isLoading: false, - setModels: (models) => { - set({ models }) - // Update the providers when models change - updateOllamaProviderModels(models) - }, - - // Fetch models from API (client-side only) - fetchModels: async () => { - if (typeof window === 'undefined') { - logger.info('Skipping client-side model fetch on server') - return - } - - if (get().isLoading) { - logger.info('Model fetch already in progress') - return - } - - logger.info('Fetching Ollama models from API') - set({ isLoading: true }) - - try { - const models = await fetchOllamaModels() - logger.info('Successfully fetched Ollama models', { - count: models.length, - models, - }) - get().setModels(models) - } catch (error) { - logger.error('Failed to fetch Ollama models', { - error: error instanceof Error ? error.message : 'Unknown error', - }) - } finally { - set({ isLoading: false }) - } - }, -})) - -// Auto-fetch models when the store is first accessed on the client -if (typeof window !== 'undefined') { - // Delay to avoid hydration issues - setTimeout(() => { - useOllamaStore.getState().fetchModels() - }, 1000) -} diff --git a/apps/sim/stores/ollama/types.ts b/apps/sim/stores/ollama/types.ts deleted file mode 100644 index 77b0fa26cd..0000000000 --- a/apps/sim/stores/ollama/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface OllamaStore { - models: string[] - isLoading: boolean - setModels: (models: string[]) => void - fetchModels: () => Promise -} diff --git a/apps/sim/stores/openrouter/store.ts b/apps/sim/stores/openrouter/store.ts deleted file mode 100644 index 8bb5121b76..0000000000 --- a/apps/sim/stores/openrouter/store.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { create } from 'zustand' -import { createLogger } from '@/lib/logs/console/logger' -import { updateOpenRouterProviderModels } from '@/providers/utils' -import type { OpenRouterStore } from '@/stores/openrouter/types' - -const logger = createLogger('OpenRouterStore') - -const fetchOpenRouterModels = async (): Promise => { - try { - const response = await fetch('/api/providers/openrouter/models') - if (!response.ok) { - logger.warn('Failed to fetch OpenRouter models from API', { - status: response.status, - statusText: response.statusText, - }) - return [] - } - const data = await response.json() - return data.models || [] - } catch (error) { - logger.error('Error fetching OpenRouter models', { - error: error instanceof Error ? error.message : 'Unknown error', - }) - return [] - } -} - -export const useOpenRouterStore = create((set, get) => ({ - models: [], - isLoading: false, - setModels: (models) => { - const unique = Array.from(new Set(models)) - set({ models: unique }) - updateOpenRouterProviderModels(models) - }, - - fetchModels: async () => { - if (typeof window === 'undefined') { - logger.info('Skipping client-side model fetch on server') - return - } - if (get().isLoading) { - logger.info('Model fetch already in progress') - return - } - logger.info('Fetching OpenRouter models from API') - set({ isLoading: true }) - try { - const models = await fetchOpenRouterModels() - logger.info('Successfully fetched OpenRouter models', { - count: models.length, - }) - get().setModels(models) - } catch (error) { - logger.error('Failed to fetch OpenRouter models', { - error: error instanceof Error ? error.message : 'Unknown error', - }) - } finally { - set({ isLoading: false }) - } - }, -})) - -if (typeof window !== 'undefined') { - setTimeout(() => { - useOpenRouterStore.getState().fetchModels() - }, 1000) -} diff --git a/apps/sim/stores/openrouter/types.ts b/apps/sim/stores/openrouter/types.ts deleted file mode 100644 index 19f94f5e57..0000000000 --- a/apps/sim/stores/openrouter/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface OpenRouterStore { - models: string[] - isLoading: boolean - setModels: (models: string[]) => void - fetchModels: () => Promise -} diff --git a/apps/sim/stores/organization/types.ts b/apps/sim/stores/organization/types.ts index 1d7fef0566..e61f7cde2f 100644 --- a/apps/sim/stores/organization/types.ts +++ b/apps/sim/stores/organization/types.ts @@ -59,7 +59,6 @@ export interface OrganizationFormData { logo: string } -// Organization billing and usage types export interface MemberUsageData { userId: string userName: string diff --git a/apps/sim/stores/providers/store.ts b/apps/sim/stores/providers/store.ts new file mode 100644 index 0000000000..5c258ffda8 --- /dev/null +++ b/apps/sim/stores/providers/store.ts @@ -0,0 +1,126 @@ +import { create } from 'zustand' +import { createLogger } from '@/lib/logs/console/logger' +import { updateOllamaProviderModels, updateOpenRouterProviderModels } from '@/providers/utils' +import type { ProviderConfig, ProviderName, ProvidersStore } from './types' + +const logger = createLogger('ProvidersStore') + +const PROVIDER_CONFIGS: Record = { + ollama: { + apiEndpoint: '/api/providers/ollama/models', + updateFunction: updateOllamaProviderModels, + }, + openrouter: { + apiEndpoint: '/api/providers/openrouter/models', + dedupeModels: true, + updateFunction: updateOpenRouterProviderModels, + }, +} + +const fetchProviderModels = async (provider: ProviderName): Promise => { + try { + const config = PROVIDER_CONFIGS[provider] + const response = await fetch(config.apiEndpoint) + + if (!response.ok) { + logger.warn(`Failed to fetch ${provider} models from API`, { + status: response.status, + statusText: response.statusText, + }) + return [] + } + + const data = await response.json() + return data.models || [] + } catch (error) { + logger.error(`Error fetching ${provider} models`, { + error: error instanceof Error ? error.message : 'Unknown error', + }) + return [] + } +} + +export const useProvidersStore = create((set, get) => ({ + providers: { + ollama: { models: [], isLoading: false }, + openrouter: { models: [], isLoading: false }, + }, + + setModels: (provider, models) => { + const config = PROVIDER_CONFIGS[provider] + + const processedModels = config.dedupeModels ? Array.from(new Set(models)) : models + + set((state) => ({ + providers: { + ...state.providers, + [provider]: { + ...state.providers[provider], + models: processedModels, + }, + }, + })) + + config.updateFunction(models) + }, + + fetchModels: async (provider) => { + if (typeof window === 'undefined') { + logger.info(`Skipping client-side ${provider} model fetch on server`) + return + } + + const currentState = get().providers[provider] + if (currentState.isLoading) { + logger.info(`${provider} model fetch already in progress`) + return + } + + logger.info(`Fetching ${provider} models from API`) + + set((state) => ({ + providers: { + ...state.providers, + [provider]: { + ...state.providers[provider], + isLoading: true, + }, + }, + })) + + try { + const models = await fetchProviderModels(provider) + logger.info(`Successfully fetched ${provider} models`, { + count: models.length, + ...(provider === 'ollama' ? { models } : {}), + }) + get().setModels(provider, models) + } catch (error) { + logger.error(`Failed to fetch ${provider} models`, { + error: error instanceof Error ? error.message : 'Unknown error', + }) + } finally { + set((state) => ({ + providers: { + ...state.providers, + [provider]: { + ...state.providers[provider], + isLoading: false, + }, + }, + })) + } + }, + + getProvider: (provider) => { + return get().providers[provider] + }, +})) + +if (typeof window !== 'undefined') { + setTimeout(() => { + const store = useProvidersStore.getState() + store.fetchModels('ollama') + store.fetchModels('openrouter') + }, 1000) +} diff --git a/apps/sim/stores/providers/types.ts b/apps/sim/stores/providers/types.ts new file mode 100644 index 0000000000..4aabdf4f80 --- /dev/null +++ b/apps/sim/stores/providers/types.ts @@ -0,0 +1,19 @@ +export type ProviderName = 'ollama' | 'openrouter' + +export interface ProviderState { + models: string[] + isLoading: boolean +} + +export interface ProvidersStore { + providers: Record + setModels: (provider: ProviderName, models: string[]) => void + fetchModels: (provider: ProviderName) => Promise + getProvider: (provider: ProviderName) => ProviderState +} + +export interface ProviderConfig { + apiEndpoint: string + dedupeModels?: boolean + updateFunction: (models: string[]) => void | Promise +} diff --git a/apps/sim/stores/settings/environment/store.ts b/apps/sim/stores/settings/environment/store.ts index 5b2b9e72b9..30f05fc0e2 100644 --- a/apps/sim/stores/settings/environment/store.ts +++ b/apps/sim/stores/settings/environment/store.ts @@ -10,7 +10,6 @@ export const useEnvironmentStore = create()((set, get) => ({ isLoading: false, error: null, - // Load environment variables from DB loadEnvironmentVariables: async () => { try { set({ isLoading: true, error: null }) @@ -43,12 +42,10 @@ export const useEnvironmentStore = create()((set, get) => ({ } }, - // Save environment variables to DB saveEnvironmentVariables: async (variables: Record) => { try { set({ isLoading: true, error: null }) - // Transform variables to the format expected by the store const transformedVariables = Object.entries(variables).reduce( (acc, [key, value]) => ({ ...acc, @@ -57,10 +54,8 @@ export const useEnvironmentStore = create()((set, get) => ({ {} ) - // Update local state immediately (optimistic update) set({ variables: transformedVariables }) - // Send to DB const response = await fetch(API_ENDPOINTS.ENVIRONMENT, { method: 'POST', headers: { @@ -89,12 +84,10 @@ export const useEnvironmentStore = create()((set, get) => ({ isLoading: false, }) - // Reload from DB to ensure consistency get().loadEnvironmentVariables() } }, - // Workspace environment actions loadWorkspaceEnvironment: async (workspaceId: string) => { try { set({ isLoading: true, error: null }) @@ -105,7 +98,6 @@ export const useEnvironmentStore = create()((set, get) => ({ } const { data } = await response.json() - // The UI component for environment modal will handle workspace section state locally. set({ isLoading: false }) return data as { workspace: Record @@ -155,15 +147,6 @@ export const useEnvironmentStore = create()((set, get) => ({ } }, - // Legacy method updated to use the new saveEnvironmentVariables - setVariables: (variables: Record) => { - get().saveEnvironmentVariables(variables) - }, - - getVariable: (key: string): string | undefined => { - return get().variables[key]?.value - }, - getAllVariables: (): Record => { return get().variables }, diff --git a/apps/sim/stores/settings/environment/types.ts b/apps/sim/stores/settings/environment/types.ts index 8d95a242db..78c484aacc 100644 --- a/apps/sim/stores/settings/environment/types.ts +++ b/apps/sim/stores/settings/environment/types.ts @@ -10,14 +10,9 @@ export interface EnvironmentState { } export interface EnvironmentStore extends EnvironmentState { - // Legacy method - setVariables: (variables: Record) => void - - // New methods for direct DB interaction loadEnvironmentVariables: () => Promise saveEnvironmentVariables: (variables: Record) => Promise - // Workspace environment loadWorkspaceEnvironment: (workspaceId: string) => Promise<{ workspace: Record personal: Record @@ -29,7 +24,5 @@ export interface EnvironmentStore extends EnvironmentState { ) => Promise removeWorkspaceEnvironmentKeys: (workspaceId: string, keys: string[]) => Promise - // Utility methods - getVariable: (key: string) => string | undefined getAllVariables: () => Record } diff --git a/apps/sim/stores/subscription/store.ts b/apps/sim/stores/subscription/store.ts index 617bca1a11..40ef9ac908 100644 --- a/apps/sim/stores/subscription/store.ts +++ b/apps/sim/stores/subscription/store.ts @@ -142,7 +142,7 @@ export const useSubscriptionStore = create()( loadUsageLimitData: async () => { try { - const response = await fetch('/api/usage-limits?context=user') + const response = await fetch('/api/usage?context=user') if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) @@ -168,7 +168,7 @@ export const useSubscriptionStore = create()( updateUsageLimit: async (newLimit: number) => { try { - const response = await fetch('/api/usage-limits?context=user', { + const response = await fetch('/api/usage?context=user', { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -240,7 +240,7 @@ export const useSubscriptionStore = create()( // Load both subscription and usage limit data in parallel const [subscriptionResponse, usageLimitResponse] = await Promise.all([ fetch('/api/billing?context=user'), - fetch('/api/usage-limits?context=user'), + fetch('/api/usage?context=user'), ]) if (!subscriptionResponse.ok) { diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 465e6bc742..c5edb5a068 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -34,7 +34,7 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise { try { useWorkflowRegistry.getState().setLoading(true) - const url = new URL(API_ENDPOINTS.SYNC, window.location.origin) + const url = new URL(API_ENDPOINTS.WORKFLOWS, window.location.origin) if (workspaceId) { url.searchParams.append('workspaceId', workspaceId) @@ -940,7 +940,7 @@ export const useWorkflowRegistry = create()( }, } - const response = await fetch('/api/workflows/sync', { + const response = await fetch('/api/workflows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ From 54d42b33ebd25a177f73a212e4ad3571e44c2ce2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 2 Sep 2025 09:15:25 -0700 Subject: [PATCH 3/3] fix(wand): remove duplicate transfer encoding header meant to be set by nginx proxy (#1221) --- apps/sim/app/api/wand-generate/route.ts | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/api/wand-generate/route.ts b/apps/sim/app/api/wand-generate/route.ts index bca08194ed..2aad903654 100644 --- a/apps/sim/app/api/wand-generate/route.ts +++ b/apps/sim/app/api/wand-generate/route.ts @@ -49,7 +49,6 @@ interface RequestBody { history?: ChatMessage[] } -// Helper: safe stringify for error payloads that may include circular structures function safeStringify(value: unknown): string { try { return JSON.stringify(value) @@ -83,18 +82,14 @@ export async function POST(req: NextRequest) { ) } - // Use provided system prompt or default const finalSystemPrompt = systemPrompt || 'You are a helpful AI assistant. Generate content exactly as requested by the user.' - // Prepare messages for OpenAI API const messages: ChatMessage[] = [{ role: 'system', content: finalSystemPrompt }] - // Add previous messages from history messages.push(...history.filter((msg) => msg.role !== 'system')) - // Add the current user prompt messages.push({ role: 'user', content: prompt }) logger.debug( @@ -108,7 +103,6 @@ export async function POST(req: NextRequest) { } ) - // For streaming responses if (stream) { try { logger.debug( @@ -119,7 +113,6 @@ export async function POST(req: NextRequest) { `[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}` ) - // Use native fetch for streaming to avoid OpenAI SDK issues with Node.js runtime const apiUrl = useWandAzure ? `${azureEndpoint}/openai/deployments/${wandModelName}/chat/completions?api-version=${azureApiVersion}` : 'https://api.openai.com/v1/chat/completions' @@ -161,7 +154,6 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Stream response received, starting processing`) - // Create a TransformStream to process the SSE data const encoder = new TextEncoder() const decoder = new TextDecoder() @@ -187,12 +179,10 @@ export async function POST(req: NextRequest) { break } - // Decode the chunk buffer += decoder.decode(value, { stream: true }) - // Process complete SSE messages const lines = buffer.split('\n') - buffer = lines.pop() || '' // Keep incomplete line in buffer + buffer = lines.pop() || '' for (const line of lines) { if (line.startsWith('data: ')) { @@ -217,25 +207,21 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Received first content chunk`) } - // Forward the content controller.enqueue( encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`) ) } - // Log usage if present if (parsed.usage) { logger.info( `[${requestId}] Received usage data: ${JSON.stringify(parsed.usage)}` ) } - // Log progress periodically if (chunkCount % 10 === 0) { logger.debug(`[${requestId}] Processed ${chunkCount} chunks`) } } catch (parseError) { - // Skip invalid JSON lines logger.debug( `[${requestId}] Skipped non-JSON line: ${data.substring(0, 100)}` ) @@ -252,7 +238,6 @@ export async function POST(req: NextRequest) { stack: streamError?.stack, }) - // Send error to client const errorData = `data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n` controller.enqueue(encoder.encode(errorData)) controller.close() @@ -262,14 +247,12 @@ export async function POST(req: NextRequest) { }, }) - // Return Response with proper headers for Node.js runtime return new Response(readable, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', // Disable Nginx buffering - 'Transfer-Encoding': 'chunked', // Important for Node.js runtime + 'X-Accel-Buffering': 'no', }, }) } catch (error: any) { @@ -294,7 +277,6 @@ export async function POST(req: NextRequest) { } } - // For non-streaming responses const completion = await client.chat.completions.create({ model: useWandAzure ? wandModelName : 'gpt-4o', messages: messages,