diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 29fb289076f..09157d7de1e 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -17,7 +17,7 @@ interface SocialLoginButtonsProps { export function SocialLoginButtons({ githubAvailable, googleAvailable, - callbackURL = '/w', + callbackURL = '/workspace', isProduction, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) diff --git a/apps/sim/app/(auth)/layout.tsx b/apps/sim/app/(auth)/layout.tsx index 6a81c5ba228..d448a4eae24 100644 --- a/apps/sim/app/(auth)/layout.tsx +++ b/apps/sim/app/(auth)/layout.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import Link from 'next/link' import { GridPattern } from '../(landing)/components/grid-pattern' -import { NotificationList } from '../w/[id]/components/notifications/notifications' +import { NotificationList } from '../workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications' export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/sim/app/(auth)/login/login-form.test.tsx b/apps/sim/app/(auth)/login/login-form.test.tsx index 00bf49df227..a40edfd88fc 100644 --- a/apps/sim/app/(auth)/login/login-form.test.tsx +++ b/apps/sim/app/(auth)/login/login-form.test.tsx @@ -149,7 +149,7 @@ describe('LoginPage', () => { { email: 'test@example.com', password: 'password123', - callbackURL: '/w', + callbackURL: '/workspace', }, expect.objectContaining({ onError: expect.any(Function), diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 566aa18cd01..88b9e5af624 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -125,7 +125,7 @@ export default function LoginPage({ const [showValidationError, setShowValidationError] = useState(false) // Initialize state for URL parameters - const [callbackUrl, setCallbackUrl] = useState('/w') + const [callbackUrl, setCallbackUrl] = useState('/workspace') const [isInviteFlow, setIsInviteFlow] = useState(false) // Forgot password states @@ -155,7 +155,7 @@ export default function LoginPage({ setCallbackUrl(callback) } else { logger.warn('Invalid callback URL detected and blocked:', { url: callback }) - // Keep the default safe value ('/w') + // Keep the default safe value ('/workspace') } } @@ -222,7 +222,7 @@ export default function LoginPage({ try { // Final validation before submission - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/w' + const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' const result = await client.signIn.email( { diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index c061c6ba030..8b5e88dc813 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -410,7 +410,7 @@ function SignupFormContent({ diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 933352d4907..b800cbd5782 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -148,7 +148,7 @@ export function useVerification({ router.push(redirectUrl) } else { // Default redirect to dashboard - router.push('/w') + router.push('/workspace') } }, 2000) } else { @@ -233,7 +233,7 @@ export function useVerification({ if (isDevOrDocker || !hasResendKey) { setIsVerified(true) const timeoutId = setTimeout(() => { - router.push('/w') + router.push('/workspace') }, 1000) return () => clearTimeout(timeoutId) diff --git a/apps/sim/app/(landing)/components/sections/footer.tsx b/apps/sim/app/(landing)/components/sections/footer.tsx index d0fdbf8c1b4..0a957c6011f 100644 --- a/apps/sim/app/(landing)/components/sections/footer.tsx +++ b/apps/sim/app/(landing)/components/sections/footer.tsx @@ -21,7 +21,7 @@ function Footer() { if (typeof window !== 'undefined') { // Check if user has an active session if (isAuthenticated) { - router.push('/w') + router.push('/workspace') } else { // Check if user has logged in before const hasLoggedInBefore = diff --git a/apps/sim/app/(landing)/components/sections/hero.tsx b/apps/sim/app/(landing)/components/sections/hero.tsx index a3823a7cc0c..e822888b7c2 100644 --- a/apps/sim/app/(landing)/components/sections/hero.tsx +++ b/apps/sim/app/(landing)/components/sections/hero.tsx @@ -18,7 +18,7 @@ function Hero() { if (typeof window !== 'undefined') { // Check if user has an active session if (isAuthenticated) { - router.push('/w') + router.push('/workspace') } else { // Check if user has logged in before const hasLoggedInBefore = diff --git a/apps/sim/app/api/chat/subdomains/validate/route.ts b/apps/sim/app/api/chat/subdomains/validate/route.ts index 77467042626..1b01b44024d 100644 --- a/apps/sim/app/api/chat/subdomains/validate/route.ts +++ b/apps/sim/app/api/chat/subdomains/validate/route.ts @@ -44,6 +44,7 @@ export async function GET(request: Request) { 'help', 'support', 'admin', + 'qa', ] if (reservedSubdomains.includes(subdomain)) { return NextResponse.json( diff --git a/apps/sim/app/api/marketplace/workflows/route.ts b/apps/sim/app/api/marketplace/workflows/route.ts index 40bc4fe1975..fdf7a3cdb81 100644 --- a/apps/sim/app/api/marketplace/workflows/route.ts +++ b/apps/sim/app/api/marketplace/workflows/route.ts @@ -2,7 +2,7 @@ import { desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { CATEGORIES } from '@/app/w/marketplace/constants/categories' +import { CATEGORIES } from '@/app/workspace/[workspaceId]/marketplace/constants/categories' import { db } from '@/db' import * as schema from '@/db/schema' diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts new file mode 100644 index 00000000000..9c4462f2af4 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -0,0 +1,644 @@ +/** + * Integration tests for workflow by ID API route + * Tests the new centralized permissions system + * + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('Workflow By ID API Route', () => { + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + beforeEach(() => { + vi.resetModules() + + vi.stubGlobal('crypto', { + randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'), + }) + + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + vi.doMock('@/lib/workflows/db-helpers', () => ({ + loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(null), + })) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('GET /api/workflows/[id]', () => { + it('should return 401 when user is not authenticated', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + }) + + it('should return 404 when workflow does not exist', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(undefined), + }), + }), + }), + }, + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent') + const params = Promise.resolve({ id: 'nonexistent' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Workflow not found') + }) + + it.concurrent('should allow access when user owns the workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: null, + state: { blocks: {}, edges: [] }, + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.id).toBe('workflow-123') + }) + + it.concurrent('should allow access when user has workspace permissions', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + state: { blocks: {}, edges: [] }, + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('read'), + hasAdminPermission: vi.fn().mockResolvedValue(false), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.id).toBe('workflow-123') + }) + + it('should deny access when user has no workspace permissions', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + state: { blocks: {}, edges: [] }, + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue(null), + hasAdminPermission: vi.fn().mockResolvedValue(false), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Access denied') + }) + + it.concurrent('should use normalized tables when available', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: null, + state: { blocks: {}, edges: [] }, + } + + const mockNormalizedData = { + blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, + edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], + loops: {}, + parallels: {}, + isFromNormalizedTables: true, + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + vi.doMock('@/lib/workflows/db-helpers', () => ({ + loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks) + expect(data.data.state.edges).toEqual(mockNormalizedData.edges) + }) + }) + + describe('DELETE /api/workflows/[id]', () => { + it('should allow owner to delete workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: null, + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + await callback({ + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }) + }) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + transaction: mockTransaction, + }, + })) + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { DELETE } = await import('./route') + const response = await DELETE(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + }) + + it('should allow admin to delete workspace workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + await callback({ + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }) + }) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + transaction: mockTransaction, + }, + })) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), + hasAdminPermission: vi.fn().mockResolvedValue(true), + })) + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { DELETE } = await import('./route') + const response = await DELETE(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + }) + + it.concurrent('should deny deletion for non-admin users', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('read'), + hasAdminPermission: vi.fn().mockResolvedValue(false), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { DELETE } = await import('./route') + const response = await DELETE(req, { params }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Access denied') + }) + }) + + describe('PUT /api/workflows/[id]', () => { + it('should allow owner to update workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: null, + } + + const updateData = { name: 'Updated Workflow' } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ ...mockWorkflow, ...updateData }]), + }), + }), + }), + }, + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { PUT } = await import('./route') + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.workflow.name).toBe('Updated Workflow') + }) + + it('should allow users with write permission to update workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + const updateData = { name: 'Updated Workflow' } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ ...mockWorkflow, ...updateData }]), + }), + }), + }), + }, + })) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('write'), + hasAdminPermission: vi.fn().mockResolvedValue(false), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { PUT } = await import('./route') + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.workflow.name).toBe('Updated Workflow') + }) + + it('should deny update for users with only read permission', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + const updateData = { name: 'Updated Workflow' } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('read'), + hasAdminPermission: vi.fn().mockResolvedValue(false), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { PUT } = await import('./route') + const response = await PUT(req, { params }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Access denied') + }) + + it.concurrent('should validate request data', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + workspaceId: null, + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockResolvedValue(mockWorkflow), + }), + }), + }), + }, + })) + + // Invalid data - empty name + const invalidData = { name: '' } + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify(invalidData), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { PUT } = await import('./route') + const response = await PUT(req, { params }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Invalid request data') + }) + }) + + describe('Error handling', () => { + it.concurrent('should handle database errors gracefully', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + vi.doMock('@/db', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + then: vi.fn().mockRejectedValue(new Error('Database connection timeout')), + }), + }), + }), + }, + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.error).toBe('Internal server error') + expect(mockLogger.error).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 2068a201646..fd0286faaf2 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,21 +1,15 @@ -import { and, eq } from 'drizzle-orm' +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 { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { db } from '@/db' -import { - workflow, - workflowBlocks, - workflowEdges, - workflowSubflows, - workspaceMember, -} from '@/db/schema' +import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema' const logger = createLogger('WorkflowByIdAPI') -// Schema for workflow metadata updates const UpdateWorkflowSchema = z.object({ name: z.string().min(1, 'Name is required').optional(), description: z.string().optional(), @@ -63,20 +57,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ hasAccess = true } - // Case 2: Workflow belongs to a workspace the user is a member of + // Case 2: Workflow belongs to a workspace the user has permissions for if (!hasAccess && workflowData.workspaceId) { - const membership = await db - .select({ id: workspaceMember.id }) - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, workflowData.workspaceId), - eq(workspaceMember.userId, userId) - ) - ) - .then((rows) => rows[0]) - - if (membership) { + const userPermission = await getUserEntityPermissions( + userId, + 'workspace', + workflowData.workspaceId + ) + if (userPermission !== null) { hasAccess = true } } @@ -182,20 +170,10 @@ export async function DELETE( canDelete = true } - // Case 2: Workflow belongs to a workspace and user has admin/owner role + // Case 2: Workflow belongs to a workspace and user has admin permission if (!canDelete && workflowData.workspaceId) { - const membership = await db - .select({ role: workspaceMember.role }) - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, workflowData.workspaceId), - eq(workspaceMember.userId, userId) - ) - ) - .then((rows) => rows[0]) - - if (membership && (membership.role === 'owner' || membership.role === 'admin')) { + const hasAdmin = await hasAdminPermission(userId, workflowData.workspaceId) + if (hasAdmin) { canDelete = true } } @@ -300,20 +278,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ canUpdate = true } - // Case 2: Workflow belongs to a workspace and user has admin/owner role + // Case 2: Workflow belongs to a workspace and user has write or admin permission if (!canUpdate && workflowData.workspaceId) { - const membership = await db - .select({ role: workspaceMember.role }) - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, workflowData.workspaceId), - eq(workspaceMember.userId, userId) - ) - ) - .then((rows) => rows[0]) - - if (membership && (membership.role === 'owner' || membership.role === 'admin')) { + const userPermission = await getUserEntityPermissions( + userId, + 'workspace', + workflowData.workspaceId + ) + if (userPermission === 'write' || userPermission === 'admin') { canUpdate = true } } diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts new file mode 100644 index 00000000000..b09cbb29099 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -0,0 +1,325 @@ +/** + * Tests for workflow variables API route + * Tests the optimized permissions and caching system + * + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockDatabase, + mockAuth, + mockCryptoUuid, + mockUser, + setupCommonApiMocks, +} from '@/app/api/__test-utils__/utils' + +describe('Workflow Variables API Route', () => { + let authMocks: ReturnType + let databaseMocks: ReturnType + + beforeEach(() => { + vi.resetModules() + setupCommonApiMocks() + mockCryptoUuid('mock-request-id-12345678') + authMocks = mockAuth(mockUser) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('GET /api/workflows/[id]/variables', () => { + it('should return 401 when user is not authenticated', async () => { + authMocks.setUnauthenticated() + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + }) + + it('should return 404 when workflow does not exist', async () => { + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[]] }, // No workflow found + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent/variables') + const params = Promise.resolve({ id: 'nonexistent' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Workflow not found') + }) + + it('should allow access when user owns the workflow', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + workspaceId: null, + variables: { + 'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' }, + }, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data).toEqual(mockWorkflow.variables) + }) + + it('should allow access when user has workspace permissions', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + workspaceId: 'workspace-456', + variables: { + 'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' }, + }, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue('read'), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data).toEqual(mockWorkflow.variables) + + // Verify permissions check was called + const { getUserEntityPermissions } = await import('@/lib/permissions/utils') + expect(getUserEntityPermissions).toHaveBeenCalledWith( + 'user-123', + 'workspace', + 'workspace-456' + ) + }) + + it('should deny access when user has no workspace permissions', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + workspaceId: 'workspace-456', + variables: {}, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue(null), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + }) + + it.concurrent('should include proper cache headers', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + workspaceId: null, + variables: { + 'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' }, + }, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('max-age=30, stale-while-revalidate=300') + expect(response.headers.get('ETag')).toMatch(/^"variables-workflow-123-\d+"$/) + }) + + it.concurrent('should return empty object for workflows with no variables', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + workspaceId: null, + variables: null, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data).toEqual({}) + }) + }) + + describe('POST /api/workflows/[id]/variables', () => { + it('should allow owner to update variables', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + workspaceId: null, + variables: {}, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + update: { results: [{}] }, + }) + + const variables = [ + { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, + ] + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { + method: 'POST', + body: JSON.stringify({ variables }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { POST } = await import('./route') + const response = await POST(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + }) + + it('should deny access for users without permissions', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + workspaceId: 'workspace-456', + variables: {}, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + vi.doMock('@/lib/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn().mockResolvedValue(null), + })) + + const variables = [ + { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, + ] + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { + method: 'POST', + body: JSON.stringify({ variables }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { POST } = await import('./route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + }) + + it.concurrent('should validate request data schema', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + workspaceId: null, + variables: {}, + } + + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { results: [[mockWorkflow]] }, + }) + + // Invalid data - missing required fields + const invalidData = { variables: [{ name: 'test' }] } + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { + method: 'POST', + body: JSON.stringify(invalidData), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { POST } = await import('./route') + const response = await POST(req, { params }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Invalid request data') + }) + }) + + describe('Error handling', () => { + it.concurrent('should handle database errors gracefully', async () => { + authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' }) + databaseMocks = createMockDatabase({ + select: { throwError: true, errorMessage: 'Database connection failed' }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('./route') + const response = await GET(req, { params }) + + expect(response.status).toBe(500) + const data = await response.json() + expect(data.error).toBe('Database connection failed') + }) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 880593ec822..c93cb3884ae 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -1,10 +1,11 @@ -import { and, eq } from 'drizzle-orm' +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 { getUserEntityPermissions } from '@/lib/permissions/utils' import { db } from '@/db' -import { workflow, workspaceMember } from '@/db/schema' +import { workflow } from '@/db/schema' import type { Variable } from '@/stores/panel/variables/types' const logger = createLogger('WorkflowVariablesAPI') @@ -47,23 +48,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const workflowData = workflowRecord[0] const workspaceId = workflowData.workspaceId - // Check authorization - either the user owns the workflow or is a member of the workspace + // Check authorization - either the user owns the workflow or has workspace permissions let isAuthorized = workflowData.userId === session.user.id - // If not authorized by ownership and the workflow belongs to a workspace, check workspace membership + // If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions if (!isAuthorized && workspaceId) { - const membership = await db - .select() - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, session.user.id) - ) - ) - .limit(1) - - isAuthorized = membership.length > 0 + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + isAuthorized = userPermission !== null } if (!isAuthorized) { @@ -151,23 +146,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: const workflowData = workflowRecord[0] const workspaceId = workflowData.workspaceId - // Check authorization - either the user owns the workflow or is a member of the workspace + // Check authorization - either the user owns the workflow or has workspace permissions let isAuthorized = workflowData.userId === session.user.id - // If not authorized by ownership and the workflow belongs to a workspace, check workspace membership + // If not authorized by ownership and the workflow belongs to a workspace, check workspace permissions if (!isAuthorized && workspaceId) { - const membership = await db - .select() - .from(workspaceMember) - .where( - and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, session.user.id) - ) - ) - .limit(1) - - isAuthorized = membership.length > 0 + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + isAuthorized = userPermission !== null } if (!isAuthorized) { @@ -181,9 +170,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: const variables = (workflowData.variables as Record) || {} // Add cache headers to prevent frequent reloading + const variableHash = JSON.stringify(variables).length const headers = new Headers({ - 'Cache-Control': 'max-age=60, stale-while-revalidate=300', // Cache for 1 minute, stale for 5 - ETag: `"${requestId}-${Object.keys(variables).length}"`, + 'Cache-Control': 'max-age=30, stale-while-revalidate=300', // Cache for 30 seconds, stale for 5 min + ETag: `"variables-${workflowId}-${variableHash}"`, }) return NextResponse.json( diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts index 2b30618005a..560b36f68e7 100644 --- a/apps/sim/app/api/workspaces/invitations/accept/route.ts +++ b/apps/sim/app/api/workspaces/invitations/accept/route.ts @@ -149,7 +149,10 @@ export async function GET(req: NextRequest) { .where(eq(workspaceInvitation.id, invitation.id)) return NextResponse.redirect( - new URL(`/w/${invitation.workspaceId}`, env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai') + new URL( + `/workspace/${invitation.workspaceId}/w`, + env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) ) } @@ -194,7 +197,10 @@ export async function GET(req: NextRequest) { // Redirect to the workspace return NextResponse.redirect( - new URL(`/w/${invitation.workspaceId}`, env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai') + new URL( + `/workspace/${invitation.workspaceId}/w`, + env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) ) } catch (error) { console.error('Error accepting invitation:', error) diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 3cac00e554f..86b748b6866 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -131,7 +131,7 @@ export default function Invite() { // Redirect to workspace after a brief delay setTimeout(() => { - router.push('/w') + router.push('/workspace') }, 2000) } else { // For organization invites, use the client API @@ -153,7 +153,7 @@ export default function Invite() { // Redirect to workspace after a brief delay setTimeout(() => { - router.push('/w') + router.push('/workspace') }, 2000) } } catch (err: any) { diff --git a/apps/sim/app/invite/invite-error/invite-error.tsx b/apps/sim/app/invite/invite-error/invite-error.tsx index 480a59d2ae8..064a70b9332 100644 --- a/apps/sim/app/invite/invite-error/invite-error.tsx +++ b/apps/sim/app/invite/invite-error/invite-error.tsx @@ -54,7 +54,7 @@ export default function InviteError() {

{displayMessage}

- + diff --git a/apps/sim/app/w/error.tsx b/apps/sim/app/w/error.tsx deleted file mode 100644 index adac0456b81..00000000000 --- a/apps/sim/app/w/error.tsx +++ /dev/null @@ -1,5 +0,0 @@ -'use client' - -import { NextError } from './[id]/components/error' - -export default NextError diff --git a/apps/sim/app/w/global-error.tsx b/apps/sim/app/w/global-error.tsx deleted file mode 100644 index 9c7bd975759..00000000000 --- a/apps/sim/app/w/global-error.tsx +++ /dev/null @@ -1,5 +0,0 @@ -'use client' - -import { NextGlobalError } from './[id]/components/error' - -export default NextGlobalError diff --git a/apps/sim/app/workspace/[workspaceId]/error.tsx b/apps/sim/app/workspace/[workspaceId]/error.tsx new file mode 100644 index 00000000000..c5e6e668b88 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/error.tsx @@ -0,0 +1,5 @@ +'use client' + +import { NextError } from './w/[workflowId]/components/error' + +export default NextError diff --git a/apps/sim/app/workspace/[workspaceId]/global-error.tsx b/apps/sim/app/workspace/[workspaceId]/global-error.tsx new file mode 100644 index 00000000000..d92a73ce254 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/global-error.tsx @@ -0,0 +1,5 @@ +'use client' + +import { NextGlobalError } from './w/[workflowId]/components/error' + +export default NextGlobalError diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx similarity index 100% rename from apps/sim/app/w/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/create-chunk-modal/create-chunk-modal.tsx diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx similarity index 100% rename from apps/sim/app/w/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/components/document-loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx similarity index 97% rename from apps/sim/app/w/knowledge/[id]/[documentId]/components/document-loading.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx index 2b9d811d733..aad70d0ed4f 100644 --- a/apps/sim/app/w/knowledge/[id]/[documentId]/components/document-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx @@ -25,12 +25,12 @@ export function DocumentLoading({ { id: 'knowledge-root', label: 'Knowledge', - href: '/w/knowledge', + href: '/knowledge', }, { id: `knowledge-base-${knowledgeBaseId}`, label: knowledgeBaseName, - href: `/w/knowledge/${knowledgeBaseId}`, + href: `/knowledge/${knowledgeBaseId}`, }, { id: `document-${knowledgeBaseId}-${documentName}`, diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx similarity index 100% rename from apps/sim/app/w/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/edit-chunk-modal/edit-chunk-modal.tsx diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx similarity index 99% rename from apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 9758f46fa8e..fc25327f6db 100644 --- a/apps/sim/app/w/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console-logger' -import { ActionBar } from '@/app/w/knowledge/[id]/components/action-bar/action-bar' +import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar' import { useDocumentChunks } from '@/hooks/use-knowledge' import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' import { useSidebarStore } from '@/stores/sidebar/store' @@ -170,10 +170,10 @@ export function Document({ const effectiveDocumentName = document?.filename || documentName || 'Document' const breadcrumbs = [ - { label: 'Knowledge', href: '/w/knowledge' }, + { label: 'Knowledge', href: '/knowledge' }, { label: effectiveKnowledgeBaseName, - href: `/w/knowledge/${knowledgeBaseId}`, + href: `/knowledge/${knowledgeBaseId}`, }, { label: effectiveDocumentName }, ] @@ -360,10 +360,10 @@ export function Document({ if (combinedError && !isLoadingChunks) { const errorBreadcrumbs = [ - { label: 'Knowledge', href: '/w/knowledge' }, + { label: 'Knowledge', href: '/knowledge' }, { label: effectiveKnowledgeBaseName, - href: `/w/knowledge/${knowledgeBaseId}`, + href: `/knowledge/${knowledgeBaseId}`, }, { label: 'Error' }, ] diff --git a/apps/sim/app/w/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx similarity index 100% rename from apps/sim/app/w/knowledge/[id]/[documentId]/page.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx diff --git a/apps/sim/app/w/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx similarity index 98% rename from apps/sim/app/w/knowledge/[id]/base.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 2b7ab83d394..eeffdb79e7d 100644 --- a/apps/sim/app/w/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -13,7 +13,7 @@ import { Trash2, X, } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { AlertDialog, AlertDialogAction, @@ -28,10 +28,10 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console-logger' -import { ActionBar } from '@/app/w/knowledge/[id]/components/action-bar/action-bar' -import { getDocumentIcon } from '@/app/w/knowledge/components/icons/document-icons' -import { PrimaryButton } from '@/app/w/knowledge/components/primary-button/primary-button' -import { SearchInput } from '@/app/w/knowledge/components/search-input/search-input' +import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar' +import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons' +import { PrimaryButton } from '@/app/workspace/[workspaceId]/knowledge/components/primary-button/primary-button' +import { SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components/search-input/search-input' import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge' import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' import { useSidebarStore } from '@/stores/sidebar/store' @@ -122,6 +122,8 @@ export function KnowledgeBase({ }: KnowledgeBaseProps) { const { mode, isExpanded } = useSidebarStore() const { removeKnowledgeBase } = useKnowledgeStore() + const params = useParams() + const workspaceId = params.workspaceId as string const { knowledgeBase, isLoading: isLoadingKnowledgeBase, @@ -402,11 +404,11 @@ export function KnowledgeBase({ const handleDocumentClick = (docId: string) => { // Find the document to get its filename const document = documents.find((doc) => doc.id === docId) - const params = new URLSearchParams({ + const urlParams = new URLSearchParams({ kbName: knowledgeBaseName, // Use the instantly available name docName: document?.filename || 'Document', }) - router.push(`/w/knowledge/${id}/${docId}?${params.toString()}`) + router.push(`/workspace/${workspaceId}/knowledge/${id}/${docId}?${urlParams.toString()}`) } const handleDeleteKnowledgeBase = async () => { @@ -428,7 +430,7 @@ export function KnowledgeBase({ if (result.success) { // Remove from store and redirect to knowledge bases list removeKnowledgeBase(id) - router.push('/w/knowledge') + router.push(`/workspace/${workspaceId}/knowledge`) } else { throw new Error(result.error || 'Failed to delete knowledge base') } @@ -741,7 +743,7 @@ export function KnowledgeBase({ { id: 'knowledge-root', label: 'Knowledge', - href: '/w/knowledge', + href: '/knowledge', }, { id: `knowledge-base-${id}`, @@ -760,7 +762,7 @@ export function KnowledgeBase({ { id: 'knowledge-root', label: 'Knowledge', - href: '/w/knowledge', + href: '/knowledge', }, { id: 'error', diff --git a/apps/sim/app/w/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx similarity index 100% rename from apps/sim/app/w/knowledge/[id]/components/action-bar/action-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx diff --git a/apps/sim/app/w/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx similarity index 99% rename from apps/sim/app/w/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx index f969e7ae7f2..150979ddb66 100644 --- a/apps/sim/app/w/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx @@ -19,7 +19,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading { id: 'knowledge-root', label: 'Knowledge', - href: '/w/knowledge', + href: '/knowledge', }, { id: 'knowledge-base-loading', diff --git a/apps/sim/app/w/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx similarity index 100% rename from apps/sim/app/w/knowledge/[id]/page.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx diff --git a/apps/sim/app/w/knowledge/components/base-overview/base-overview.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx similarity index 95% rename from apps/sim/app/w/knowledge/components/base-overview/base-overview.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx index 4da5fdd9ab7..223c9e9c90f 100644 --- a/apps/sim/app/w/knowledge/components/base-overview/base-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx @@ -18,7 +18,7 @@ export function BaseOverview({ id, title, docCount, description }: BaseOverviewP const params = new URLSearchParams({ kbName: title, }) - const href = `/w/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${params.toString()}` + const href = `/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${params.toString()}` const handleCopy = async (e: React.MouseEvent) => { e.preventDefault() diff --git a/apps/sim/app/w/knowledge/components/create-modal/create-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx similarity index 99% rename from apps/sim/app/w/knowledge/components/create-modal/create-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx index 5fbfd6effa2..77bf98e8fb8 100644 --- a/apps/sim/app/w/knowledge/components/create-modal/create-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx @@ -12,7 +12,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { createLogger } from '@/lib/logs/console-logger' -import { getDocumentIcon } from '@/app/w/knowledge/components/icons/document-icons' +import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons' import type { DocumentData, KnowledgeBaseData } from '@/stores/knowledge/store' import { useKnowledgeStore } from '@/stores/knowledge/store' diff --git a/apps/sim/app/w/knowledge/components/empty-state-card/empty-state-card.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/empty-state-card/empty-state-card.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/empty-state-card/empty-state-card.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/empty-state-card/empty-state-card.tsx diff --git a/apps/sim/app/w/knowledge/components/icons/document-icons.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/icons/document-icons.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/icons/document-icons.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/icons/document-icons.tsx diff --git a/apps/sim/app/w/knowledge/components/knowledge-header/knowledge-header.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/knowledge-header/knowledge-header.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx diff --git a/apps/sim/app/w/knowledge/components/primary-button/primary-button.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/primary-button/primary-button.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/primary-button/primary-button.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/primary-button/primary-button.tsx diff --git a/apps/sim/app/w/knowledge/components/search-input/search-input.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/search-input/search-input.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/search-input/search-input.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/search-input/search-input.tsx diff --git a/apps/sim/app/w/knowledge/components/skeletons/knowledge-base-card-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/skeletons/knowledge-base-card-skeleton.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/skeletons/knowledge-base-card-skeleton.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/skeletons/knowledge-base-card-skeleton.tsx diff --git a/apps/sim/app/w/knowledge/components/skeletons/table-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/skeletons/table-skeleton.tsx similarity index 100% rename from apps/sim/app/w/knowledge/components/skeletons/table-skeleton.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/components/skeletons/table-skeleton.tsx diff --git a/apps/sim/app/w/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx similarity index 100% rename from apps/sim/app/w/knowledge/knowledge.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx diff --git a/apps/sim/app/w/knowledge/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx similarity index 100% rename from apps/sim/app/w/knowledge/loading.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx diff --git a/apps/sim/app/w/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx similarity index 100% rename from apps/sim/app/w/knowledge/page.tsx rename to apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx diff --git a/apps/sim/app/w/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx similarity index 79% rename from apps/sim/app/w/layout.tsx rename to apps/sim/app/workspace/[workspaceId]/layout.tsx index 42ac184a820..261b5d42224 100644 --- a/apps/sim/app/w/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,6 +1,6 @@ import { WorkspaceProvider } from '@/providers/workspace-provider' -import Providers from './components/providers/providers' -import { Sidebar } from './components/sidebar/sidebar' +import Providers from './w/components/providers/providers' +import { Sidebar } from './w/components/sidebar/sidebar' export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/sim/app/w/logs/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/control-bar/control-bar.tsx similarity index 100% rename from apps/sim/app/w/logs/components/control-bar/control-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/control-bar/control-bar.tsx diff --git a/apps/sim/app/w/logs/components/filters/components/filter-section.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/filter-section.tsx similarity index 100% rename from apps/sim/app/w/logs/components/filters/components/filter-section.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/filter-section.tsx diff --git a/apps/sim/app/w/logs/components/filters/components/folder.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx similarity index 93% rename from apps/sim/app/w/logs/components/filters/components/folder.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx index 025e20a7193..bc7432758f7 100644 --- a/apps/sim/app/w/logs/components/filters/components/folder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { Check, ChevronDown, Folder } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -8,9 +9,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useFilterStore } from '@/app/w/logs/stores/store' +import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface FolderOption { id: string @@ -22,7 +22,8 @@ interface FolderOption { export default function FolderFilter() { const { folderIds, toggleFolderId, setFolderIds } = useFilterStore() const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore() - const { activeWorkspaceId } = useWorkflowRegistry() + const params = useParams() + const workspaceId = params.workspaceId as string const [folders, setFolders] = useState([]) const [loading, setLoading] = useState(true) @@ -31,9 +32,9 @@ export default function FolderFilter() { const fetchFoldersData = async () => { try { setLoading(true) - if (activeWorkspaceId) { - await fetchFolders(activeWorkspaceId) - const folderTree = getFolderTree(activeWorkspaceId) + if (workspaceId) { + await fetchFolders(workspaceId) + const folderTree = getFolderTree(workspaceId) // Flatten the folder tree and create options with full paths const flattenFolders = (nodes: any[], parentPath = ''): FolderOption[] => { @@ -68,7 +69,7 @@ export default function FolderFilter() { } fetchFoldersData() - }, [activeWorkspaceId, fetchFolders, getFolderTree]) + }, [workspaceId, fetchFolders, getFolderTree]) // Get display text for the dropdown button const getSelectedFoldersText = () => { diff --git a/apps/sim/app/w/logs/components/filters/components/level.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/level.tsx similarity index 91% rename from apps/sim/app/w/logs/components/filters/components/level.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/level.tsx index 78550907e93..c83706a5b40 100644 --- a/apps/sim/app/w/logs/components/filters/components/level.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/level.tsx @@ -6,8 +6,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useFilterStore } from '@/app/w/logs/stores/store' -import type { LogLevel } from '@/app/w/logs/stores/types' +import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store' +import type { LogLevel } from '@/app/workspace/[workspaceId]/logs/stores/types' export default function Level() { const { level, setLevel } = useFilterStore() diff --git a/apps/sim/app/w/logs/components/filters/components/timeline.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx similarity index 88% rename from apps/sim/app/w/logs/components/filters/components/timeline.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx index 39ae02fb0d1..0a475b6f1dc 100644 --- a/apps/sim/app/w/logs/components/filters/components/timeline.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx @@ -6,8 +6,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useFilterStore } from '@/app/w/logs/stores/store' -import type { TimeRange } from '@/app/w/logs/stores/types' +import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store' +import type { TimeRange } from '@/app/workspace/[workspaceId]/logs/stores/types' export default function Timeline() { const { timeRange, setTimeRange } = useFilterStore() diff --git a/apps/sim/app/w/logs/components/filters/components/trigger.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx similarity index 97% rename from apps/sim/app/w/logs/components/filters/components/trigger.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx index 760649d35be..1cabe0583c9 100644 --- a/apps/sim/app/w/logs/components/filters/components/trigger.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/trigger.tsx @@ -7,7 +7,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useFilterStore } from '@/app/w/logs/stores/store' +import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store' import type { TriggerType } from '../../../stores/types' export default function Trigger() { diff --git a/apps/sim/app/w/logs/components/filters/components/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx similarity index 97% rename from apps/sim/app/w/logs/components/filters/components/workflow.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx index d5f94b6de74..47081d31674 100644 --- a/apps/sim/app/w/logs/components/filters/components/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx @@ -8,7 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useFilterStore } from '@/app/w/logs/stores/store' +import { useFilterStore } from '@/app/workspace/[workspaceId]/logs/stores/store' interface WorkflowOption { id: string diff --git a/apps/sim/app/w/logs/components/filters/filters.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/filters.tsx similarity index 100% rename from apps/sim/app/w/logs/components/filters/filters.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/filters/filters.tsx diff --git a/apps/sim/app/w/logs/components/sidebar/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer.tsx similarity index 100% rename from apps/sim/app/w/logs/components/sidebar/components/markdown-renderer.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer.tsx diff --git a/apps/sim/app/w/logs/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/sidebar/sidebar.tsx similarity index 99% rename from apps/sim/app/w/logs/components/sidebar/sidebar.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/sidebar/sidebar.tsx index a47131e42d5..65d070356b8 100644 --- a/apps/sim/app/w/logs/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/sidebar/sidebar.tsx @@ -7,8 +7,8 @@ import { CopyButton } from '@/components/ui/copy-button' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { redactApiKeys } from '@/lib/utils' -import type { WorkflowLog } from '@/app/w/logs/stores/types' -import { formatDate } from '@/app/w/logs/utils/format-date' +import type { WorkflowLog } from '@/app/workspace/[workspaceId]/logs/stores/types' +import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date' import { formatCost } from '@/providers/utils' import { ToolCallsDisplay } from '../tool-calls/tool-calls-display' import { TraceSpansDisplay } from '../trace-spans/trace-spans-display' diff --git a/apps/sim/app/w/logs/components/tool-calls/tool-calls-display.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/tool-calls/tool-calls-display.tsx similarity index 100% rename from apps/sim/app/w/logs/components/tool-calls/tool-calls-display.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/tool-calls/tool-calls-display.tsx diff --git a/apps/sim/app/w/logs/components/trace-spans/trace-spans-display.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx similarity index 100% rename from apps/sim/app/w/logs/components/trace-spans/trace-spans-display.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx diff --git a/apps/sim/app/w/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx similarity index 100% rename from apps/sim/app/w/logs/logs.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/logs.tsx diff --git a/apps/sim/app/w/logs/page.tsx b/apps/sim/app/workspace/[workspaceId]/logs/page.tsx similarity index 100% rename from apps/sim/app/w/logs/page.tsx rename to apps/sim/app/workspace/[workspaceId]/logs/page.tsx diff --git a/apps/sim/app/w/logs/stores/store.ts b/apps/sim/app/workspace/[workspaceId]/logs/stores/store.ts similarity index 100% rename from apps/sim/app/w/logs/stores/store.ts rename to apps/sim/app/workspace/[workspaceId]/logs/stores/store.ts diff --git a/apps/sim/app/w/logs/stores/types.ts b/apps/sim/app/workspace/[workspaceId]/logs/stores/types.ts similarity index 100% rename from apps/sim/app/w/logs/stores/types.ts rename to apps/sim/app/workspace/[workspaceId]/logs/stores/types.ts diff --git a/apps/sim/app/w/logs/utils/format-date.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils/format-date.ts similarity index 100% rename from apps/sim/app/w/logs/utils/format-date.ts rename to apps/sim/app/workspace/[workspaceId]/logs/utils/format-date.ts diff --git a/apps/sim/app/w/marketplace/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/components/control-bar/control-bar.tsx similarity index 100% rename from apps/sim/app/w/marketplace/components/control-bar/control-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/components/control-bar/control-bar.tsx diff --git a/apps/sim/app/w/marketplace/components/error-message.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/components/error-message.tsx similarity index 100% rename from apps/sim/app/w/marketplace/components/error-message.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/components/error-message.tsx diff --git a/apps/sim/app/w/marketplace/components/section.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/components/section.tsx similarity index 100% rename from apps/sim/app/w/marketplace/components/section.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/components/section.tsx diff --git a/apps/sim/app/w/marketplace/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/components/toolbar/toolbar.tsx similarity index 100% rename from apps/sim/app/w/marketplace/components/toolbar/toolbar.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/components/toolbar/toolbar.tsx diff --git a/apps/sim/app/w/marketplace/components/workflow-card-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/components/workflow-card-skeleton.tsx similarity index 100% rename from apps/sim/app/w/marketplace/components/workflow-card-skeleton.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/components/workflow-card-skeleton.tsx diff --git a/apps/sim/app/w/marketplace/components/workflow-card.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/components/workflow-card.tsx similarity index 94% rename from apps/sim/app/w/marketplace/components/workflow-card.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/components/workflow-card.tsx index 82db83df0b7..76667b1d724 100644 --- a/apps/sim/app/w/marketplace/components/workflow-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/marketplace/components/workflow-card.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react' import { Eye } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' -import { WorkflowPreview } from '@/app/w/components/workflow-preview/workflow-preview' +import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { Workflow } from '../marketplace' @@ -28,6 +28,8 @@ interface WorkflowCardProps { export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) { const [isPreviewReady, setIsPreviewReady] = useState(!!workflow.workflowState) const router = useRouter() + const params = useParams() + const workspaceId = params.workspaceId as string const { createWorkflow } = useWorkflowRegistry() // When workflow state becomes available, update preview ready state @@ -71,7 +73,7 @@ export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) { }) // Navigate to the new workflow - router.push(`/w/${newWorkflowId}`) + router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`) } else { console.error('Cannot import workflow: state is not available') } diff --git a/apps/sim/app/w/marketplace/constants/categories.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/constants/categories.tsx similarity index 100% rename from apps/sim/app/w/marketplace/constants/categories.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/constants/categories.tsx diff --git a/apps/sim/app/w/marketplace/marketplace.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/marketplace.tsx similarity index 100% rename from apps/sim/app/w/marketplace/marketplace.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/marketplace.tsx diff --git a/apps/sim/app/w/marketplace/page.tsx b/apps/sim/app/workspace/[workspaceId]/marketplace/page.tsx similarity index 100% rename from apps/sim/app/w/marketplace/page.tsx rename to apps/sim/app/workspace/[workspaceId]/marketplace/page.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/page.tsx new file mode 100644 index 00000000000..bd93acadf88 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation' + +export default async function WorkspacePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + redirect(`/workspace/${workspaceId}/w`) +} diff --git a/apps/sim/app/w/[id]/components/code-prompt-bar/code-prompt-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/code-prompt-bar/code-prompt-bar.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/code-prompt-bar/code-prompt-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/code-prompt-bar/code-prompt-bar.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index ef4b60ed270..f680c2ffa99 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -33,7 +33,7 @@ import { Textarea } from '@/components/ui/textarea' import { createLogger } from '@/lib/logs/console-logger' import { getBaseDomain } from '@/lib/urls/utils' import { cn } from '@/lib/utils' -import { OutputSelect } from '@/app/w/[id]/components/panel/components/chat/components/output-select/output-select' +import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select' import { useNotificationStore } from '@/stores/notifications/store' import type { OutputConfig } from '@/stores/panel/chat/types' diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status.tsx diff --git a/apps/sim/app/w/[id]/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 similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx similarity index 88% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx index f8a794b7fd1..5f98febf4ce 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info.tsx @@ -15,10 +15,10 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { ApiEndpoint } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint' -import { ApiKey } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key' -import { DeployStatus } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status' -import { ExampleCommand } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command' +import { ApiEndpoint } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-endpoint/api-endpoint' +import { ApiKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key' +import { DeployStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status' +import { ExampleCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command' import { useNotificationStore } from '@/stores/notifications/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { DeployedWorkflowModal } from '../../../deployment-controls/components/deployed-workflow-modal' diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx similarity index 98% rename from apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/deploy-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index af068534848..8595d333f88 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -20,9 +20,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { ChatDeploy } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy' -import { DeployForm } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form' -import { DeploymentInfo } from '@/app/w/[id]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info' +import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy' +import { DeployForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form' +import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info' import { useNotificationStore } from '@/stores/notifications/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-card.tsx similarity index 96% rename from apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-card.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-card.tsx index 187c4fe10e9..500eac1ff38 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-card.tsx @@ -6,7 +6,7 @@ import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { WorkflowPreview } from '@/app/w/components/workflow-preview/workflow-preview' +import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.test.ts similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.test.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.test.ts diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/history-dropdown-item/history-dropdown-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/history-dropdown-item/history-dropdown-item.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/history-dropdown-item/history-dropdown-item.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/history-dropdown-item/history-dropdown-item.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/marketplace-modal/marketplace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/marketplace-modal/marketplace-modal.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/control-bar/components/marketplace-modal/marketplace-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/marketplace-modal/marketplace-modal.tsx index 3d64dcc06f2..7b7bdb59687 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/marketplace-modal/marketplace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/marketplace-modal/marketplace-modal.tsx @@ -35,7 +35,7 @@ import { getCategoryColor, getCategoryIcon, getCategoryLabel, -} from '@/app/w/marketplace/constants/categories' +} from '@/app/workspace/[workspaceId]/marketplace/constants/categories' import { useNotificationStore } from '@/stores/notifications/store' import { getWorkflowWithValues } from '@/stores/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' diff --git a/apps/sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/notification-dropdown-item/notification-dropdown-item.tsx diff --git a/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx similarity index 84% rename from apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx index 764b3fcc931..0fe708ae786 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/components/user-avatar/user-avatar.tsx @@ -4,7 +4,7 @@ import { type CSSProperties, useMemo } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' interface AvatarProps { - connectionId: number + connectionId: string | number name?: string color?: string tooltipContent?: React.ReactNode | null @@ -25,12 +25,18 @@ const APP_COLORS = [ /** * Generate a deterministic gradient based on a connection ID */ -function generateGradient(connectionId: number): string { - // Use the connection ID to select a color pair from our palette - const colorPair = APP_COLORS[connectionId % APP_COLORS.length] +function generateGradient(connectionId: string | number): string { + // Convert connectionId to a number for consistent hashing + const numericId = + typeof connectionId === 'string' + ? Math.abs(connectionId.split('').reduce((a, b) => a + b.charCodeAt(0), 0)) + : connectionId + + // Use the numeric ID to select a color pair from our palette + const colorPair = APP_COLORS[numericId % APP_COLORS.length] // Add a slight rotation to the gradient based on connection ID for variety - const rotation = (connectionId * 25) % 360 + const rotation = (numericId * 25) % 360 return `linear-gradient(${rotation}deg, ${colorPair.from}, ${colorPair.to})` } diff --git a/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx similarity index 95% rename from apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx index b9b0973d3f5..2489fda0622 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack.tsx @@ -5,7 +5,7 @@ import { usePresence } from '../../../../hooks/use-presence' import { UserAvatar } from './components/user-avatar/user-avatar' interface User { - connectionId: number + connectionId: string | number name?: string color?: string info?: string @@ -80,7 +80,7 @@ export function UserAvatarStack({ {/* Render overflow indicator if there are more users */} {overflowCount > 0 && ( { + // Get and sort regular workflows by last modified (newest first) + const regularWorkflows = Object.values(workflows) + .filter((workflow) => workflow.workspaceId === workspaceId) + .filter((workflow) => workflow.marketplaceData?.status !== 'temp') + .sort((a, b) => { + const dateA = + a.lastModified instanceof Date + ? a.lastModified.getTime() + : new Date(a.lastModified).getTime() + const dateB = + b.lastModified instanceof Date + ? b.lastModified.getTime() + : new Date(b.lastModified).getTime() + return dateB - dateA + }) + + // Group workflows by folder + const workflowsByFolder = regularWorkflows.reduce( + (acc, workflow) => { + const folderId = workflow.folderId || 'root' + if (!acc[folderId]) acc[folderId] = [] + acc[folderId].push(workflow) + return acc + }, + {} as Record + ) + + const orderedWorkflows: typeof regularWorkflows = [] + + // Recursively collect workflows from expanded folders + const collectFromFolders = (folders: ReturnType) => { + folders.forEach((folder) => { + if (expandedFolders.has(folder.id)) { + orderedWorkflows.push(...(workflowsByFolder[folder.id] || [])) + if (folder.children.length > 0) { + collectFromFolders(folder.children) + } + } + }) + } + + // Get workflows from expanded folders first, then root workflows + if (workspaceId) collectFromFolders(getFolderTree(workspaceId)) + orderedWorkflows.push(...(workflowsByFolder.root || [])) + + return orderedWorkflows + } + /** * Handle deleting the current workflow */ const handleDeleteWorkflow = () => { if (!activeWorkflowId || !userPermissions.canEdit) return - const workflowIds = Object.keys(workflows) - const currentIndex = workflowIds.indexOf(activeWorkflowId) + const sidebarWorkflows = getSidebarOrderedWorkflows() + const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId) - // Find the next workflow to navigate to - let nextWorkflowId = null - if (workflowIds.length > 1) { - // Try next workflow, then previous, then any other - if (currentIndex < workflowIds.length - 1) { - nextWorkflowId = workflowIds[currentIndex + 1] + // Find next workflow: try next, then previous + let nextWorkflowId: string | null = null + if (sidebarWorkflows.length > 1) { + if (currentIndex < sidebarWorkflows.length - 1) { + nextWorkflowId = sidebarWorkflows[currentIndex + 1].id } else if (currentIndex > 0) { - nextWorkflowId = workflowIds[currentIndex - 1] - } else { - nextWorkflowId = workflowIds.find((id) => id !== activeWorkflowId) || null + nextWorkflowId = sidebarWorkflows[currentIndex - 1].id } } - // Navigate to the next workflow or home + // Navigate to next workflow or workspace home if (nextWorkflowId) { - router.push(`/w/${nextWorkflowId}`) + router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push('/') + router.push(`/workspace/${workspaceId}`) } // Remove the workflow from the registry @@ -573,8 +625,17 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { const handleDuplicateWorkflow = async () => { if (!activeWorkflowId || !userPermissions.canEdit) return - // Duplicate the workflow - no automatic navigation - await duplicateWorkflow(activeWorkflowId) + try { + const newWorkflow = await duplicateWorkflow(activeWorkflowId) + if (newWorkflow) { + router.push(`/workspace/${workspaceId}/w/${newWorkflow}`) + } else { + addNotification('error', 'Failed to duplicate workflow', activeWorkflowId) + } + } catch (error) { + logger.error('Error duplicating workflow:', { error }) + addNotification('error', 'Failed to duplicate workflow', activeWorkflowId) + } } /** diff --git a/apps/sim/app/w/[id]/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot/copilot.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/copilot/copilot.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot/copilot.tsx diff --git a/apps/sim/app/w/[id]/components/error/index.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/error/index.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/components/loop-badges.test.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/loop-node/components/loop-badges.test.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/components/loop-badges.test.tsx diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/components/loop-badges.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/components/loop-badges.tsx diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-config.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-config.ts similarity index 100% rename from apps/sim/app/w/[id]/components/loop-node/loop-config.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-config.ts diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx similarity index 94% rename from apps/sim/app/w/[id]/components/loop-node/loop-node.test.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx index 9625cb5f611..14f5343666f 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { LoopNodeComponent } from './loop-node' -// Mock dependencies that don't need DOM vi.mock('@/stores/workflows/workflow/store', () => ({ useWorkflowStore: vi.fn(), })) @@ -16,7 +15,6 @@ vi.mock('@/lib/logs/console-logger', () => ({ })), })) -// Mock ReactFlow components and hooks vi.mock('reactflow', () => ({ Handle: ({ id, type, position }: any) => ({ id, type, position }), Position: { @@ -32,7 +30,6 @@ vi.mock('reactflow', () => ({ memo: (component: any) => component, })) -// Mock React hooks vi.mock('react', async () => { const actual = await vi.importActual('react') return { @@ -43,7 +40,6 @@ vi.mock('react', async () => { } }) -// Mock UI components vi.mock('@/components/ui/button', () => ({ Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }), })) @@ -60,7 +56,6 @@ vi.mock('@/lib/utils', () => ({ cn: (...classes: any[]) => classes.filter(Boolean).join(' '), })) -// Mock the LoopBadges component vi.mock('./components/loop-badges', () => ({ LoopBadges: ({ loopId }: any) => ({ loopId }), })) @@ -87,8 +82,6 @@ describe('LoopNodeComponent', () => { beforeEach(() => { vi.clearAllMocks() - // Mock useWorkflowStore - ;(useWorkflowStore as any).mockImplementation((selector: any) => { const state = { removeBlock: mockRemoveBlock, @@ -96,7 +89,6 @@ describe('LoopNodeComponent', () => { return selector(state) }) - // Mock getNodes mockGetNodes.mockReturnValue([]) }) @@ -111,14 +103,12 @@ describe('LoopNodeComponent', () => { }) it('should be a memoized component', () => { - // Since we mocked memo to return the component as-is, we can verify it exists expect(LoopNodeComponent).toBeDefined() }) }) describe('Props Validation and Type Safety', () => { it('should accept NodeProps interface', () => { - // Test that the component accepts the correct prop types const validProps = { id: 'test-id', type: 'loopNode' as const, @@ -135,9 +125,7 @@ describe('LoopNodeComponent', () => { dragging: false, } - // This tests that TypeScript compilation succeeds with these props expect(() => { - // We're not calling the component, just verifying the types const _component: typeof LoopNodeComponent = LoopNodeComponent expect(_component).toBeDefined() }).not.toThrow() @@ -163,10 +151,8 @@ describe('LoopNodeComponent', () => { describe('Store Integration', () => { it('should integrate with workflow store', () => { - // Test that the component uses the store correctly expect(useWorkflowStore).toBeDefined() - // Verify the store selector function works const mockState = { removeBlock: mockRemoveBlock } const selector = vi.fn((state) => state.removeBlock) @@ -181,7 +167,6 @@ describe('LoopNodeComponent', () => { expect(mockRemoveBlock).toBeDefined() expect(typeof mockRemoveBlock).toBe('function') - // Test calling removeBlock mockRemoveBlock('test-id') expect(mockRemoveBlock).toHaveBeenCalledWith('test-id') }) @@ -189,7 +174,6 @@ describe('LoopNodeComponent', () => { describe('Component Logic Tests', () => { it('should handle nesting level calculation logic', () => { - // Test the nesting level calculation logic const testCases = [ { nodes: [], parentId: undefined, expectedLevel: 0 }, { nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 }, diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/loop-node/loop-node.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx diff --git a/apps/sim/app/w/[id]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/notifications/notifications.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/chat/chat.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/chat/components/chat-message/chat-message.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/chat/components/chat-modal/chat-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx similarity index 97% rename from apps/sim/app/w/[id]/components/panel/components/chat/components/chat-modal/chat-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx index 31b887312a2..f0b8d9498a2 100644 --- a/apps/sim/app/w/[id]/components/panel/components/chat/components/chat-modal/chat-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx @@ -4,8 +4,8 @@ import { type KeyboardEvent, useEffect, useMemo, useRef } from 'react' import { ArrowUp, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { JSONView } from '@/app/w/[id]/components/panel/components/console/components/json-view/json-view' -import { useWorkflowExecution } from '@/app/w/[id]/hooks/use-workflow-execution' +import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useExecutionStore } from '@/stores/execution/store' import { useChatStore } from '@/stores/panel/chat/store' import type { ChatMessage as ChatMessageType } from '@/stores/panel/chat/types' diff --git a/apps/sim/app/w/[id]/components/panel/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/chat/components/output-select/output-select.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/console/components/audio-player/audio-player.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/audio-player/audio-player.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/console/components/audio-player/audio-player.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/audio-player/audio-player.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/console/components/console-entry/console-entry.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/console/components/json-view/json-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/console/components/json-view/json-view.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/console/console.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/panel/components/console/console.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx diff --git a/apps/sim/app/w/[id]/components/panel/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/panel/components/variables/variables.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx index 7954408c104..598a5aab282 100644 --- a/apps/sim/app/w/[id]/components/panel/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx @@ -42,12 +42,12 @@ export function Variables({ panelWidth }: VariablesProps) { // Get variables for the current workflow const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : [] - // Load variables when workflow changes + // Load variables when active workflow changes useEffect(() => { - if (activeWorkflowId && workflows[activeWorkflowId]) { + if (activeWorkflowId) { loadVariables(activeWorkflowId) } - }, [activeWorkflowId, workflows, loadVariables]) + }, [activeWorkflowId, loadVariables]) // Track editor references const editorRefs = useRef>({}) diff --git a/apps/sim/app/w/[id]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/panel/panel.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 7afc5e3eb1e..27494712dc0 100644 --- a/apps/sim/app/w/[id]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -5,8 +5,8 @@ import { Expand, PanelRight } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useChatStore } from '@/stores/panel/chat/store' import { useConsoleStore } from '@/stores/panel/console/store' +import { usePanelStore } from '@/stores/panel/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { usePanelStore } from '../../../../../stores/panel/store' import { Chat } from './components/chat/chat' import { ChatModal } from './components/chat/components/chat-modal/chat-modal' import { Console } from './components/console/console' diff --git a/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/components/parallel-badges.tsx similarity index 94% rename from apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/components/parallel-badges.tsx index 5b1604e09ce..de57ce42071 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/components/parallel-badges.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/components/parallel-badges.tsx @@ -82,33 +82,12 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) { (newType: 'count' | 'collection') => { if (isPreview) return // Don't allow changes in preview mode - // Update the parallel type using collaborative function - this will persist to database + // Use single collaborative function that handles all the state changes atomically collaborativeUpdateParallelType(nodeId, newType) - // Reset values based on type - if (newType === 'count') { - collaborativeUpdateParallelCollection(nodeId, '') - collaborativeUpdateParallelCount(nodeId, iterations) - } else { - collaborativeUpdateParallelCount(nodeId, 1) - const collectionValue = - typeof editorValue === 'string' - ? editorValue || '[]' - : JSON.stringify(editorValue) || '[]' - collaborativeUpdateParallelCollection(nodeId, collectionValue) - } - setTypePopoverOpen(false) }, - [ - nodeId, - iterations, - editorValue, - collaborativeUpdateParallelCount, - collaborativeUpdateParallelCollection, - collaborativeUpdateParallelType, - isPreview, - ] + [nodeId, collaborativeUpdateParallelType, isPreview] ) // Handle iterations input change diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-config.ts similarity index 100% rename from apps/sim/app/w/[id]/components/parallel-node/parallel-config.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-config.ts diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.test.tsx similarity index 81% rename from apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.test.tsx index 260a691795d..a6bf26b5f57 100644 --- a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.test.tsx @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ParallelNodeComponent } from './parallel-node' -// Mock dependencies that don't need DOM vi.mock('@/stores/workflows/workflow/store', () => ({ useWorkflowStore: vi.fn(), })) @@ -16,7 +15,6 @@ vi.mock('@/lib/logs/console-logger', () => ({ })), })) -// Mock ReactFlow components and hooks vi.mock('reactflow', () => ({ Handle: ({ id, type, position }: any) => ({ id, type, position }), Position: { @@ -32,7 +30,6 @@ vi.mock('reactflow', () => ({ memo: (component: any) => component, })) -// Mock React hooks vi.mock('react', async () => { const actual = await vi.importActual('react') return { @@ -43,7 +40,6 @@ vi.mock('react', async () => { } }) -// Mock UI components vi.mock('@/components/ui/button', () => ({ Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }), })) @@ -52,15 +48,21 @@ vi.mock('@/components/ui/card', () => ({ Card: ({ children, ...props }: any) => ({ children, ...props }), })) -vi.mock('@/components/icons', () => ({ - StartIcon: ({ className }: any) => ({ className }), +vi.mock('@/blocks/registry', () => ({ + getBlock: vi.fn(() => ({ + name: 'Mock Block', + description: 'Mock block description', + icon: () => null, + subBlocks: [], + outputs: {}, + })), + getAllBlocks: vi.fn(() => ({})), })) vi.mock('@/lib/utils', () => ({ cn: (...classes: any[]) => classes.filter(Boolean).join(' '), })) -// Mock the ParallelBadges component vi.mock('./components/parallel-badges', () => ({ ParallelBadges: ({ parallelId }: any) => ({ parallelId }), })) @@ -87,8 +89,6 @@ describe('ParallelNodeComponent', () => { beforeEach(() => { vi.clearAllMocks() - // Mock useWorkflowStore - ;(useWorkflowStore as any).mockImplementation((selector: any) => { const state = { removeBlock: mockRemoveBlock, @@ -96,54 +96,33 @@ describe('ParallelNodeComponent', () => { return selector(state) }) - // Mock getNodes mockGetNodes.mockReturnValue([]) }) describe('Component Definition and Structure', () => { - it('should be defined as a function component', () => { + it.concurrent('should be defined as a function component', () => { expect(ParallelNodeComponent).toBeDefined() expect(typeof ParallelNodeComponent).toBe('function') }) - it('should have correct display name', () => { + it.concurrent('should have correct display name', () => { expect(ParallelNodeComponent.displayName).toBe('ParallelNodeComponent') }) - it('should be a memoized component', () => { - // Since we mocked memo to return the component as-is, we can verify it exists + it.concurrent('should be a memoized component', () => { expect(ParallelNodeComponent).toBeDefined() }) }) describe('Props Validation and Type Safety', () => { - it('should accept NodeProps interface', () => { - // Test that the component accepts the correct prop types - const validProps = { - id: 'test-id', - type: 'parallelNode' as const, - data: { - width: 400, - height: 300, - state: 'valid' as const, - }, - selected: false, - zIndex: 1, - isConnectable: true, - xPos: 0, - yPos: 0, - dragging: false, - } - - // This tests that TypeScript compilation succeeds with these props + it.concurrent('should accept NodeProps interface', () => { expect(() => { - // We're not calling the component, just verifying the types const _component: typeof ParallelNodeComponent = ParallelNodeComponent expect(_component).toBeDefined() }).not.toThrow() }) - it('should handle different data configurations', () => { + it.concurrent('should handle different data configurations', () => { const configurations = [ { width: 500, height: 300, state: 'valid' }, { width: 800, height: 600, state: 'invalid' }, @@ -162,11 +141,9 @@ describe('ParallelNodeComponent', () => { }) describe('Store Integration', () => { - it('should integrate with workflow store', () => { - // Test that the component uses the store correctly + it.concurrent('should integrate with workflow store', () => { expect(useWorkflowStore).toBeDefined() - // Verify the store selector function works const mockState = { removeBlock: mockRemoveBlock } const selector = vi.fn((state) => state.removeBlock) @@ -177,19 +154,17 @@ describe('ParallelNodeComponent', () => { expect(selector(mockState)).toBe(mockRemoveBlock) }) - it('should handle removeBlock function', () => { + it.concurrent('should handle removeBlock function', () => { expect(mockRemoveBlock).toBeDefined() expect(typeof mockRemoveBlock).toBe('function') - // Test calling removeBlock mockRemoveBlock('test-id') expect(mockRemoveBlock).toHaveBeenCalledWith('test-id') }) }) describe('Component Logic Tests', () => { - it('should handle nesting level calculation logic', () => { - // Test the nesting level calculation logic (same as loop node) + it.concurrent('should handle nesting level calculation logic', () => { const testCases = [ { nodes: [], parentId: undefined, expectedLevel: 0 }, { nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 }, @@ -206,7 +181,6 @@ describe('ParallelNodeComponent', () => { testCases.forEach(({ nodes, parentId, expectedLevel }) => { mockGetNodes.mockReturnValue(nodes) - // Simulate the nesting level calculation logic let level = 0 let currentParentId = parentId @@ -221,8 +195,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle nested styles generation for parallel nodes', () => { - // Test the nested styles logic with parallel-specific colors + it.concurrent('should handle nested styles generation for parallel nodes', () => { const testCases = [ { nestingLevel: 0, state: 'valid', expectedBg: 'rgba(254,225,43,0.05)' }, { nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' }, @@ -231,7 +204,6 @@ describe('ParallelNodeComponent', () => { ] testCases.forEach(({ nestingLevel, state, expectedBg }) => { - // Simulate the getNestedStyles logic for parallel nodes const styles: Record = { backgroundColor: state === 'valid' ? 'rgba(254,225,43,0.05)' : 'transparent', } @@ -248,14 +220,13 @@ describe('ParallelNodeComponent', () => { }) describe('Parallel-Specific Features', () => { - it('should handle parallel execution states', () => { + it.concurrent('should handle parallel execution states', () => { const parallelStates = ['valid', 'invalid', 'executing', 'completed', 'pending'] parallelStates.forEach((state) => { const data = { width: 500, height: 300, state } expect(data.state).toBe(state) - // Test parallel-specific state handling const isExecuting = state === 'executing' const isCompleted = state === 'completed' @@ -264,8 +235,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle parallel node color scheme', () => { - // Test that parallel nodes use yellow color scheme + it.concurrent('should handle parallel node color scheme', () => { const parallelColors = { background: 'rgba(254,225,43,0.05)', ring: '#FEE12B', @@ -277,8 +247,7 @@ describe('ParallelNodeComponent', () => { expect(parallelColors.startIcon).toBe('#FEE12B') }) - it('should differentiate from loop node styling', () => { - // Ensure parallel nodes have different styling than loop nodes + it.concurrent('should differentiate from loop node styling', () => { const loopColors = { background: 'rgba(34,197,94,0.05)', ring: '#2FB3FF', @@ -298,7 +267,7 @@ describe('ParallelNodeComponent', () => { }) describe('Component Configuration', () => { - it('should handle different dimensions', () => { + it.concurrent('should handle different dimensions', () => { const dimensionTests = [ { width: 500, height: 300 }, { width: 800, height: 600 }, @@ -313,7 +282,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle different states', () => { + it.concurrent('should handle different states', () => { const stateTests = ['valid', 'invalid', 'pending', 'executing', 'completed'] stateTests.forEach((state) => { @@ -324,12 +293,11 @@ describe('ParallelNodeComponent', () => { }) describe('Event Handling Logic', () => { - it('should handle delete button click logic', () => { + it.concurrent('should handle delete button click logic', () => { const mockEvent = { stopPropagation: vi.fn(), } - // Simulate the delete button click handler const handleDelete = (e: any, nodeId: string) => { e.stopPropagation() mockRemoveBlock(nodeId) @@ -341,19 +309,18 @@ describe('ParallelNodeComponent', () => { expect(mockRemoveBlock).toHaveBeenCalledWith('test-id') }) - it('should handle event propagation prevention', () => { + it.concurrent('should handle event propagation prevention', () => { const mockEvent = { stopPropagation: vi.fn(), } - // Test that stopPropagation is called mockEvent.stopPropagation() expect(mockEvent.stopPropagation).toHaveBeenCalled() }) }) describe('Component Data Handling', () => { - it('should handle missing data properties gracefully', () => { + it.concurrent('should handle missing data properties gracefully', () => { const testCases = [ undefined, {}, @@ -375,7 +342,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle parent ID relationships', () => { + it.concurrent('should handle parent ID relationships', () => { const testCases = [ { parentId: undefined, hasParent: false }, { parentId: 'parent-1', hasParent: true }, @@ -390,7 +357,7 @@ describe('ParallelNodeComponent', () => { }) describe('Handle Configuration', () => { - it('should have correct handle IDs for parallel nodes', () => { + it.concurrent('should have correct handle IDs for parallel nodes', () => { const handleIds = { startSource: 'parallel-start-source', endSource: 'parallel-end-source', @@ -402,7 +369,7 @@ describe('ParallelNodeComponent', () => { expect(handleIds.endSource).not.toContain('loop') }) - it('should handle different handle positions', () => { + it.concurrent('should handle different handle positions', () => { const positions = { left: 'left', right: 'right', @@ -418,7 +385,7 @@ describe('ParallelNodeComponent', () => { }) describe('Edge Cases and Error Handling', () => { - it('should handle circular parent references', () => { + it.concurrent('should handle circular parent references', () => { // Test circular reference prevention const nodes = [ { id: 'node1', data: { parentId: 'node2' } }, @@ -456,7 +423,7 @@ describe('ParallelNodeComponent', () => { expect(visited.has('node2')).toBe(true) }) - it('should handle complex circular reference chains', () => { + it.concurrent('should handle complex circular reference chains', () => { // Test more complex circular reference scenarios const nodes = [ { id: 'node1', data: { parentId: 'node2' } }, @@ -489,7 +456,7 @@ describe('ParallelNodeComponent', () => { expect(visited.size).toBe(3) }) - it('should handle self-referencing nodes', () => { + it.concurrent('should handle self-referencing nodes', () => { // Test node that references itself const nodes = [ { id: 'node1', data: { parentId: 'node1' } }, // Self-reference @@ -520,7 +487,7 @@ describe('ParallelNodeComponent', () => { expect(visited.has('node1')).toBe(true) }) - it('should handle extreme values', () => { + it.concurrent('should handle extreme values', () => { const extremeValues = [ { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER }, { width: -1, height: -1 }, @@ -538,7 +505,7 @@ describe('ParallelNodeComponent', () => { }) }) - it('should handle negative position values', () => { + it.concurrent('should handle negative position values', () => { const positions = [ { xPos: -100, yPos: -200 }, { xPos: 0, yPos: 0 }, @@ -556,7 +523,7 @@ describe('ParallelNodeComponent', () => { }) describe('Component Comparison with Loop Node', () => { - it('should have similar structure to loop node but different type', () => { + it.concurrent('should have similar structure to loop node but different type', () => { expect(defaultProps.type).toBe('parallelNode') expect(defaultProps.id).toContain('parallel') @@ -565,7 +532,7 @@ describe('ParallelNodeComponent', () => { expect(defaultProps.id).not.toContain('loop') }) - it('should handle the same prop structure as loop node', () => { + it.concurrent('should handle the same prop structure as loop node', () => { // Test that parallel node accepts the same prop structure as loop node const sharedPropStructure = { id: 'test-parallel', @@ -594,8 +561,7 @@ describe('ParallelNodeComponent', () => { expect(sharedPropStructure.data.height).toBe(300) }) - it('should maintain consistency with loop node interface', () => { - // Both components should accept the same base props + it.concurrent('should maintain consistency with loop node interface', () => { const baseProps = [ 'id', 'type', diff --git a/apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/parallel-node/parallel-node.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx diff --git a/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/skeleton-loading/skeleton-loading.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/skeleton-loading/skeleton-loading.tsx index 141700a1ec2..de8e90cdfab 100644 --- a/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/skeleton-loading/skeleton-loading.tsx @@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useSidebarStore } from '@/stores/sidebar/store' -// Skeleton Components const SkeletonControlBar = () => { return (
diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-block/toolbar-block.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-block/toolbar-block.tsx diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block.tsx diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx diff --git a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar.tsx similarity index 95% rename from apps/sim/app/w/[id]/components/toolbar/toolbar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar.tsx index 8d86f8407f5..177413e6ed7 100644 --- a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar.tsx @@ -6,7 +6,7 @@ import { useParams } from 'next/navigation' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { getAllBlocks, getBlocksByCategory } from '@/blocks' import type { BlockCategory } from '@/blocks/types' import { useSidebarStore } from '@/stores/sidebar/store' @@ -43,16 +43,15 @@ export const Toolbar = React.memo(() => { const params = useParams() const workflowId = params?.id as string - // Get the workspace ID from the workflow registry - const { activeWorkspaceId, workflows } = useWorkflowRegistry() + // Get the workspace ID from URL params + const { workflows } = useWorkflowRegistry() + const workspaceId = params.workspaceId as string const currentWorkflow = useMemo( () => (workflowId ? workflows[workflowId] : null), [workflowId, workflows] ) - const workspaceId = currentWorkflow?.workspaceId || activeWorkspaceId - const userPermissions = useUserPermissionsContext() const [activeTab, setActiveTab] = useState('blocks') diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx similarity index 96% rename from apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 7f926ef4fa0..1e7c5e7e967 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -12,8 +12,7 @@ interface ActionBarProps { } export function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { - const { collaborativeRemoveBlock } = useCollaborativeWorkflow() - const toggleBlockEnabled = useWorkflowStore((state) => state.toggleBlockEnabled) + const { collaborativeRemoveBlock, collaborativeToggleBlockEnabled } = useCollaborativeWorkflow() const toggleBlockHandles = useWorkflowStore((state) => state.toggleBlockHandles) const duplicateBlock = useWorkflowStore((state) => state.duplicateBlock) const isEnabled = useWorkflowStore((state) => state.blocks[blockId]?.enabled ?? true) @@ -56,7 +55,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro size='sm' onClick={() => { if (!disabled) { - toggleBlockEnabled(blockId) + collaborativeToggleBlockEnabled(blockId) } }} className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connection-blocks/connection-blocks.tsx similarity index 97% rename from apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connection-blocks/connection-blocks.tsx index baf322f53e7..b9f17f57024 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connection-blocks/connection-blocks.tsx @@ -1,6 +1,9 @@ import { Card } from '@/components/ui/card' import { cn } from '@/lib/utils' -import { type ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections' +import { + type ConnectedBlock, + useBlockConnections, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections' import { useSubBlockStore } from '@/stores/workflows/subblock/store' interface ConnectionBlocksProps { diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/checkbox-list.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/checkbox-list.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/checkbox-list.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx index d47adca2d8e..a228efb1714 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx @@ -11,7 +11,7 @@ import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-drop import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { useCodeGeneration } from '@/app/w/[id]/hooks/use-code-generation' +import { useCodeGeneration } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { CodePromptBar } from '../../../../code-prompt-bar/code-prompt-bar' import { useSubBlockValue } from '../hooks/use-sub-block-value' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/condition-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/condition-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/condition-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/date-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/date-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/date-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/date-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/dropdown.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/dropdown.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/dropdown.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/eval-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/eval-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/eval-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/eval-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-calendar-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-upload.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx similarity index 98% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx index 2a0d9990eab..31abe42a2b3 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx @@ -15,7 +15,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { createLogger } from '@/lib/logs/console-logger' import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' +import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import { saveToStorage } from '@/stores/workflows/persistence' const logger = createLogger('FolderSelector') diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/long-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-project-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/response/response-format.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/response/response-format.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/components/schedule-modal.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx index 182b8ae6922..9b9c58f6d1c 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx @@ -46,7 +46,7 @@ export function ScheduleConfig({ const [refreshCounter, setRefreshCounter] = useState(0) const params = useParams() - const workflowId = params.id as string + const workflowId = params.workflowId as string // Get workflow state from store const setScheduleStatus = useWorkflowStore((state) => state.setScheduleStatus) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/short-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/slider-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/slider-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/slider-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/slider-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/switch.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/switch.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/switch.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/switch.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/table.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/table.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/table.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/table.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/time-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/time-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/time-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/time-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index aee40413bec..84f74fc0833 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -24,7 +24,7 @@ import { Label } from '@/components/ui/label' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { useCodeGeneration } from '@/app/w/[id]/hooks/use-code-generation' +import { useCodeGeneration } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { CodePromptBar } from '../../../../../../../code-prompt-bar/code-prompt-bar' import { CodeEditor } from '../code-editor/code-editor' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/airtable.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/airtable.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/airtable.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/airtable.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/discord.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/discord.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/discord.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/discord.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/generic.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/generic.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/generic.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/generic.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/github.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/github.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/github.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/github.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx similarity index 98% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx index a23eb809d83..3168af8fbfe 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/gmail.tsx @@ -16,7 +16,7 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Logger } from '@/lib/logs/console-logger' -import { JSONView } from '@/app/w/[id]/components/panel/components/console/components/json-view/json-view' +import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view' import { ConfigSection } from '../ui/config-section' const logger = new Logger('GmailConfig') diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack.tsx similarity index 97% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack.tsx index cb584d2e751..4bb03e252f1 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack.tsx @@ -1,6 +1,6 @@ import { SlackIcon } from '@/components/icons' import { Notice } from '@/components/ui/notice' -import { JSONView } from '@/app/w/[id]/components/panel/components/console/components/json-view/json-view' +import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view' import { ConfigSection } from '../ui/config-section' import { InstructionsSection } from '../ui/instructions-section' import { TestResultDisplay } from '../ui/test-result' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/stripe.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/stripe.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/stripe.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/stripe.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/telegram.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/telegram.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/telegram.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/telegram.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/whatsapp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/whatsapp.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/whatsapp.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/whatsapp.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-field.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-field.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-field.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-field.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-section.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-section.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/config-section.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/confirmation.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/confirmation.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/confirmation.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/confirmation.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/copyable.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/copyable.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/copyable.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/copyable.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/instructions-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/instructions-section.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/instructions-section.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/instructions-section.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/test-result.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/test-result.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/test-result.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/test-result.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-config-field.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-config-field.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-config-field.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-config-field.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-url.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx similarity index 99% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx index a7a82be82ab..041ddd7aa41 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/webhook.tsx @@ -310,7 +310,7 @@ export function WebhookConfig({ const [error, setError] = useState(null) const [webhookId, setWebhookId] = useState(null) const params = useParams() - const workflowId = params.id as string + const workflowId = params.workflowId as string const [isLoading, setIsLoading] = useState(false) const [gmailCredentialId, setGmailCredentialId] = useState('') diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx diff --git a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx similarity index 96% rename from apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 82cf37cfa1d..0ffb7258664 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -7,8 +7,9 @@ import { Card } from '@/components/ui/card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { parseCronToHumanReadable } from '@/lib/schedules/utils' import { cn, formatDateTime, validateName } from '@/lib/utils' -import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useExecutionStore } from '@/stores/execution/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' @@ -67,11 +68,15 @@ export function WorkflowBlock({ id, data }: NodeProps) { const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0) const hasActiveWebhook = useWorkflowStore((state) => state.hasActiveWebhook ?? false) const blockAdvancedMode = useWorkflowStore((state) => state.blocks[id]?.advancedMode ?? false) - const toggleBlockAdvancedMode = useWorkflowStore((state) => state.toggleBlockAdvancedMode) + + // Collaborative workflow actions + const { + collaborativeUpdateBlockName, + collaborativeToggleBlockWide, + collaborativeToggleBlockAdvancedMode, + } = useCollaborativeWorkflow() // Workflow store actions - const updateBlockName = useWorkflowStore((state) => state.updateBlockName) - const toggleBlockWide = useWorkflowStore((state) => state.toggleBlockWide) const updateBlockHeight = useWorkflowStore((state) => state.updateBlockHeight) // Execution store @@ -371,7 +376,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { const handleNameSubmit = () => { const trimmedName = editedName.trim().slice(0, 18) if (trimmedName && trimmedName !== name) { - updateBlockName(id, trimmedName) + collaborativeUpdateBlockName(id, trimmedName) } setIsEditing(false) } @@ -622,14 +627,27 @@ export function WorkflowBlock({ id, data }: NodeProps) { - {blockAdvancedMode ? 'Switch to Basic Mode' : 'Switch to Advanced Mode'} + {!userPermissions.canEdit + ? 'Read-only mode' + : blockAdvancedMode + ? 'Switch to Basic Mode' + : 'Switch to Advanced Mode'} )} @@ -704,7 +722,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { size='sm' onClick={() => { if (userPermissions.canEdit) { - toggleBlockWide(id) + collaborativeToggleBlockWide(id) } }} className={cn( diff --git a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx similarity index 100% rename from apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx diff --git a/apps/sim/app/w/[id]/hooks/use-block-connections.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts similarity index 100% rename from apps/sim/app/w/[id]/hooks/use-block-connections.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts diff --git a/apps/sim/app/w/[id]/hooks/use-code-generation.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation.ts similarity index 100% rename from apps/sim/app/w/[id]/hooks/use-code-generation.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation.ts diff --git a/apps/sim/app/w/[id]/hooks/use-presence.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts similarity index 76% rename from apps/sim/app/w/[id]/hooks/use-presence.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts index 3b86676e64f..2e3b1a5f8ab 100644 --- a/apps/sim/app/w/[id]/hooks/use-presence.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-presence.ts @@ -3,8 +3,8 @@ import { useMemo } from 'react' import { useSocket } from '@/contexts/socket-context' -interface PresenceUser { - connectionId: number +type PresenceUser = { + connectionId: string | number name?: string color?: string info?: string @@ -25,9 +25,9 @@ export function usePresence(): UsePresenceReturn { const users = useMemo(() => { return presenceUsers.map((user, index) => ({ - connectionId: user.socketId - ? Math.abs(user.socketId.split('').reduce((a, b) => a + b.charCodeAt(0), 0)) - : index + 1, + // Use socketId directly as connectionId to ensure uniqueness + // If no socketId, use a unique fallback based on userId + index + connectionId: user.socketId || `fallback-${user.userId}-${index}`, name: user.userName, color: undefined, // Let the avatar component generate colors info: user.selection?.type ? `Editing ${user.selection.type}` : undefined, diff --git a/apps/sim/app/w/[id]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts similarity index 100% rename from apps/sim/app/w/[id]/hooks/use-workflow-execution.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts diff --git a/apps/sim/app/w/[id]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx similarity index 100% rename from apps/sim/app/w/[id]/layout.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx diff --git a/apps/sim/app/w/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx similarity index 100% rename from apps/sim/app/w/[id]/page.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx diff --git a/apps/sim/app/w/[id]/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils.ts similarity index 100% rename from apps/sim/app/w/[id]/utils.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils.ts diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx similarity index 92% rename from apps/sim/app/w/[id]/workflow.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index e656b3fb1eb..72606e7f03a 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -11,12 +11,16 @@ import ReactFlow, { useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' - import { createLogger } from '@/lib/logs/console-logger' -import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' -import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' -import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node' -import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' +import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar' +import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' +import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node' +import { NotificationList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications' +import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' +import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node' +import { SkeletonLoading } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/skeleton-loading/skeleton-loading' +import { Toolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' import { useSocket } from '@/contexts/socket-context' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' @@ -26,14 +30,8 @@ import { useNotificationStore } from '@/stores/notifications/store' import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useSidebarStore } from '@/stores/sidebar/store' -// Removed sync manager import - Socket.IO handles real-time sync import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { ControlBar } from './components/control-bar/control-bar' -import { ErrorBoundary } from './components/error/index' -import { Panel } from './components/panel/panel' -import { SkeletonLoading } from './components/skeleton-loading/skeleton-loading' -import { Toolbar } from './components/toolbar/toolbar' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' import { @@ -95,11 +93,16 @@ const WorkflowContent = React.memo(() => { const { project, getNodes, fitView } = useReactFlow() // Get workspace ID from current workflow - const workflowId = params.id as string + const workflowId = params.workflowId as string const { workflows, activeWorkflowId, isLoading, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - const { blocks, edges, updateNodeDimensions } = useWorkflowStore() + const { + blocks, + edges, + updateNodeDimensions, + updateBlockPosition: storeUpdateBlockPosition, + } = useWorkflowStore() // Use collaborative operations for real-time sync const currentWorkflow = useMemo(() => workflows[workflowId], [workflows, workflowId]) const workspaceId = currentWorkflow?.workspaceId @@ -117,7 +120,7 @@ const WorkflowContent = React.memo(() => { collaborativeAddBlock: addBlock, collaborativeAddEdge: addEdge, collaborativeRemoveEdge: removeEdge, - collaborativeUpdateBlockPosition: updateBlockPosition, + collaborativeUpdateBlockPosition, collaborativeUpdateParentId: updateParentId, isConnected, currentWorkflowId, @@ -186,12 +189,12 @@ const WorkflowContent = React.memo(() => { nodeId, newParentId, getNodes, - updateBlockPosition, + collaborativeUpdateBlockPosition, updateParentId, () => resizeLoopNodes(getNodes, updateNodeDimensions, blocks) ) }, - [getNodes, updateBlockPosition, updateParentId, updateNodeDimensions, blocks] + [getNodes, collaborativeUpdateBlockPosition, updateParentId, updateNodeDimensions, blocks] ) // Function to resize all loop nodes with improved hierarchy handling @@ -256,13 +259,20 @@ const WorkflowContent = React.memo(() => { [detectedOrientation] ) - applyAutoLayoutSmooth(blocks, edges, updateBlockPosition, fitView, resizeLoopNodesWrapper, { - ...orientationConfig, - alignByLayer: true, - animationDuration: 500, // Smooth 500ms animation - isSidebarCollapsed, - handleOrientation: detectedOrientation, // Explicitly set the detected orientation - }) + applyAutoLayoutSmooth( + blocks, + edges, + collaborativeUpdateBlockPosition, + fitView, + resizeLoopNodesWrapper, + { + ...orientationConfig, + alignByLayer: true, + animationDuration: 500, // Smooth 500ms animation + isSidebarCollapsed, + handleOrientation: detectedOrientation, // Explicitly set the detected orientation + } + ) const orientationMessage = detectedOrientation === 'vertical' @@ -273,7 +283,14 @@ const WorkflowContent = React.memo(() => { orientation: detectedOrientation, blockCount: Object.keys(blocks).length, }) - }, [blocks, edges, updateBlockPosition, fitView, isSidebarCollapsed, resizeLoopNodesWrapper]) + }, [ + blocks, + edges, + collaborativeUpdateBlockPosition, + fitView, + isSidebarCollapsed, + resizeLoopNodesWrapper, + ]) const debouncedAutoLayout = useCallback(() => { const debounceTimer = setTimeout(() => { @@ -787,7 +804,7 @@ const WorkflowContent = React.memo(() => { // Track when workflow is fully ready for rendering useEffect(() => { - const currentId = params.id as string + const currentId = params.workflowId as string // Reset workflow ready state when workflow changes if (activeWorkflowId !== currentId) { @@ -813,13 +830,13 @@ const WorkflowContent = React.memo(() => { return () => clearTimeout(timeoutId) } setIsWorkflowReady(false) - }, [activeWorkflowId, params.id, workflows, isLoading]) + }, [activeWorkflowId, params.workflowId, workflows, isLoading]) // Init workflow useEffect(() => { const validateAndNavigate = async () => { const workflowIds = Object.keys(workflows) - const currentId = params.id as string + const currentId = params.workflowId as string // Wait for both initialization and workflow loading to complete if (isLoading) { @@ -830,27 +847,26 @@ const WorkflowContent = React.memo(() => { // If no workflows exist, redirect to workspace root to let server handle workflow creation if (workflowIds.length === 0 && !isLoading) { logger.info('No workflows found, redirecting to workspace root') - router.replace('/w') + router.replace(`/workspace/${workspaceId}/w`) return } // Navigate to existing workflow or first available if (!workflows[currentId]) { logger.info(`Workflow ${currentId} not found, redirecting to first available workflow`) - router.replace(`/w/${workflowIds[0]}`) + router.replace(`/workspace/${workspaceId}/w/${workflowIds[0]}`) return } - // Reset variables loaded state before setting active workflow - resetVariablesLoaded() - - // Always call setActiveWorkflow when workflow ID changes to ensure proper state + // Get current active workflow state const { activeWorkflowId } = useWorkflowRegistry.getState() if (activeWorkflowId !== currentId) { + // Only reset variables when actually switching workflows + resetVariablesLoaded() setActiveWorkflow(currentId) } else { - // Even if the workflow is already active, call setActiveWorkflow to ensure state consistency + // Don't reset variables cache if we're not actually switching workflows setActiveWorkflow(currentId) } @@ -859,7 +875,7 @@ const WorkflowContent = React.memo(() => { validateAndNavigate() }, [ - params.id, + params.workflowId, workflows, isLoading, setActiveWorkflow, @@ -956,18 +972,20 @@ const WorkflowContent = React.memo(() => { return nodeArray }, [blocks, activeBlockIds, pendingBlocks, isDebugModeEnabled, nestedSubflowErrors]) - // Update nodes + // Update nodes - use store version to avoid collaborative feedback loops const onNodesChange = useCallback( (changes: any) => { changes.forEach((change: any) => { if (change.type === 'position' && change.position) { const node = nodes.find((n) => n.id === change.id) if (!node) return - updateBlockPosition(change.id, change.position) + // Use store version to avoid collaborative feedback loop + // React Flow position changes can be triggered by collaborative updates + storeUpdateBlockPosition(change.id, change.position) } }) }, - [nodes, updateBlockPosition] + [nodes, storeUpdateBlockPosition] ) // Effect to resize loops when nodes change (add/remove/position change) @@ -1002,11 +1020,16 @@ const WorkflowContent = React.memo(() => { const absolutePosition = getNodeAbsolutePositionWrapper(id) // Update the node to remove parent reference and use absolute position - updateBlockPosition(id, absolutePosition) + collaborativeUpdateBlockPosition(id, absolutePosition) updateParentId(id, '', 'parent') } }) - }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]) + }, [blocks, collaborativeUpdateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]) + + // Validate nested subflows whenever blocks change + useEffect(() => { + validateNestedSubflows() + }, [blocks, validateNestedSubflows]) // Validate nested subflows whenever blocks change useEffect(() => { @@ -1109,6 +1132,9 @@ const WorkflowContent = React.memo(() => { // Store currently dragged node ID setDraggedNodeId(node.id) + // Emit collaborative position update during drag for smooth real-time movement + collaborativeUpdateBlockPosition(node.id, node.position) + // Get the current parent ID of the node being dragged const currentParentId = blocks[node.id]?.data?.parentId || null @@ -1255,6 +1281,7 @@ const WorkflowContent = React.memo(() => { getNodeHierarchyWrapper, getNodeAbsolutePositionWrapper, getNodeDepthWrapper, + collaborativeUpdateBlockPosition, ] ) @@ -1277,7 +1304,11 @@ const WorkflowContent = React.memo(() => { }) document.body.style.cursor = '' - // Don't process if the node hasn't actually changed parent or is being moved within same parent + // Emit collaborative position update for the final position + // This ensures other users see the smooth final position + collaborativeUpdateBlockPosition(node.id, node.position) + + // Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent if (potentialParentId === dragStartParentId) return // Check if this is a starter block - starter blocks should never be in containers @@ -1320,7 +1351,14 @@ const WorkflowContent = React.memo(() => { setDraggedNodeId(null) setPotentialParentId(null) }, - [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchyWrapper] + [ + getNodes, + dragStartParentId, + potentialParentId, + updateNodeParent, + getNodeHierarchyWrapper, + collaborativeUpdateBlockPosition, + ] ) // Update onPaneClick to only handle edge selection @@ -1438,7 +1476,7 @@ const WorkflowContent = React.memo(() => {
@@ -1454,12 +1492,12 @@ const WorkflowContent = React.memo(() => { return (
-
+
0} />
diff --git a/apps/sim/app/w/components/providers/providers.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/providers/providers.tsx similarity index 100% rename from apps/sim/app/w/components/providers/providers.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/providers/providers.tsx diff --git a/apps/sim/app/w/components/providers/theme-provider.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/providers/theme-provider.tsx similarity index 100% rename from apps/sim/app/w/components/providers/theme-provider.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/providers/theme-provider.tsx diff --git a/apps/sim/app/w/components/providers/workspace-permissions-provider.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider.tsx similarity index 86% rename from apps/sim/app/w/components/providers/workspace-permissions-provider.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider.tsx index 77b4dfbf5d8..9ebbd4286e8 100644 --- a/apps/sim/app/w/components/providers/workspace-permissions-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider.tsx @@ -1,12 +1,15 @@ 'use client' import React, { createContext, useContext, useMemo } from 'react' +import { useParams } from 'next/navigation' +import { createLogger } from '@/lib/logs/console-logger' import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkspacePermissions, type WorkspacePermissions, } from '@/hooks/use-workspace-permissions' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const logger = createLogger('WorkspacePermissionsProvider') interface WorkspacePermissionsContextType { // Raw workspace permissions data @@ -27,17 +30,20 @@ interface WorkspacePermissionsProviderProps { const WorkspacePermissionsProvider = React.memo( ({ children }) => { - const { activeWorkspaceId } = useWorkflowRegistry() + const params = useParams() + const workspaceId = params.workspaceId as string + + if (!workspaceId) { + logger.warn('Workspace ID is undefined from params:', params) + } - // Fetch workspace permissions once const { permissions: workspacePermissions, loading: permissionsLoading, error: permissionsError, updatePermissions, - } = useWorkspacePermissions(activeWorkspaceId) + } = useWorkspacePermissions(workspaceId) - // Compute user permissions based on workspace permissions const userPermissions = useUserPermissions( workspacePermissions, permissionsLoading, diff --git a/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx similarity index 94% rename from apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx index 5ff60db8657..188ee91ea8d 100644 --- a/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx @@ -1,7 +1,9 @@ 'use client' import { useState } from 'react' +import { logger } from '@sentry/nextjs' import { File, Folder, Plus } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' @@ -9,7 +11,6 @@ import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface CreateMenuProps { onCreateWorkflow: (folderId?: string) => void @@ -22,7 +23,8 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { const [isCreating, setIsCreating] = useState(false) const [isHoverOpen, setIsHoverOpen] = useState(false) - const { activeWorkspaceId } = useWorkflowRegistry() + const params = useParams() + const workspaceId = params.workspaceId as string const { createFolder } = useFolderStore() const handleCreateWorkflow = () => { @@ -37,18 +39,18 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { const handleFolderSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!folderName.trim() || !activeWorkspaceId) return + if (!folderName.trim() || !workspaceId) return setIsCreating(true) try { await createFolder({ name: folderName.trim(), - workspaceId: activeWorkspaceId, + workspaceId: workspaceId, }) setFolderName('') setShowFolderDialog(false) } catch (error) { - console.error('Failed to create folder:', error) + logger.error('Failed to create folder:', { error }) } finally { setIsCreating(false) } diff --git a/apps/sim/app/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx similarity index 93% rename from apps/sim/app/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx index fcda8a79bec..08cebccd299 100644 --- a/apps/sim/app/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-context-menu/folder-context-menu.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { File, Folder, MoreHorizontal, Pencil, Trash2 } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { @@ -13,8 +14,10 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { createLogger } from '@/lib/logs/console-logger' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const logger = createLogger('FolderContextMenu') interface FolderContextMenuProps { folderId: string @@ -37,8 +40,9 @@ export function FolderContextMenu({ const [renameName, setRenameName] = useState(folderName) const [isCreating, setIsCreating] = useState(false) const [isRenaming, setIsRenaming] = useState(false) + const params = useParams() + const workspaceId = params.workspaceId as string - const { activeWorkspaceId } = useWorkflowRegistry() const { createFolder, updateFolder, deleteFolder } = useFolderStore() const handleCreateWorkflow = () => { @@ -59,25 +63,25 @@ export function FolderContextMenu({ onDelete(folderId) } else { // Default delete behavior - deleteFolder(folderId) + deleteFolder(folderId, workspaceId) } } const handleSubfolderSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!subfolderName.trim() || !activeWorkspaceId) return + if (!subfolderName.trim() || !workspaceId) return setIsCreating(true) try { await createFolder({ name: subfolderName.trim(), - workspaceId: activeWorkspaceId, + workspaceId: workspaceId, parentId: folderId, }) setSubfolderName('') setShowSubfolderDialog(false) } catch (error) { - console.error('Failed to create subfolder:', error) + logger.error('Failed to create subfolder:', { error }) } finally { setIsCreating(false) } diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx similarity index 94% rename from apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx index db9ffe073a2..5d0accc0e3d 100644 --- a/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react' +import { useParams } from 'next/navigation' import { AlertDialog, AlertDialogAction, @@ -14,9 +15,12 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { createLogger } from '@/lib/logs/console-logger' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' import { FolderContextMenu } from '../../folder-context-menu/folder-context-menu' +const logger = createLogger('FolderItem') + interface FolderItemProps { folder: FolderTreeNode isCollapsed?: boolean @@ -39,7 +43,8 @@ export function FolderItem({ const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - + const params = useParams() + const workspaceId = params.workspaceId as string const isExpanded = expandedFolders.has(folder.id) const updateTimeoutRef = useRef | undefined>(undefined) const pendingStateRef = useRef(null) @@ -76,7 +81,7 @@ export function FolderItem({ try { await updateFolderAPI(folderId, { name: newName }) } catch (error) { - console.error('Failed to rename folder:', error) + logger.error('Failed to rename folder:', { error }) } } @@ -87,10 +92,10 @@ export function FolderItem({ const confirmDelete = async () => { setIsDeleting(true) try { - await deleteFolder(folder.id) + await deleteFolder(folder.id, workspaceId) setShowDeleteDialog(false) } catch (error) { - console.error('Failed to delete folder:', error) + logger.error('Failed to delete folder:', { error }) } finally { setIsDeleting(false) } diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx similarity index 91% rename from apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx index 07bf393d9bb..f054a4ca7a7 100644 --- a/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item.tsx @@ -3,10 +3,14 @@ import { useRef, useState } from 'react' import clsx from 'clsx' import Link from 'next/link' +import { useParams } from 'next/navigation' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { createLogger } from '@/lib/logs/console-logger' import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +const logger = createLogger('WorkflowItem') + interface WorkflowItemProps { workflow: WorkflowMetadata active: boolean @@ -26,6 +30,8 @@ export function WorkflowItem({ }: WorkflowItemProps) { const [isDragging, setIsDragging] = useState(false) const dragStartedRef = useRef(false) + const params = useParams() + const workspaceId = params.workspaceId as string const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore() const isSelected = useIsWorkflowSelected(workflow.id) @@ -74,7 +80,7 @@ export function WorkflowItem({ { - if (activeWorkspaceId) { - fetchFolders(activeWorkspaceId) + if (workspaceId) { + fetchFolders(workspaceId) } - }, [activeWorkspaceId, fetchFolders]) + }, [workspaceId, fetchFolders]) useEffect(() => { clearSelection() - }, [activeWorkspaceId, clearSelection]) + }, [workspaceId, clearSelection]) - const folderTree = activeWorkspaceId ? getFolderTree(activeWorkspaceId) : [] + const folderTree = workspaceId ? getFolderTree(workspaceId) : [] // Group workflows by folder const workflowsByFolder = regularWorkflows.reduce( @@ -255,7 +258,7 @@ export function FolderTree({ - No workflows or folders in {activeWorkspaceId ? 'this workspace' : 'your account'}. - Create one to get started. + No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create one + to get started.
)}
diff --git a/apps/sim/app/w/components/sidebar/components/help-modal/components/help-form/help-form.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/components/help-form/help-form.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/help-modal/components/help-form/help-form.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/components/help-form/help-form.tsx diff --git a/apps/sim/app/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/help-modal/help-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/invite-modal/invite-modal.tsx similarity index 98% rename from apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/invite-modal/invite-modal.tsx index fd28d8d910f..ff51008450e 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/invite-modal/invite-modal.tsx @@ -2,6 +2,7 @@ import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { HelpCircle, Loader2, X } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' @@ -15,10 +16,9 @@ import { cn } from '@/lib/utils' import { useUserPermissionsContext, useWorkspacePermissionsContext, -} from '@/app/w/components/providers/workspace-permissions-provider' +} from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions' import { API_ENDPOINTS } from '@/stores/constants' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('InviteModal') @@ -397,7 +397,9 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const [showSent, setShowSent] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const [successMessage, setSuccessMessage] = useState(null) - const { activeWorkspaceId } = useWorkflowRegistry() + const params = useParams() + const workspaceId = params.workspaceId as string + const { data: session } = useSession() const { workspacePermissions, @@ -410,7 +412,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const hasNewInvites = emails.length > 0 || inputValue.trim() const fetchPendingInvitations = useCallback(async () => { - if (!activeWorkspaceId) return + if (!workspaceId) return setIsPendingInvitationsLoading(true) try { @@ -421,7 +423,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { data.invitations ?.filter( (inv: PendingInvitation) => - inv.status === 'pending' && inv.workspaceId === activeWorkspaceId + inv.status === 'pending' && inv.workspaceId === workspaceId ) .map((inv: PendingInvitation) => ({ email: inv.email, @@ -436,10 +438,10 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } finally { setIsPendingInvitationsLoading(false) } - }, [activeWorkspaceId]) + }, [workspaceId]) useEffect(() => { - if (open && activeWorkspaceId) { + if (open && workspaceId) { fetchPendingInvitations() } }, [open, fetchPendingInvitations]) @@ -535,7 +537,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { ) const handleSaveChanges = useCallback(async () => { - if (!userPerms.canAdmin || !hasPendingChanges || !activeWorkspaceId) return + if (!userPerms.canAdmin || !hasPendingChanges || !workspaceId) return setIsSaving(true) setErrorMessage(null) @@ -546,7 +548,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { permissions: changes.permissionType || 'read', })) - const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(activeWorkspaceId), { + const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(workspaceId), { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -583,7 +585,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { }, [ userPerms.canAdmin, hasPendingChanges, - activeWorkspaceId, + workspaceId, existingUserPermissionChanges, updatePermissions, ]) @@ -646,7 +648,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setErrorMessage(null) setSuccessMessage(null) - if (emails.length === 0 || !activeWorkspaceId) { + if (emails.length === 0 || !workspaceId) { return } @@ -667,7 +669,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { 'Content-Type': 'application/json', }, body: JSON.stringify({ - workspaceId: activeWorkspaceId, + workspaceId, email: email, role: 'member', permission: permissionType, @@ -739,7 +741,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { inputValue, addEmail, emails, - activeWorkspaceId, + workspaceId, userPermissions, invalidEmails, fetchPendingInvitations, @@ -922,7 +924,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { !hasNewInvites || isSubmitting || isSaving || - !activeWorkspaceId + !workspaceId } className={cn( 'ml-auto gap-2 font-medium', diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx similarity index 96% rename from apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx index dc04a9e719c..12ff428a0f5 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { Table, @@ -11,7 +12,6 @@ import { TableRow, } from '@/components/ui/table' import { createLogger } from '@/lib/logs/console-logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('InvitesSent') @@ -47,11 +47,12 @@ export function InvitesSent() { const [invitations, setInvitations] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - const { activeWorkspaceId } = useWorkflowRegistry() + const params = useParams() + const workspaceId = params.workspaceId as string useEffect(() => { async function fetchInvitations() { - if (!activeWorkspaceId) { + if (!workspaceId) { setIsLoading(false) return } @@ -82,7 +83,7 @@ export function InvitesSent() { } fetchInvitations() - }, [activeWorkspaceId]) + }, [workspaceId]) const TableSkeleton = () => (
@@ -106,7 +107,7 @@ export function InvitesSent() { ) } - if (!activeWorkspaceId) { + if (!workspaceId) { return null } diff --git a/apps/sim/app/w/components/sidebar/components/nav-section/nav-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-section/nav-section.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/nav-section/nav-section.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-section/nav-section.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/account/account.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/account/account.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx similarity index 99% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx index da4faef5dc4..21ad99b186b 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx @@ -172,10 +172,10 @@ export function Credentials({ onOpenChange }: CredentialsProps) { } // Clear the URL parameters - router.replace('/w') + router.replace('/workspace') } else if (error) { logger.error('OAuth error:', { error }) - router.replace('/w') + router.replace('/workspace') } }, [searchParams, router, userId]) diff --git a/apps/sim/app/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 similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/environment/environment.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/general/general.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/team-seats-dialog.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/settings-modal/settings-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx diff --git a/apps/sim/app/w/components/sidebar/components/sidebar-control/sidebar-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-control/sidebar-control.tsx similarity index 100% rename from apps/sim/app/w/components/sidebar/components/sidebar-control/sidebar-control.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-control/sidebar-control.tsx diff --git a/apps/sim/app/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx similarity index 87% rename from apps/sim/app/w/components/sidebar/components/workflow-list/workflow-list.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index bf4e067eb8f..c54e7d73e94 100644 --- a/apps/sim/app/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -3,10 +3,9 @@ import { useMemo } from 'react' import clsx from 'clsx' import Link from 'next/link' -import { usePathname } from 'next/navigation' +import { useParams, usePathname } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { useSession } from '@/lib/auth-client' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' interface WorkflowItemProps { @@ -17,9 +16,12 @@ interface WorkflowItemProps { } function WorkflowItem({ workflow, active, isMarketplace, isCollapsed }: WorkflowItemProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + return ( ))} @@ -121,7 +124,7 @@ export function WorkflowList({ @@ -132,8 +135,8 @@ export function WorkflowList({ {/* Empty state */} {showEmptyState && !isCollapsed && (
- No workflows in {activeWorkspaceId ? 'this workspace' : 'your account'}. Create one to - get started. + No workflows in {workspaceId ? 'this workspace' : 'your account'}. Create one to get + started.
)} diff --git a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx similarity index 93% rename from apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 4f9351d0154..3d67c0087d6 100644 --- a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ChevronDown, Pencil, Trash2, X } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { AgentIcon } from '@/components/icons' import { AlertDialog, @@ -28,11 +28,14 @@ import { import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useSession } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const logger = createLogger('WorkspaceHeader') + interface Workspace { id: string name: string @@ -254,7 +257,9 @@ export const WorkspaceHeader = React.memo( const router = useRouter() // Get workflowRegistry state and actions - const { activeWorkspaceId, switchToWorkspace, setActiveWorkspaceId } = useWorkflowRegistry() + const { switchToWorkspace } = useWorkflowRegistry() + const params = useParams() + const currentWorkspaceId = params.workspaceId as string // Get user permissions for the active workspace const userPermissions = useUserPermissionsContext() @@ -275,7 +280,7 @@ export const WorkspaceHeader = React.memo( const data = await response.json() setPlan(data.isPro ? 'Pro Plan' : 'Free Plan') } catch (err) { - console.error('Error fetching subscription status:', err) + logger.error('Error fetching subscription status:', err) } }, []) @@ -289,30 +294,36 @@ export const WorkspaceHeader = React.memo( const fetchedWorkspaces = data.workspaces as Workspace[] setWorkspaces(fetchedWorkspaces) - // Only update workspace if we have a valid activeWorkspaceId from registry - if (activeWorkspaceId) { + // Only update workspace if we have a valid currentWorkspaceId from URL + if (currentWorkspaceId) { const matchingWorkspace = fetchedWorkspaces.find( - (workspace) => workspace.id === activeWorkspaceId + (workspace) => workspace.id === currentWorkspaceId ) if (matchingWorkspace) { setActiveWorkspace(matchingWorkspace) } else { - // Active workspace not found, fallback to first workspace - const fallbackWorkspace = fetchedWorkspaces[0] - if (fallbackWorkspace) { + // Log the mismatch for debugging + logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`) + + // Current workspace not found, fallback to first workspace + if (fetchedWorkspaces.length > 0) { + const fallbackWorkspace = fetchedWorkspaces[0] setActiveWorkspace(fallbackWorkspace) - setActiveWorkspaceId(fallbackWorkspace.id) + // Navigate to the fallback workspace + router.push(`/workspace/${fallbackWorkspace.id}/w`) + } else { + // No workspaces available - handle this edge case + logger.error('No workspaces available for user') } } } - // If no activeWorkspaceId, let loadWorkspaceFromWorkflowId handle workspace selection } } catch (err) { - console.error('Error fetching workspaces:', err) + logger.error('Error fetching workspaces:', err) } finally { setIsWorkspacesLoading(false) } - }, [activeWorkspaceId, setActiveWorkspaceId]) + }, [currentWorkspaceId, router]) useEffect(() => { // Fetch subscription status if user is logged in @@ -337,7 +348,7 @@ export const WorkspaceHeader = React.memo( switchToWorkspace(workspace.id) // Update URL to include workspace ID - router.push(`/w/${workspace.id}`) + router.push(`/workspace/${workspace.id}/w`) }, [activeWorkspace?.id, switchToWorkspace, router] ) @@ -367,10 +378,10 @@ export const WorkspaceHeader = React.memo( switchToWorkspace(newWorkspace.id) // Update URL to include new workspace ID - router.push(`/w/${newWorkspace.id}`) + router.push(`/workspace/${newWorkspace.id}/w`) } } catch (err) { - console.error('Error creating workspace:', err) + logger.error('Error creating workspace:', err) } finally { setIsWorkspacesLoading(false) } @@ -396,7 +407,7 @@ export const WorkspaceHeader = React.memo( if (!response.ok) { if (response.status === 403) { - console.error( + logger.error( 'Permission denied: Only users with admin permissions can update workspaces' ) } @@ -420,7 +431,7 @@ export const WorkspaceHeader = React.memo( }) } } catch (err) { - console.error('Error updating workspace:', err) + logger.error('Error updating workspace:', err) } finally { setIsWorkspacesLoading(false) } @@ -442,7 +453,7 @@ export const WorkspaceHeader = React.memo( if (!response.ok) { if (response.status === 403) { - console.error( + logger.error( 'Permission denied: Only users with admin permissions can delete workspaces' ) } @@ -463,7 +474,7 @@ export const WorkspaceHeader = React.memo( setIsOpen(false) } catch (err) { - console.error('Error deleting workspace:', err) + logger.error('Error deleting workspace:', err) } finally { setIsDeleting(false) } @@ -486,7 +497,7 @@ export const WorkspaceHeader = React.memo( // Determine URL for workspace links const workspaceUrl = useMemo( - () => (activeWorkspace ? `/w/${activeWorkspace.id}` : '/w'), + () => (activeWorkspace ? `/workspace/${activeWorkspace.id}/w` : '/workspace'), [activeWorkspace] ) diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx similarity index 91% rename from apps/sim/app/w/components/sidebar/sidebar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index e8485572e82..f1557a64796 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -3,12 +3,15 @@ import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' import { HelpCircle, LibraryBig, ScrollText, Send, Settings } from 'lucide-react' -import { usePathname, useRouter } from 'next/navigation' +import { useParams, usePathname, useRouter } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' -import { getKeyboardShortcutText, useGlobalShortcuts } from '@/app/w/hooks/use-keyboard-shortcuts' +import { + getKeyboardShortcutText, + useGlobalShortcuts, +} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -31,16 +34,13 @@ export function Sidebar() { useRegistryLoading() useGlobalShortcuts() - const { - workflows, - activeWorkspaceId, - createWorkflow, - isLoading: workflowsLoading, - } = useWorkflowRegistry() + const { workflows, createWorkflow, isLoading: workflowsLoading } = useWorkflowRegistry() const { isPending: sessionLoading } = useSession() const userPermissions = useUserPermissionsContext() const isLoading = workflowsLoading || sessionLoading const router = useRouter() + const params = useParams() + const workspaceId = params.workspaceId as string const pathname = usePathname() const [showSettings, setShowSettings] = useState(false) @@ -66,7 +66,7 @@ export function Sidebar() { if (!isLoading) { Object.values(workflows).forEach((workflow) => { - if (workflow.workspaceId === activeWorkspaceId || !workflow.workspaceId) { + if (workflow.workspaceId === workspaceId || !workflow.workspaceId) { if (workflow.marketplaceData?.status === 'temp') { temp.push(workflow) } else { @@ -93,16 +93,16 @@ export function Sidebar() { } return { regularWorkflows: regular, tempWorkflows: temp } - }, [workflows, isLoading, activeWorkspaceId]) + }, [workflows, isLoading, workspaceId]) // Create workflow handler const handleCreateWorkflow = async (folderId?: string) => { try { const id = await createWorkflow({ - workspaceId: activeWorkspaceId || undefined, + workspaceId: workspaceId || undefined, folderId: folderId || undefined, }) - router.push(`/w/${id}`) + router.push(`/workspace/${workspaceId}/w/${id}`) } catch (error) { logger.error('Error creating workflow:', error) } @@ -154,10 +154,10 @@ export function Sidebar() { {/* Workflows Section */}

{isLoading ? : 'Workflows'}

@@ -179,18 +179,18 @@ export function Sidebar() { } - href='/w/logs' + href={`/workspace/${workspaceId}/logs`} label='Logs' - active={pathname === '/w/logs'} + active={pathname === `/workspace/${workspaceId}/logs`} isCollapsed={isCollapsed} shortcutCommand={getKeyboardShortcutText('L', true, true)} shortcutCommandPosition='below' /> } - href='/w/knowledge' + href={`/workspace/${workspaceId}/knowledge`} label='Knowledge' - active={pathname === '/w/knowledge'} + active={pathname === `/workspace/${workspaceId}/knowledge`} isCollapsed={isCollapsed} shortcutCommand={getKeyboardShortcutText('K', true, true)} shortcutCommandPosition='below' diff --git a/apps/sim/app/w/components/workflow-preview/workflow-preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview.tsx similarity index 93% rename from apps/sim/app/w/components/workflow-preview/workflow-preview.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview.tsx index 79abac5ab47..ff6a7c2e97a 100644 --- a/apps/sim/app/w/components/workflow-preview/workflow-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview.tsx @@ -15,10 +15,10 @@ import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' -import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node' -import { WorkflowBlock } from '@/app/w/[id]/components/workflow-block/workflow-block' -import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edge' +import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node' +import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node' +import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' +import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { getBlock } from '@/blocks' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/app/w/hooks/use-keyboard-shortcuts.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts.ts similarity index 89% rename from apps/sim/app/w/hooks/use-keyboard-shortcuts.ts rename to apps/sim/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts.ts index 9f49d8a1818..70d418eb620 100644 --- a/apps/sim/app/w/hooks/use-keyboard-shortcuts.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts.ts @@ -95,7 +95,16 @@ export function useGlobalShortcuts() { ((isMac && event.metaKey) || (!isMac && event.ctrlKey)) ) { event.preventDefault() - router.push('/w/logs') + + const pathParts = window.location.pathname.split('/') + const workspaceIndex = pathParts.indexOf('workspace') + + if (workspaceIndex !== -1 && pathParts[workspaceIndex + 1]) { + const workspaceId = pathParts[workspaceIndex + 1] + router.push(`/workspace/${workspaceId}/logs`) + } else { + router.push('/workspace') + } } } diff --git a/apps/sim/app/w/hooks/use-registry-loading.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-registry-loading.ts similarity index 69% rename from apps/sim/app/w/hooks/use-registry-loading.ts rename to apps/sim/app/workspace/[workspaceId]/w/hooks/use-registry-loading.ts index 207514e18fd..239a7148dbe 100644 --- a/apps/sim/app/w/hooks/use-registry-loading.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-registry-loading.ts @@ -1,7 +1,7 @@ 'use client' import { useEffect } from 'react' -import { usePathname, useRouter } from 'next/navigation' +import { useParams, usePathname, useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -34,44 +34,41 @@ function extractWorkflowIdFromPathname(pathname: string): string | null { * Custom hook to manage workflow registry loading state and handle first-time navigation * * This hook initializes the loading state and automatically clears it - * when workflows are loaded. It also handles smart workspace selection - * and navigation for first-time users. + * when workflows are loaded. It also handles navigation for first-time users. */ export function useRegistryLoading() { - const { workflows, setLoading, isLoading, activeWorkspaceId, loadWorkspaceFromWorkflowId } = - useWorkflowRegistry() + const { workflows, setLoading, isLoading, loadWorkflows } = useWorkflowRegistry() const pathname = usePathname() const router = useRouter() + const params = useParams() + const workspaceId = params.workspaceId as string - // Handle workspace selection from URL + // Load workflows for current workspace useEffect(() => { - if (!activeWorkspaceId) { - const workflowIdFromUrl = extractWorkflowIdFromPathname(pathname) - if (workflowIdFromUrl) { - loadWorkspaceFromWorkflowId(workflowIdFromUrl).catch((error) => { - logger.warn('Failed to load workspace from workflow ID:', error) - }) - } + if (workspaceId) { + loadWorkflows(workspaceId).catch((error) => { + logger.warn('Failed to load workflows for workspace:', error) + }) } - }, [activeWorkspaceId, pathname, loadWorkspaceFromWorkflowId]) + }, [workspaceId, loadWorkflows]) // Handle first-time navigation: if we're at /w and have workflows, navigate to first one useEffect(() => { - if (!isLoading && activeWorkspaceId && Object.keys(workflows).length > 0) { - const workflowCount = Object.keys(workflows).length + if (!isLoading && workspaceId && Object.keys(workflows).length > 0) { const currentWorkflowId = extractWorkflowIdFromPathname(pathname) - // If we're at a generic workspace URL (/w, /w/, or /w/workspaceId) without a specific workflow + // Check if we're on the workspace root and need to redirect to first workflow if ( - !currentWorkflowId && - (pathname === '/w' || pathname === '/w/' || pathname === `/w/${activeWorkspaceId}`) + (pathname === `/workspace/${workspaceId}/w` || + pathname === `/workspace/${workspaceId}/w/`) && + Object.keys(workflows).length > 0 ) { const firstWorkflowId = Object.keys(workflows)[0] logger.info('First-time navigation: redirecting to first workflow:', firstWorkflowId) - router.replace(`/w/${firstWorkflowId}`) + router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) } } - }, [isLoading, activeWorkspaceId, workflows, pathname, router]) + }, [isLoading, workspaceId, workflows, pathname, router]) // Handle loading states useEffect(() => { diff --git a/apps/sim/app/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx similarity index 70% rename from apps/sim/app/w/page.tsx rename to apps/sim/app/workspace/[workspaceId]/w/page.tsx index fea6f1d2df9..998bd9a27b8 100644 --- a/apps/sim/app/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -1,12 +1,15 @@ 'use client' import { useEffect } from 'react' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { LoadingAgent } from '@/components/ui/loading-agent' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export default function WorkflowsPage() { const router = useRouter() const { workflows, isLoading } = useWorkflowRegistry() + const params = useParams() + const workspaceId = params.workspaceId useEffect(() => { // Wait for workflows to load @@ -16,7 +19,7 @@ export default function WorkflowsPage() { // If we have workflows, redirect to the first one if (workflowIds.length > 0) { - router.replace(`/w/${workflowIds[0]}`) + router.replace(`/workspace/${workspaceId}/w/${workflowIds[0]}`) return } @@ -24,14 +27,15 @@ export default function WorkflowsPage() { // or the user doesn't have any workspaces. Redirect to home to let the system // handle workspace/workflow creation properly. router.replace('/') - }, [workflows, isLoading, router]) + }, [workflows, isLoading, router, workspaceId]) // Show loading state while determining where to redirect return (
-
-

Loading workflows...

+
+ +
) diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx new file mode 100644 index 00000000000..d6d6b54e62b --- /dev/null +++ b/apps/sim/app/workspace/page.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { useSession } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('WorkspacePage') + +export default function WorkspacePage() { + const router = useRouter() + const { data: session, isPending } = useSession() + + useEffect(() => { + const redirectToFirstWorkspace = async () => { + // Wait for session to load + if (isPending) { + return + } + + // If user is not authenticated, redirect to login + if (!session?.user) { + logger.info('User not authenticated, redirecting to login') + router.replace('/login') + return + } + + try { + // Check if we need to redirect a specific workflow from old URL format + const urlParams = new URLSearchParams(window.location.search) + const redirectWorkflowId = urlParams.get('redirect_workflow') + + if (redirectWorkflowId) { + // Try to get the workspace for this workflow + try { + const workflowResponse = await fetch(`/api/workflows/${redirectWorkflowId}`) + if (workflowResponse.ok) { + const workflowData = await workflowResponse.json() + const workspaceId = workflowData.data?.workspaceId + + if (workspaceId) { + logger.info( + `Redirecting workflow ${redirectWorkflowId} to workspace ${workspaceId}` + ) + router.replace(`/workspace/${workspaceId}/w/${redirectWorkflowId}`) + return + } + } + } catch (error) { + logger.error('Error fetching workflow for redirect:', error) + } + } + + // Fetch user's workspaces + const response = await fetch('/api/workspaces') + + if (!response.ok) { + throw new Error('Failed to fetch workspaces') + } + + const data = await response.json() + const workspaces = data.workspaces || [] + + if (workspaces.length === 0) { + logger.warn('No workspaces found for user, creating default workspace') + + try { + const createResponse = await fetch('/api/workspaces', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: 'My Workspace' }), + }) + + if (createResponse.ok) { + const createData = await createResponse.json() + const newWorkspace = createData.workspace + + if (newWorkspace?.id) { + logger.info(`Created default workspace: ${newWorkspace.id}`) + router.replace(`/workspace/${newWorkspace.id}/w`) + return + } + } + + logger.error('Failed to create default workspace') + } catch (createError) { + logger.error('Error creating default workspace:', createError) + } + + // If we can't create a workspace, redirect to login to reset state + router.replace('/login') + return + } + + // Get the first workspace (they should be ordered by most recent) + const firstWorkspace = workspaces[0] + logger.info(`Redirecting to first workspace: ${firstWorkspace.id}`) + + // Redirect to the first workspace + router.replace(`/workspace/${firstWorkspace.id}/w`) + } catch (error) { + logger.error('Error fetching workspaces for redirect:', error) + // Don't redirect if there's an error - let the user stay on the page + } + } + + // Only run this logic when we're at the root /workspace path + // If we're already in a specific workspace, the children components will handle it + if (typeof window !== 'undefined' && window.location.pathname === '/workspace') { + redirectToFirstWorkspace() + } + }, [session, isPending, router]) + + // Show loading state while we determine where to redirect + if (isPending) { + return ( +
+
+ +
+
+ ) + } + + // If user is not authenticated, show nothing (redirect will happen) + if (!session?.user) { + return null + } + + return null +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 86e9f39a5a3..cdd13dcfb37 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2949,7 +2949,7 @@ export const ResponseIcon = (props: SVGProps) => ( > ) diff --git a/apps/sim/components/ui/loading-agent.tsx b/apps/sim/components/ui/loading-agent.tsx index 582d2ae609f..5f87b57f17d 100644 --- a/apps/sim/components/ui/loading-agent.tsx +++ b/apps/sim/components/ui/loading-agent.tsx @@ -11,7 +11,6 @@ export interface LoadingAgentProps { export function LoadingAgent({ size = 'md' }: LoadingAgentProps) { const pathLength = 120 - // Size mappings for width and height const sizes = { sm: { width: 16, height: 18 }, md: { width: 21, height: 24 }, diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index 78c9bf0a146..cb3c15b9f00 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -2,7 +2,10 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { type ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections' +import { + type ConnectedBlock, + useBlockConnections, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections' import { getBlock } from '@/blocks' import { useVariablesStore } from '@/stores/panel/variables/store' import type { Variable } from '@/stores/panel/variables/types' diff --git a/apps/sim/contexts/socket-context.tsx b/apps/sim/contexts/socket-context.tsx index 7b7627a3041..2453e25f800 100644 --- a/apps/sim/contexts/socket-context.tsx +++ b/apps/sim/contexts/socket-context.tsx @@ -85,6 +85,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const [currentWorkflowId, setCurrentWorkflowId] = useState(null) const [presenceUsers, setPresenceUsers] = useState([]) + // Connection state tracking + const reconnectCount = useRef(0) + // Use refs to store event handlers to avoid stale closures const eventHandlers = useRef<{ workflowOperation?: (data: any) => void @@ -127,10 +130,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { }) const socketInstance = io(socketUrl, { - transports: ['polling', 'websocket'], + transports: ['websocket', 'polling'], // Keep polling fallback for reliability withCredentials: true, - reconnectionAttempts: 5, - timeout: 10000, + reconnectionAttempts: 5, // Back to original conservative setting + timeout: 10000, // Back to original timeout auth: { token, // Send one-time token for authentication }, @@ -140,17 +143,24 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketInstance.on('connect', () => { setIsConnected(true) setIsConnecting(false) + reconnectCount.current = 0 + logger.info('Socket connected successfully', { socketId: socketInstance.id, connected: socketInstance.connected, transport: socketInstance.io.engine?.transport?.name, + reconnectCount: reconnectCount.current, }) }) socketInstance.on('disconnect', (reason) => { setIsConnected(false) setIsConnecting(false) - logger.info('Socket disconnected', { reason }) + + logger.info('Socket disconnected', { + reason, + reconnectCount: reconnectCount.current, + }) // Clear presence when disconnected setPresenceUsers([]) @@ -169,7 +179,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { // Add reconnection logging socketInstance.on('reconnect', (attemptNumber) => { - logger.info('Socket reconnected', { attemptNumber }) + reconnectCount.current = attemptNumber + logger.info('Socket reconnected', { + attemptNumber, + }) }) socketInstance.on('reconnect_attempt', (attemptNumber) => { @@ -189,15 +202,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { setPresenceUsers(users) }) - socketInstance.on('user-joined', (userData) => { - setPresenceUsers((prev) => [...prev, userData]) - eventHandlers.current.userJoined?.(userData) - }) - - socketInstance.on('user-left', ({ userId, socketId }) => { - setPresenceUsers((prev) => prev.filter((u) => u.socketId !== socketId)) - eventHandlers.current.userLeft?.({ userId, socketId }) - }) + // Note: user-joined and user-left events removed in favor of authoritative presence-update // Workflow operation events socketInstance.on('workflow-operation', (data) => { @@ -276,6 +281,15 @@ export function SocketProvider({ children, user }: SocketProviderProps) { // Start the socket initialization initializeSocket() + + // Cleanup on unmount + return () => { + positionUpdateTimeouts.current.forEach((timeoutId) => { + clearTimeout(timeoutId) + }) + positionUpdateTimeouts.current.clear() + pendingPositionUpdates.current.clear() + } }, [user?.id]) // Join workflow room @@ -299,13 +313,55 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socket.emit('leave-workflow') setCurrentWorkflowId(null) setPresenceUsers([]) + + // Clean up any pending position updates + positionUpdateTimeouts.current.forEach((timeoutId) => { + clearTimeout(timeoutId) + }) + positionUpdateTimeouts.current.clear() + pendingPositionUpdates.current.clear() } }, [socket, currentWorkflowId]) + // Light throttling for position updates to ensure smooth collaborative movement + const positionUpdateTimeouts = useRef>(new Map()) + const pendingPositionUpdates = useRef>(new Map()) + // Emit workflow operations (blocks, edges, subflows) const emitWorkflowOperation = useCallback( (operation: string, target: string, payload: any) => { - if (socket && currentWorkflowId) { + if (!socket || !currentWorkflowId) return + + // Apply light throttling only to position updates for smooth collaborative experience + const isPositionUpdate = operation === 'update-position' && target === 'block' + + if (isPositionUpdate && payload.id) { + const blockId = payload.id + + // Store the latest position update + pendingPositionUpdates.current.set(blockId, { + operation, + target, + payload, + timestamp: Date.now(), + }) + + // Check if we already have a pending timeout for this block + if (!positionUpdateTimeouts.current.has(blockId)) { + // Schedule emission with light throttling (120fps = ~8ms) + const timeoutId = window.setTimeout(() => { + const latestUpdate = pendingPositionUpdates.current.get(blockId) + if (latestUpdate) { + socket.emit('workflow-operation', latestUpdate) + pendingPositionUpdates.current.delete(blockId) + } + positionUpdateTimeouts.current.delete(blockId) + }, 8) // 120fps for smooth movement + + positionUpdateTimeouts.current.set(blockId, timeoutId) + } + } else { + // For all non-position updates, emit immediately socket.emit('workflow-operation', { operation, target, @@ -340,11 +396,17 @@ export function SocketProvider({ children, user }: SocketProviderProps) { [socket, currentWorkflowId] ) - // Emit cursor position updates + // Minimal cursor throttling (reduced from 30fps to 120fps) + const lastCursorEmit = useRef(0) const emitCursorUpdate = useCallback( (cursor: { x: number; y: number }) => { if (socket && currentWorkflowId) { - socket.emit('cursor-update', { cursor }) + const now = performance.now() + // Very light throttling at 120fps (8ms) to prevent excessive spam + if (now - lastCursorEmit.current >= 8) { + socket.emit('cursor-update', { cursor }) + lastCursorEmit.current = now + } } }, [socket, currentWorkflowId] diff --git a/apps/sim/db/migrations/0047_new_triathlon.sql b/apps/sim/db/migrations/0047_new_triathlon.sql new file mode 100644 index 00000000000..c48b11ff948 --- /dev/null +++ b/apps/sim/db/migrations/0047_new_triathlon.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow_blocks" ADD COLUMN "advanced_mode" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0047_snapshot.json b/apps/sim/db/migrations/meta/0047_snapshot.json new file mode 100644 index 00000000000..ccde6fbffe9 --- /dev/null +++ b/apps/sim/db/migrations/meta/0047_snapshot.json @@ -0,0 +1,3677 @@ +{ + "id": "399915e4-dbee-440d-8c36-eb4bc0d83962", + "prevId": "cc643ea0-33d4-410a-9c53-82faa9d2c352", + "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": {}, + "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.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.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 + }, + "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": {} + } + }, + "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 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "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_metadata_gin_idx": { + "name": "emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "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": {}, + "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\": 100, \"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": "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": {}, + "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 + }, + "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": {}, + "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'" + }, + "debug_mode": { + "name": "debug_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_notified_user": { + "name": "telemetry_notified_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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": {}, + "foreignKeys": {}, + "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_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'" + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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": {}, + "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 + }, + "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" + } + }, + "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 + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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": {}, + "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 + }, + "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_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_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_logs": { + "name": "workflow_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": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_logs_workflow_id_workflow_id_fk": { + "name": "workflow_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_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 + }, + "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": {}, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_schedule_workflow_id_unique": { + "name": "workflow_schedule_workflow_id_unique", + "nullsNotDistinct": false, + "columns": ["workflow_id"] + } + }, + "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_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": "text", + "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'" + }, + "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 + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_workspace_idx": { + "name": "user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_member_user_id_user_id_fk": { + "name": "workspace_member_user_id_user_id_fk", + "tableFrom": "workspace_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + } + }, + "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 1f03e0039d2..e42b081700f 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1750527995274, "tag": "0046_loose_blizzard", "breakpoints": true + }, + { + "idx": 47, + "version": "7", + "when": 1750794256278, + "tag": "0047_new_triathlon", + "breakpoints": true } ] } diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index 8a5d7404219..a3040709e77 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -154,6 +154,7 @@ export const workflowBlocks = pgTable( enabled: boolean('enabled').notNull().default(true), // Whether block is active horizontalHandles: boolean('horizontal_handles').notNull().default(true), // UI layout preference isWide: boolean('is_wide').notNull().default(false), // Whether block uses wide layout + advancedMode: boolean('advanced_mode').notNull().default(false), // Whether block is in advanced mode height: decimal('height').notNull().default('0'), // Custom height override // Block data (keeping JSON for flexibility as current system does) diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index a62c34df101..2e0c0dfa858 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef } from 'react' import type { Edge } from 'reactflow' import { createLogger } from '@/lib/logs/console-logger' +import { getBlock } from '@/blocks' +import { resolveOutputType } from '@/blocks/utils' import { useSocket } from '@/contexts/socket-context' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -58,7 +60,7 @@ export function useCollaborativeWorkflow() { // Handle incoming workflow operations from other users useEffect(() => { const handleWorkflowOperation = (data: any) => { - const { operation, target, payload, senderId, userId } = data + const { operation, target, payload, userId } = data // Don't apply our own operations if (isApplyingRemoteChange.current) return @@ -72,6 +74,8 @@ export function useCollaborativeWorkflow() { if (target === 'block') { switch (operation) { case 'add': + // Use normal addBlock - the collaborative system now sends complete data + // and the validation schema preserves outputs and subBlocks workflowStore.addBlock( payload.id, payload.type, @@ -83,6 +87,7 @@ export function useCollaborativeWorkflow() { ) break case 'update-position': + // Apply immediate position update with smooth interpolation for other users workflowStore.updateBlockPosition(payload.id, payload.position) break case 'update-name': @@ -97,6 +102,14 @@ export function useCollaborativeWorkflow() { case 'update-parent': workflowStore.updateParentId(payload.id, payload.parentId, payload.extent) break + case 'update-wide': + workflowStore.setBlockWide(payload.id, payload.isWide) + break + case 'update-advanced-mode': + // Note: toggleBlockAdvancedMode doesn't take a parameter, it just toggles + // For now, we'll use the existing toggle method + workflowStore.toggleBlockAdvancedMode(payload.id) + break } } else if (target === 'edge') { switch (operation) { @@ -107,6 +120,35 @@ export function useCollaborativeWorkflow() { workflowStore.removeEdge(payload.id) break } + } else if (target === 'subflow') { + switch (operation) { + case 'update': + // Handle subflow configuration updates (loop/parallel type changes, etc.) + if (payload.type === 'loop') { + const { config } = payload + if (config.loopType !== undefined) { + workflowStore.updateLoopType(payload.id, config.loopType) + } + if (config.iterations !== undefined) { + workflowStore.updateLoopCount(payload.id, config.iterations) + } + if (config.forEachItems !== undefined) { + workflowStore.updateLoopCollection(payload.id, config.forEachItems) + } + } else if (payload.type === 'parallel') { + const { config } = payload + if (config.parallelType !== undefined) { + workflowStore.updateParallelType(payload.id, config.parallelType) + } + if (config.count !== undefined) { + workflowStore.updateParallelCount(payload.id, config.count) + } + if (config.distribution !== undefined) { + workflowStore.updateParallelCollection(payload.id, config.distribution) + } + } + break + } } } catch (error) { logger.error('Error applying remote operation:', error) @@ -116,7 +158,7 @@ export function useCollaborativeWorkflow() { } const handleSubblockUpdate = (data: any) => { - const { blockId, subblockId, value, senderId, userId } = data + const { blockId, subblockId, value, userId } = data if (isApplyingRemoteChange.current) return @@ -189,21 +231,83 @@ export function useCollaborativeWorkflow() { parentId?: string, extent?: 'parent' ) => { - // Apply locally first - workflowStore.addBlock(id, type, name, position, data, parentId, extent) + // Create complete block data upfront using the same logic as the store + const blockConfig = getBlock(type) - // Then broadcast to other clients - if (!isApplyingRemoteChange.current) { - emitWorkflowOperation('add', 'block', { + // Handle loop/parallel blocks that don't use BlockConfig + if (!blockConfig && (type === 'loop' || type === 'parallel')) { + // For loop/parallel blocks, use empty subBlocks and outputs + const completeBlockData = { id, type, name, position, - data, + data: data || {}, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, parentId, extent, + } + + // Apply locally first + workflowStore.addBlock(id, type, name, position, data, parentId, extent) + + // Then broadcast to other clients with complete block data + if (!isApplyingRemoteChange.current) { + emitWorkflowOperation('add', 'block', completeBlockData) + } + return + } + + if (!blockConfig) { + console.error(`Block type ${type} not found`) + return + } + + // Generate subBlocks and outputs from the block configuration + const subBlocks: Record = {} + + // Create subBlocks from the block configuration + if (blockConfig.subBlocks) { + blockConfig.subBlocks.forEach((subBlock) => { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: null, + } }) } + + // Generate outputs using the same logic as the store + const outputs = resolveOutputType(blockConfig.outputs, subBlocks) + + const completeBlockData = { + id, + type, + name, + position, + data: data || {}, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, // Default height, will be set by the UI + parentId, + extent, + } + + // Apply locally first + workflowStore.addBlock(id, type, name, position, data, parentId, extent) + + // Then broadcast to other clients with complete block data + if (!isApplyingRemoteChange.current) { + emitWorkflowOperation('add', 'block', completeBlockData) + } }, [workflowStore, emitWorkflowOperation] ) @@ -242,9 +346,22 @@ export function useCollaborativeWorkflow() { // Then broadcast to other clients if (!isApplyingRemoteChange.current) { emitWorkflowOperation('update-name', 'block', { id, name }) + + // Check for pending subblock updates from the store + const globalWindow = window as any + const pendingUpdates = globalWindow.__pendingSubblockUpdates + if (pendingUpdates && Array.isArray(pendingUpdates)) { + // Emit collaborative subblock updates for each changed subblock + for (const update of pendingUpdates) { + const { blockId, subBlockId, newValue } = update + emitSubblockUpdate(blockId, subBlockId, newValue) + } + // Clear the pending updates + globalWindow.__pendingSubblockUpdates = undefined + } } }, - [workflowStore, emitWorkflowOperation] + [workflowStore, emitWorkflowOperation, emitSubblockUpdate] ) const collaborativeToggleBlockEnabled = useCallback( @@ -273,6 +390,49 @@ export function useCollaborativeWorkflow() { [workflowStore, emitWorkflowOperation] ) + const collaborativeToggleBlockWide = useCallback( + (id: string) => { + // Get the current state before toggling + const currentBlock = workflowStore.blocks[id] + if (!currentBlock) return + + // Calculate the new isWide value + const newIsWide = !currentBlock.isWide + + // Apply locally first + workflowStore.toggleBlockWide(id) + + // Emit with the calculated new value (don't rely on async state update) + if (!isApplyingRemoteChange.current) { + emitWorkflowOperation('update-wide', 'block', { id, isWide: newIsWide }) + } + }, + [workflowStore, emitWorkflowOperation] + ) + + const collaborativeToggleBlockAdvancedMode = useCallback( + (id: string) => { + // Get the current state before toggling + const currentBlock = workflowStore.blocks[id] + if (!currentBlock) return + + // Calculate the new advancedMode value + const newAdvancedMode = !currentBlock.advancedMode + + // Apply locally first + workflowStore.toggleBlockAdvancedMode(id) + + // Emit with the calculated new value (don't rely on async state update) + if (!isApplyingRemoteChange.current) { + emitWorkflowOperation('update-advanced-mode', 'block', { + id, + advancedMode: newAdvancedMode, + }) + } + }, + [workflowStore, emitWorkflowOperation] + ) + const collaborativeAddEdge = useCallback( (edge: Edge) => { // Apply locally first @@ -328,33 +488,37 @@ export function useCollaborativeWorkflow() { // Collaborative loop/parallel configuration updates const collaborativeUpdateLoopCount = useCallback( (loopId: string, count: number) => { - // Apply locally first + // Get current state BEFORE making changes + const currentBlock = workflowStore.blocks[loopId] + if (!currentBlock || currentBlock.type !== 'loop') return + + // Find child nodes before state changes + const childNodes = Object.values(workflowStore.blocks) + .filter((b) => b.data?.parentId === loopId) + .map((b) => b.id) + + // Get current values to preserve them + const currentLoopType = currentBlock.data?.loopType || 'for' + const currentCollection = currentBlock.data?.collection || '' + + // Apply local change workflowStore.updateLoopCount(loopId, count) - // Emit subflow update operation to persist configuration changes + // Emit subflow update operation with calculated values if (!isApplyingRemoteChange.current) { - // Build the configuration manually to ensure it matches the database structure - const block = workflowStore.blocks[loopId] - if (block && block.type === 'loop') { - // Find child nodes - const childNodes = Object.values(workflowStore.blocks) - .filter((b) => b.data?.parentId === loopId) - .map((b) => b.id) - - const config = { - id: loopId, - nodes: childNodes, - iterations: count, - loopType: block.data?.loopType || 'for', - forEachItems: block.data?.collection || '', - } - - emitWorkflowOperation('update', 'subflow', { - id: loopId, - type: 'loop', - config, - }) + const config = { + id: loopId, + nodes: childNodes, + iterations: count, + loopType: currentLoopType, + forEachItems: currentCollection, } + + emitWorkflowOperation('update', 'subflow', { + id: loopId, + type: 'loop', + config, + }) } }, [workflowStore, emitWorkflowOperation] @@ -362,32 +526,37 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateLoopType = useCallback( (loopId: string, loopType: 'for' | 'forEach') => { - // Apply locally first + // Get current state BEFORE making changes + const currentBlock = workflowStore.blocks[loopId] + if (!currentBlock || currentBlock.type !== 'loop') return + + // Find child nodes before state changes + const childNodes = Object.values(workflowStore.blocks) + .filter((b) => b.data?.parentId === loopId) + .map((b) => b.id) + + // Get current values to preserve them + const currentIterations = currentBlock.data?.count || 5 + const currentCollection = currentBlock.data?.collection || '' + + // Apply local change workflowStore.updateLoopType(loopId, loopType) - // Emit subflow update operation to persist configuration changes + // Emit subflow update operation with calculated values if (!isApplyingRemoteChange.current) { - const block = workflowStore.blocks[loopId] - if (block && block.type === 'loop') { - // Find child nodes - const childNodes = Object.values(workflowStore.blocks) - .filter((b) => b.data?.parentId === loopId) - .map((b) => b.id) - - const config = { - id: loopId, - nodes: childNodes, - iterations: block.data?.count || 5, - loopType, - forEachItems: block.data?.collection || '', - } - - emitWorkflowOperation('update', 'subflow', { - id: loopId, - type: 'loop', - config, - }) + const config = { + id: loopId, + nodes: childNodes, + iterations: currentIterations, + loopType, + forEachItems: currentCollection, } + + emitWorkflowOperation('update', 'subflow', { + id: loopId, + type: 'loop', + config, + }) } }, [workflowStore, emitWorkflowOperation] @@ -395,32 +564,37 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateLoopCollection = useCallback( (loopId: string, collection: string) => { - // Apply locally first + // Get current state BEFORE making changes + const currentBlock = workflowStore.blocks[loopId] + if (!currentBlock || currentBlock.type !== 'loop') return + + // Find child nodes before state changes + const childNodes = Object.values(workflowStore.blocks) + .filter((b) => b.data?.parentId === loopId) + .map((b) => b.id) + + // Get current values to preserve them + const currentIterations = currentBlock.data?.count || 5 + const currentLoopType = currentBlock.data?.loopType || 'for' + + // Apply local change workflowStore.updateLoopCollection(loopId, collection) - // Emit subflow update operation to persist configuration changes + // Emit subflow update operation with calculated values if (!isApplyingRemoteChange.current) { - const block = workflowStore.blocks[loopId] - if (block && block.type === 'loop') { - // Find child nodes - const childNodes = Object.values(workflowStore.blocks) - .filter((b) => b.data?.parentId === loopId) - .map((b) => b.id) - - const config = { - id: loopId, - nodes: childNodes, - iterations: block.data?.count || 5, - loopType: block.data?.loopType || 'for', - forEachItems: collection, - } - - emitWorkflowOperation('update', 'subflow', { - id: loopId, - type: 'loop', - config, - }) + const config = { + id: loopId, + nodes: childNodes, + iterations: currentIterations, + loopType: currentLoopType, + forEachItems: collection, } + + emitWorkflowOperation('update', 'subflow', { + id: loopId, + type: 'loop', + config, + }) } }, [workflowStore, emitWorkflowOperation] @@ -428,32 +602,37 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateParallelCount = useCallback( (parallelId: string, count: number) => { - // Apply locally first + // Get current state BEFORE making changes + const currentBlock = workflowStore.blocks[parallelId] + if (!currentBlock || currentBlock.type !== 'parallel') return + + // Find child nodes before state changes + const childNodes = Object.values(workflowStore.blocks) + .filter((b) => b.data?.parentId === parallelId) + .map((b) => b.id) + + // Get current values to preserve them + const currentDistribution = currentBlock.data?.collection || '' + const currentParallelType = currentBlock.data?.parallelType || 'collection' + + // Apply local change workflowStore.updateParallelCount(parallelId, count) - // Emit subflow update operation to persist configuration changes + // Emit subflow update operation with calculated values if (!isApplyingRemoteChange.current) { - const block = workflowStore.blocks[parallelId] - if (block && block.type === 'parallel') { - // Find child nodes - const childNodes = Object.values(workflowStore.blocks) - .filter((b) => b.data?.parentId === parallelId) - .map((b) => b.id) - - const config = { - id: parallelId, - nodes: childNodes, - count: Math.max(1, Math.min(20, count)), // Clamp between 1-20 - distribution: block.data?.collection || '', - parallelType: block.data?.parallelType || 'collection', - } - - emitWorkflowOperation('update', 'subflow', { - id: parallelId, - type: 'parallel', - config, - }) + const config = { + id: parallelId, + nodes: childNodes, + count: Math.max(1, Math.min(20, count)), // Clamp between 1-20 + distribution: currentDistribution, + parallelType: currentParallelType, } + + emitWorkflowOperation('update', 'subflow', { + id: parallelId, + type: 'parallel', + config, + }) } }, [workflowStore, emitWorkflowOperation] @@ -461,26 +640,37 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateParallelCollection = useCallback( (parallelId: string, collection: string) => { - // Apply locally first + // Get current state BEFORE making changes + const currentBlock = workflowStore.blocks[parallelId] + if (!currentBlock || currentBlock.type !== 'parallel') return + + // Find child nodes before state changes + const childNodes = Object.values(workflowStore.blocks) + .filter((b) => b.data?.parentId === parallelId) + .map((b) => b.id) + + // Get current values to preserve them + const currentCount = currentBlock.data?.count || 5 + const currentParallelType = currentBlock.data?.parallelType || 'collection' + + // Apply local change workflowStore.updateParallelCollection(parallelId, collection) - // Emit subflow update operation to persist configuration changes + // Emit subflow update operation with calculated values if (!isApplyingRemoteChange.current) { - const parallels = workflowStore.parallels - const config = parallels[parallelId] - - if (config) { - const block = workflowStore.blocks[parallelId] - emitWorkflowOperation('update', 'subflow', { - id: parallelId, - type: 'parallel', - config: { - ...config, - distribution: collection, // Ensure the new collection is included - parallelType: block?.data?.parallelType || 'collection', // Include parallelType - }, - }) + const config = { + id: parallelId, + nodes: childNodes, + count: currentCount, + distribution: collection, + parallelType: currentParallelType, } + + emitWorkflowOperation('update', 'subflow', { + id: parallelId, + type: 'parallel', + config, + }) } }, [workflowStore, emitWorkflowOperation] @@ -488,32 +678,48 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateParallelType = useCallback( (parallelId: string, parallelType: 'count' | 'collection') => { - // Apply locally first + // Get current state BEFORE making changes + const currentBlock = workflowStore.blocks[parallelId] + if (!currentBlock || currentBlock.type !== 'parallel') return + + // Find child nodes before state changes + const childNodes = Object.values(workflowStore.blocks) + .filter((b) => b.data?.parentId === parallelId) + .map((b) => b.id) + + // Calculate new values based on type change + let newCount = currentBlock.data?.count || 5 + let newDistribution = currentBlock.data?.collection || '' + + // Reset values based on type (same logic as the UI) + if (parallelType === 'count') { + newDistribution = '' + // Keep existing count + } else { + newCount = 1 + newDistribution = newDistribution || '' + } + + // Apply all changes locally first workflowStore.updateParallelType(parallelId, parallelType) + workflowStore.updateParallelCount(parallelId, newCount) + workflowStore.updateParallelCollection(parallelId, newDistribution) - // Emit subflow update operation to persist configuration changes + // Emit single subflow update with all changes if (!isApplyingRemoteChange.current) { - const block = workflowStore.blocks[parallelId] - if (block && block.type === 'parallel') { - // Find child nodes - const childNodes = Object.values(workflowStore.blocks) - .filter((b) => b.data?.parentId === parallelId) - .map((b) => b.id) - - const config = { - id: parallelId, - nodes: childNodes, - count: block.data?.count || 5, - distribution: block.data?.collection || '', - parallelType, - } - - emitWorkflowOperation('update', 'subflow', { - id: parallelId, - type: 'parallel', - config, - }) + const config = { + id: parallelId, + nodes: childNodes, + count: newCount, + distribution: newDistribution, + parallelType, } + + emitWorkflowOperation('update', 'subflow', { + id: parallelId, + type: 'parallel', + config, + }) } }, [workflowStore, emitWorkflowOperation] @@ -536,6 +742,8 @@ export function useCollaborativeWorkflow() { collaborativeRemoveBlock, collaborativeToggleBlockEnabled, collaborativeUpdateParentId, + collaborativeToggleBlockWide, + collaborativeToggleBlockAdvancedMode, collaborativeAddEdge, collaborativeRemoveEdge, collaborativeSetSubblockValue, diff --git a/apps/sim/hooks/use-workspace-permissions.ts b/apps/sim/hooks/use-workspace-permissions.ts index fb5ec21ba67..c4b4941ea60 100644 --- a/apps/sim/hooks/use-workspace-permissions.ts +++ b/apps/sim/hooks/use-workspace-permissions.ts @@ -5,7 +5,6 @@ import { API_ENDPOINTS } from '@/stores/constants' const logger = createLogger('useWorkspacePermissions') -// Use the enum from the database schema for type safety export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] export interface WorkspaceUser { diff --git a/apps/sim/lib/logs/trace-spans.ts b/apps/sim/lib/logs/trace-spans.ts index eed9418a59f..02ef418f954 100644 --- a/apps/sim/lib/logs/trace-spans.ts +++ b/apps/sim/lib/logs/trace-spans.ts @@ -1,4 +1,4 @@ -import type { TraceSpan } from '@/app/w/logs/stores/types' +import type { TraceSpan } from '@/app/workspace/[workspaceId]/logs/stores/types' import type { ExecutionResult } from '@/executor/types' // Helper function to build a tree of trace spans from execution logs diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts index a48454e81a6..f4c61e7cddd 100644 --- a/apps/sim/lib/workflows/db-helpers.ts +++ b/apps/sim/lib/workflows/db-helpers.ts @@ -60,6 +60,7 @@ export async function loadWorkflowFromNormalizedTables( enabled: block.enabled, horizontalHandles: block.horizontalHandles, isWide: block.isWide, + advancedMode: block.advancedMode, height: Number(block.height), subBlocks: block.subBlocks || {}, outputs: block.outputs || {}, diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index 6e958c91d16..286620c31b1 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -44,13 +44,24 @@ export async function middleware(request: NextRequest) { return NextResponse.rewrite(new URL(`/chat/${subdomain}${url.pathname}`, request.url)) } - // Check if the path is exactly /w - if (url.pathname === '/w') { - return NextResponse.redirect(new URL('/w/1', request.url)) + // Legacy redirect: /w -> /workspace (will be handled by workspace layout) + if (url.pathname === '/w' || url.pathname.startsWith('/w/')) { + // Extract workflow ID if present + const pathParts = url.pathname.split('/') + if (pathParts.length >= 3 && pathParts[1] === 'w') { + const workflowId = pathParts[2] + // Redirect old workflow URLs to new format + // We'll need to resolve the workspace ID for this workflow + return NextResponse.redirect( + new URL(`/workspace?redirect_workflow=${workflowId}`, request.url) + ) + } + // Simple /w redirect to workspace root + return NextResponse.redirect(new URL('/workspace', request.url)) } // Handle protected routes that require authentication - if (url.pathname.startsWith('/w/') || url.pathname === '/w') { + if (url.pathname.startsWith('/workspace')) { if (!hasActiveSession) { return NextResponse.redirect(new URL('/login', request.url)) } @@ -137,8 +148,9 @@ export async function middleware(request: NextRequest) { // Update matcher to include invitation routes export const config = { matcher: [ - '/w', // Match exactly /w - '/w/:path*', // Match protected routes + '/w', // Legacy /w redirect + '/w/:path*', // Legacy /w/* redirects + '/workspace/:path*', // New workspace routes '/login', '/signup', '/invite/:path*', // Match invitation routes diff --git a/apps/sim/socket-server/config/socket.ts b/apps/sim/socket-server/config/socket.ts new file mode 100644 index 00000000000..0ec9f326132 --- /dev/null +++ b/apps/sim/socket-server/config/socket.ts @@ -0,0 +1,64 @@ +import type { Server as HttpServer } from 'http' +import { Server } from 'socket.io' +import { createLogger } from '../../lib/logs/console-logger' + +const logger = createLogger('SocketIOConfig') + +/** + * Get allowed origins for Socket.IO CORS configuration + */ +function getAllowedOrigins(): string[] { + const allowedOrigins = [ + process.env.NEXT_PUBLIC_APP_URL, + process.env.VERCEL_URL, + 'http://localhost:3000', + 'http://localhost:3001', + ...(process.env.ALLOWED_ORIGINS?.split(',') || []), + ].filter((url): url is string => Boolean(url)) + + logger.info('Socket.IO CORS configuration:', { allowedOrigins }) + + return allowedOrigins +} + +/** + * Create and configure a Socket.IO server instance + * @param httpServer - The HTTP server instance to attach Socket.IO to + * @returns Configured Socket.IO server instance + */ +export function createSocketIOServer(httpServer: HttpServer): Server { + const allowedOrigins = getAllowedOrigins() + + const io = new Server(httpServer, { + cors: { + origin: allowedOrigins, + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'socket.io'], + credentials: true, // Enable credentials to accept cookies + }, + transports: ['polling', 'websocket'], // Keep both transports for reliability + allowEIO3: true, // Keep legacy support for compatibility + pingTimeout: 60000, // Back to original conservative setting + pingInterval: 25000, // Back to original interval + maxHttpBufferSize: 1e6, + cookie: { + name: 'io', + path: '/', + httpOnly: true, + sameSite: 'none', // Required for cross-origin cookies + secure: process.env.NODE_ENV === 'production', // HTTPS in production + }, + }) + + logger.info('Socket.IO server configured with:', { + allowedOrigins: allowedOrigins.length, + transports: ['polling', 'websocket'], + pingTimeout: 60000, + pingInterval: 25000, + maxHttpBufferSize: 1e6, + cookieSecure: process.env.NODE_ENV === 'production', + corsCredentials: true, + }) + + return io +} diff --git a/apps/sim/socket-server/database/operations.ts b/apps/sim/socket-server/database/operations.ts new file mode 100644 index 00000000000..056fc915ffb --- /dev/null +++ b/apps/sim/socket-server/database/operations.ts @@ -0,0 +1,641 @@ +import { and, eq, or } from 'drizzle-orm' +import { db } from '../../db' +import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '../../db/schema' +import { createLogger } from '../../lib/logs/console-logger' +import { loadWorkflowFromNormalizedTables } from '../../lib/workflows/db-helpers' + +const logger = createLogger('SocketDatabase') + +// Constants +const DEFAULT_LOOP_ITERATIONS = 5 + +// Enum for subflow types +enum SubflowType { + LOOP = 'loop', + PARALLEL = 'parallel', +} + +// Helper function to check if a block type is a subflow type +function isSubflowBlockType(blockType: string): blockType is SubflowType { + return Object.values(SubflowType).includes(blockType as SubflowType) +} + +// Helper function to update subflow node lists when child blocks are added/removed +export async function updateSubflowNodeList(dbOrTx: any, workflowId: string, parentId: string) { + try { + // Get all child blocks of this parent + const childBlocks = await dbOrTx + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.parentId, parentId))) + + const childNodeIds = childBlocks.map((block: any) => block.id) + + // Get current subflow config + const subflowData = await dbOrTx + .select({ config: workflowSubflows.config }) + .from(workflowSubflows) + .where(and(eq(workflowSubflows.id, parentId), eq(workflowSubflows.workflowId, workflowId))) + .limit(1) + + if (subflowData.length > 0) { + const updatedConfig = { + ...subflowData[0].config, + nodes: childNodeIds, + } + + await dbOrTx + .update(workflowSubflows) + .set({ + config: updatedConfig, + updatedAt: new Date(), + }) + .where(and(eq(workflowSubflows.id, parentId), eq(workflowSubflows.workflowId, workflowId))) + + logger.debug(`Updated subflow ${parentId} node list: [${childNodeIds.join(', ')}]`) + } + } catch (error) { + logger.error(`Error updating subflow node list for ${parentId}:`, error) + } +} + +// Get workflow state +export async function getWorkflowState(workflowId: string) { + try { + const workflowData = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowData.length) { + throw new Error(`Workflow ${workflowId} not found`) + } + + // Load from normalized tables first (same logic as REST API) + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + + if (normalizedData) { + // Use normalized data as source of truth + const existingState = workflowData[0].state || {} + + const finalState = { + // Default values for expected properties + deploymentStatuses: {}, + hasActiveSchedule: false, + hasActiveWebhook: false, + // Preserve any existing state properties + ...existingState, + // Override with normalized data (this takes precedence) + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + lastSaved: Date.now(), + isDeployed: workflowData[0].isDeployed || false, + deployedAt: workflowData[0].deployedAt, + } + + return { + ...workflowData[0], + state: finalState, + lastModified: Date.now(), + } + } + // Fallback to JSON blob + return { + ...workflowData[0], + lastModified: Date.now(), + } + } catch (error) { + logger.error(`Error fetching workflow state for ${workflowId}:`, error) + throw error + } +} + +// Persist workflow operation +export async function persistWorkflowOperation(workflowId: string, operation: any) { + try { + const { operation: op, target, payload, timestamp, userId } = operation + + await db.transaction(async (tx) => { + // Update the workflow's last modified timestamp first + await tx + .update(workflow) + .set({ updatedAt: new Date(timestamp) }) + .where(eq(workflow.id, workflowId)) + + // Handle different operation types within the transaction + switch (target) { + case 'block': + await handleBlockOperationTx(tx, workflowId, op, payload, userId) + break + case 'edge': + await handleEdgeOperationTx(tx, workflowId, op, payload, userId) + break + case 'subflow': + await handleSubflowOperationTx(tx, workflowId, op, payload, userId) + break + default: + throw new Error(`Unknown operation target: ${target}`) + } + }) + } catch (error) { + logger.error( + `❌ Error persisting workflow operation (${operation.operation} on ${operation.target}):`, + error + ) + throw error + } +} + +// Block operations +async function handleBlockOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any, + userId: string +) { + switch (operation) { + case 'add': { + // Validate required fields for add operation + if (!payload.id || !payload.type || !payload.name || !payload.position) { + throw new Error('Missing required fields for add block operation') + } + + logger.debug(`[SERVER] Adding block: ${payload.type} (${payload.id})`, { + isSubflowType: isSubflowBlockType(payload.type), + payload, + }) + + // Extract parentId and extent from payload.data if they exist there, otherwise from payload directly + const parentId = payload.parentId || payload.data?.parentId || null + const extent = payload.extent || payload.data?.extent || null + + logger.debug(`[SERVER] Block parent info:`, { + blockId: payload.id, + hasParent: !!parentId, + parentId, + extent, + payloadParentId: payload.parentId, + dataParentId: payload.data?.parentId, + }) + + try { + const insertData = { + id: payload.id, + workflowId, + type: payload.type, + name: payload.name, + positionX: payload.position.x, + positionY: payload.position.y, + data: payload.data || {}, + subBlocks: payload.subBlocks || {}, + outputs: payload.outputs || {}, + parentId, + extent, + enabled: payload.enabled ?? true, + horizontalHandles: payload.horizontalHandles ?? true, + isWide: payload.isWide ?? false, + height: payload.height || 0, + } + + await tx.insert(workflowBlocks).values(insertData) + } catch (insertError) { + logger.error(`[SERVER] ❌ Failed to insert block ${payload.id}:`, insertError) + throw insertError + } + + // Auto-create subflow entry for loop/parallel blocks + if (isSubflowBlockType(payload.type)) { + try { + const subflowConfig = + payload.type === SubflowType.LOOP + ? { + id: payload.id, + nodes: [], // Empty initially, will be populated when child blocks are added + iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS, + loopType: payload.data?.loopType || 'for', + forEachItems: payload.data?.collection || '', + } + : { + id: payload.id, + nodes: [], // Empty initially, will be populated when child blocks are added + distribution: payload.data?.collection || '', + } + + logger.debug( + `[SERVER] Auto-creating ${payload.type} subflow ${payload.id}:`, + subflowConfig + ) + + await tx.insert(workflowSubflows).values({ + id: payload.id, + workflowId, + type: payload.type, + config: subflowConfig, + }) + } catch (subflowError) { + logger.error( + `[SERVER] ❌ Failed to create ${payload.type} subflow ${payload.id}:`, + subflowError + ) + throw subflowError + } + } + + // If this block has a parent, update the parent's subflow node list + if (parentId) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + + logger.debug(`Added block ${payload.id} (${payload.type}) to workflow ${workflowId}`) + break + } + + case 'update-position': { + if (!payload.id || !payload.position) { + throw new Error('Missing required fields for update position operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + positionX: payload.position.x, + positionY: payload.position.y, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + break + } + + case 'remove': { + if (!payload.id) { + throw new Error('Missing block ID for remove operation') + } + + // Check if this is a subflow block that needs cascade deletion + const blockToRemove = await tx + .select({ type: workflowBlocks.type, parentId: workflowBlocks.parentId }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) { + // Cascade delete: Remove all child blocks first + const childBlocks = await tx + .select({ id: workflowBlocks.id, type: workflowBlocks.type }) + .from(workflowBlocks) + .where( + and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.parentId, payload.id)) + ) + + logger.debug( + `[SERVER] Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})` + ) + logger.debug( + `[SERVER] Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]` + ) + + // Remove edges connected to child blocks + for (const childBlock of childBlocks) { + await tx + .delete(workflowEdges) + .where( + and( + eq(workflowEdges.workflowId, workflowId), + or( + eq(workflowEdges.sourceBlockId, childBlock.id), + eq(workflowEdges.targetBlockId, childBlock.id) + ) + ) + ) + } + + // Remove child blocks from database + await tx + .delete(workflowBlocks) + .where( + and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.parentId, payload.id)) + ) + + // Remove the subflow entry + await tx + .delete(workflowSubflows) + .where( + and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId)) + ) + } + + // Remove any edges connected to this block + await tx + .delete(workflowEdges) + .where( + and( + eq(workflowEdges.workflowId, workflowId), + or( + eq(workflowEdges.sourceBlockId, payload.id), + eq(workflowEdges.targetBlockId, payload.id) + ) + ) + ) + + // Finally remove the block itself + await tx + .delete(workflowBlocks) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + + // If this block had a parent, update the parent's subflow node list + if (blockToRemove.length > 0 && blockToRemove[0].parentId) { + await updateSubflowNodeList(tx, workflowId, blockToRemove[0].parentId) + } + + logger.debug(`Removed block ${payload.id} and its connections from workflow ${workflowId}`) + break + } + + case 'update-name': { + if (!payload.id || !payload.name) { + throw new Error('Missing required fields for update name operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + name: payload.name, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block name: ${payload.id} -> "${payload.name}"`) + break + } + + case 'toggle-enabled': { + if (!payload.id) { + throw new Error('Missing block ID for toggle enabled operation') + } + + // Get current enabled state + const currentBlock = await tx + .select({ enabled: workflowBlocks.enabled }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (currentBlock.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + const newEnabledState = !currentBlock[0].enabled + + await tx + .update(workflowBlocks) + .set({ + enabled: newEnabledState, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + + logger.debug(`Toggled block enabled: ${payload.id} -> ${newEnabledState}`) + break + } + + case 'update-parent': { + if (!payload.id) { + throw new Error('Missing block ID for update parent operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + parentId: payload.parentId || null, + extent: payload.extent || null, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + // If the block now has a parent, update the parent's subflow node list + if (payload.parentId) { + await updateSubflowNodeList(tx, workflowId, payload.parentId) + } + + logger.debug( + `Updated block parent: ${payload.id} -> parent: ${payload.parentId}, extent: ${payload.extent}` + ) + break + } + + case 'update-wide': { + if (!payload.id || payload.isWide === undefined) { + throw new Error('Missing required fields for update wide operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + isWide: payload.isWide, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block wide state: ${payload.id} -> ${payload.isWide}`) + break + } + + case 'update-advanced-mode': { + if (!payload.id || payload.advancedMode === undefined) { + throw new Error('Missing required fields for update advanced mode operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + advancedMode: payload.advancedMode, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block advanced mode: ${payload.id} -> ${payload.advancedMode}`) + break + } + + // Add other block operations as needed + default: + logger.warn(`Unknown block operation: ${operation}`) + throw new Error(`Unsupported block operation: ${operation}`) + } +} + +// Edge operations +async function handleEdgeOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any, + userId: string +) { + switch (operation) { + case 'add': { + // Validate required fields + if (!payload.id || !payload.source || !payload.target) { + throw new Error('Missing required fields for add edge operation') + } + + await tx.insert(workflowEdges).values({ + id: payload.id, + workflowId, + sourceBlockId: payload.source, + targetBlockId: payload.target, + sourceHandle: payload.sourceHandle || null, + targetHandle: payload.targetHandle || null, + }) + + logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`) + break + } + + case 'remove': { + if (!payload.id) { + throw new Error('Missing edge ID for remove operation') + } + + const deleteResult = await tx + .delete(workflowEdges) + .where(and(eq(workflowEdges.id, payload.id), eq(workflowEdges.workflowId, workflowId))) + .returning({ id: workflowEdges.id }) + + if (deleteResult.length === 0) { + throw new Error(`Edge ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Removed edge ${payload.id} from workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown edge operation: ${operation}`) + throw new Error(`Unsupported edge operation: ${operation}`) + } +} + +// Subflow operations +async function handleSubflowOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any, + userId: string +) { + switch (operation) { + case 'update': { + if (!payload.id || !payload.config) { + throw new Error('Missing required fields for update subflow operation') + } + + logger.debug(`[SERVER] Updating subflow ${payload.id} with config:`, payload.config) + + // Update the subflow configuration + const updateResult = await tx + .update(workflowSubflows) + .set({ + config: payload.config, + updatedAt: new Date(), + }) + .where( + and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId)) + ) + .returning({ id: workflowSubflows.id }) + + if (updateResult.length === 0) { + throw new Error(`Subflow ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`[SERVER] Successfully updated subflow ${payload.id} in database`) + + // Also update the corresponding block's data to keep UI in sync + if (payload.type === 'loop' && payload.config.iterations !== undefined) { + // Update the loop block's data.count property + await tx + .update(workflowBlocks) + .set({ + data: { + ...payload.config, + count: payload.config.iterations, + loopType: payload.config.loopType, + collection: payload.config.forEachItems, + width: 500, + height: 300, + type: 'loopNode', + }, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + } else if (payload.type === 'parallel') { + // Update the parallel block's data properties + const blockData = { + ...payload.config, + width: 500, + height: 300, + type: 'parallelNode', + } + + // Include count if provided + if (payload.config.count !== undefined) { + blockData.count = payload.config.count + } + + // Include collection if provided + if (payload.config.distribution !== undefined) { + blockData.collection = payload.config.distribution + } + + // Include parallelType if provided + if (payload.config.parallelType !== undefined) { + blockData.parallelType = payload.config.parallelType + } + + await tx + .update(workflowBlocks) + .set({ + data: blockData, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + } + + break + } + + // Add other subflow operations as needed + default: + logger.warn(`Unknown subflow operation: ${operation}`) + throw new Error(`Unsupported subflow operation: ${operation}`) + } +} diff --git a/apps/sim/socket-server/handlers/connection.ts b/apps/sim/socket-server/handlers/connection.ts new file mode 100644 index 00000000000..b66511f7d9c --- /dev/null +++ b/apps/sim/socket-server/handlers/connection.ts @@ -0,0 +1,42 @@ +import { createLogger } from '../../lib/logs/console-logger' +import type { AuthenticatedSocket } from '../middleware/auth' +import type { RoomManager } from '../rooms/manager' +import type { HandlerDependencies } from './workflow' + +const logger = createLogger('ConnectionHandlers') + +export function setupConnectionHandlers( + socket: AuthenticatedSocket, + deps: HandlerDependencies | RoomManager +) { + const roomManager = + deps instanceof Object && 'roomManager' in deps ? deps.roomManager : (deps as RoomManager) + + socket.on('error', (error) => { + logger.error(`Socket ${socket.id} error:`, error) + }) + + socket.conn.on('error', (error) => { + logger.error(`Socket ${socket.id} connection error:`, error) + }) + + socket.on('disconnect', (reason) => { + const workflowId = roomManager.getWorkflowIdForSocket(socket.id) + const session = roomManager.getUserSession(socket.id) + + logger.info(`Socket ${socket.id} disconnected: ${reason}`) + + if (workflowId && session) { + roomManager.cleanupUserFromRoom(socket.id, workflowId) + + // Broadcast updated presence list to all remaining users + roomManager.broadcastPresenceUpdate(workflowId) + + logger.info( + `User ${session.userId} (${session.userName}) disconnected from workflow ${workflowId} - reason: ${reason}` + ) + } + + roomManager.clearPendingOperations(socket.id) + }) +} diff --git a/apps/sim/socket-server/handlers/index.ts b/apps/sim/socket-server/handlers/index.ts new file mode 100644 index 00000000000..cf8a2f46136 --- /dev/null +++ b/apps/sim/socket-server/handlers/index.ts @@ -0,0 +1,30 @@ +import type { AuthenticatedSocket } from '../middleware/auth' +import type { RoomManager, UserPresence, WorkflowRoom } from '../rooms/manager' +import { setupConnectionHandlers } from './connection' +import { setupOperationsHandlers } from './operations' +import { setupPresenceHandlers } from './presence' +import { setupSubblocksHandlers } from './subblocks' +import { setupWorkflowHandlers } from './workflow' + +export type { UserPresence, WorkflowRoom } + +/** + * Sets up all socket event handlers for an authenticated socket connection + * @param socket - The authenticated socket instance + * @param roomManager - Room manager instance for state management + */ +export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: RoomManager) { + setupWorkflowHandlers(socket, roomManager) + setupOperationsHandlers(socket, roomManager) + setupSubblocksHandlers(socket, roomManager) + setupPresenceHandlers(socket, roomManager) + setupConnectionHandlers(socket, roomManager) +} + +export { + setupWorkflowHandlers, + setupOperationsHandlers, + setupSubblocksHandlers, + setupPresenceHandlers, + setupConnectionHandlers, +} diff --git a/apps/sim/socket-server/handlers/operations.ts b/apps/sim/socket-server/handlers/operations.ts new file mode 100644 index 00000000000..b56bd827668 --- /dev/null +++ b/apps/sim/socket-server/handlers/operations.ts @@ -0,0 +1,169 @@ +import { ZodError } from 'zod' +import { createLogger } from '../../lib/logs/console-logger' +import { persistWorkflowOperation } from '../database/operations' +import type { AuthenticatedSocket } from '../middleware/auth' +import { verifyOperationPermission } from '../middleware/permissions' +import type { RoomManager } from '../rooms/manager' +import { WorkflowOperationSchema } from '../validation/schemas' +import type { HandlerDependencies } from './workflow' + +const logger = createLogger('OperationsHandlers') + +// Simplified conflict resolution - just last-write-wins since we have normalized tables +function shouldAcceptOperation(operation: any, roomLastModified: number): boolean { + // Accept all operations - with normalized tables, conflicts are very unlikely + return true +} + +export function setupOperationsHandlers( + socket: AuthenticatedSocket, + deps: HandlerDependencies | RoomManager +) { + const roomManager = + deps instanceof Object && 'roomManager' in deps ? deps.roomManager : (deps as RoomManager) + socket.on('workflow-operation', async (data) => { + const workflowId = roomManager.getWorkflowIdForSocket(socket.id) + const session = roomManager.getUserSession(socket.id) + + if (!workflowId || !session) { + socket.emit('error', { + type: 'NOT_JOINED', + message: 'Not joined to any workflow', + }) + return + } + + const room = roomManager.getWorkflowRoom(workflowId) + if (!room) { + socket.emit('error', { + type: 'ROOM_NOT_FOUND', + message: 'Workflow room not found', + }) + return + } + + try { + const validatedOperation = WorkflowOperationSchema.parse(data) + const { operation, target, payload, timestamp } = validatedOperation + + if (!shouldAcceptOperation(validatedOperation, room.lastModified)) { + socket.emit('operation-rejected', { + type: 'OPERATION_REJECTED', + message: 'Operation rejected', + operation, + target, + serverTimestamp: Date.now(), + }) + return + } + + // Check operation permissions + const permissionCheck = await verifyOperationPermission( + session.userId, + workflowId, + operation, + target + ) + if (!permissionCheck.allowed) { + logger.warn( + `User ${session.userId} forbidden from ${operation} on ${target}: ${permissionCheck.reason}` + ) + socket.emit('operation-forbidden', { + type: 'INSUFFICIENT_PERMISSIONS', + message: permissionCheck.reason || 'Insufficient permissions for this operation', + operation, + target, + }) + return + } + + const userPresence = room.users.get(socket.id) + if (userPresence) { + userPresence.lastActivity = Date.now() + } + + // Persist to database with transaction (last-write-wins) + const serverTimestamp = Date.now() + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: serverTimestamp, + userId: session.userId, + }) + + room.lastModified = serverTimestamp + + const broadcastData = { + operation, + target, + payload, + timestamp: serverTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + // Add operation metadata for better client handling + metadata: { + workflowId, + operationId: crypto.randomUUID(), + }, + } + + socket.to(workflowId).emit('workflow-operation', broadcastData) + + socket.emit('operation-confirmed', { + operation, + target, + operationId: broadcastData.metadata.operationId, + serverTimestamp, + }) + } catch (error) { + if (error instanceof ZodError) { + socket.emit('operation-error', { + type: 'VALIDATION_ERROR', + message: 'Invalid operation data', + errors: error.errors, + operation: data.operation, + target: data.target, + }) + logger.warn(`Validation error for operation from ${session.userId}:`, error.errors) + } else if (error instanceof Error) { + // Handle specific database errors + if (error.message.includes('not found')) { + socket.emit('operation-error', { + type: 'RESOURCE_NOT_FOUND', + message: error.message, + operation: data.operation, + target: data.target, + }) + } else if (error.message.includes('duplicate') || error.message.includes('unique')) { + socket.emit('operation-error', { + type: 'DUPLICATE_RESOURCE', + message: 'Resource already exists', + operation: data.operation, + target: data.target, + }) + } else { + socket.emit('operation-error', { + type: 'OPERATION_FAILED', + message: error.message, + operation: data.operation, + target: data.target, + }) + } + logger.error( + `Operation error for ${session.userId} (${data.operation} on ${data.target}):`, + error + ) + } else { + socket.emit('operation-error', { + type: 'UNKNOWN_ERROR', + message: 'An unknown error occurred', + operation: data.operation, + target: data.target, + }) + logger.error('Unknown error handling workflow operation:', error) + } + } + }) +} diff --git a/apps/sim/socket-server/handlers/presence.ts b/apps/sim/socket-server/handlers/presence.ts new file mode 100644 index 00000000000..0596a2aced1 --- /dev/null +++ b/apps/sim/socket-server/handlers/presence.ts @@ -0,0 +1,60 @@ +import { createLogger } from '../../lib/logs/console-logger' +import type { AuthenticatedSocket } from '../middleware/auth' +import type { RoomManager } from '../rooms/manager' +import type { HandlerDependencies } from './workflow' + +const logger = createLogger('PresenceHandlers') + +export function setupPresenceHandlers( + socket: AuthenticatedSocket, + deps: HandlerDependencies | RoomManager +) { + const roomManager = + deps instanceof Object && 'roomManager' in deps ? deps.roomManager : (deps as RoomManager) + socket.on('cursor-update', ({ cursor }) => { + const workflowId = roomManager.getWorkflowIdForSocket(socket.id) + const session = roomManager.getUserSession(socket.id) + + if (!workflowId || !session) return + + const room = roomManager.getWorkflowRoom(workflowId) + if (!room) return + + const userPresence = room.users.get(socket.id) + if (userPresence) { + userPresence.cursor = cursor + userPresence.lastActivity = Date.now() + } + + socket.to(workflowId).emit('cursor-update', { + socketId: socket.id, + userId: session.userId, + userName: session.userName, + cursor, + }) + }) + + // Handle user selection (for showing what block/element a user has selected) + socket.on('selection-update', ({ selection }) => { + const workflowId = roomManager.getWorkflowIdForSocket(socket.id) + const session = roomManager.getUserSession(socket.id) + + if (!workflowId || !session) return + + const room = roomManager.getWorkflowRoom(workflowId) + if (!room) return + + const userPresence = room.users.get(socket.id) + if (userPresence) { + userPresence.selection = selection + userPresence.lastActivity = Date.now() + } + + socket.to(workflowId).emit('selection-update', { + socketId: socket.id, + userId: session.userId, + userName: session.userName, + selection, + }) + }) +} diff --git a/apps/sim/socket-server/handlers/subblocks.ts b/apps/sim/socket-server/handlers/subblocks.ts new file mode 100644 index 00000000000..bf060409c8f --- /dev/null +++ b/apps/sim/socket-server/handlers/subblocks.ts @@ -0,0 +1,134 @@ +import { and, eq } from 'drizzle-orm' +import { db } from '../../db' +import { workflow, workflowBlocks } from '../../db/schema' +import { createLogger } from '../../lib/logs/console-logger' +import type { AuthenticatedSocket } from '../middleware/auth' +import type { RoomManager } from '../rooms/manager' +import type { HandlerDependencies } from './workflow' + +const logger = createLogger('SubblocksHandlers') + +export function setupSubblocksHandlers( + socket: AuthenticatedSocket, + deps: HandlerDependencies | RoomManager +) { + const roomManager = + deps instanceof Object && 'roomManager' in deps ? deps.roomManager : (deps as RoomManager) + socket.on('subblock-update', async (data) => { + const workflowId = roomManager.getWorkflowIdForSocket(socket.id) + const session = roomManager.getUserSession(socket.id) + + if (!workflowId || !session) { + logger.debug(`Ignoring subblock update: socket not connected to any workflow room`, { + socketId: socket.id, + hasWorkflowId: !!workflowId, + hasSession: !!session, + }) + return + } + + const { blockId, subblockId, value, timestamp } = data + const room = roomManager.getWorkflowRoom(workflowId) + + if (!room) { + logger.debug(`Ignoring subblock update: workflow room not found`, { + socketId: socket.id, + workflowId, + blockId, + subblockId, + }) + return + } + + try { + const userPresence = room.users.get(socket.id) + if (userPresence) { + userPresence.lastActivity = Date.now() + } + + // First, verify that the workflow still exists in the database + const workflowExists = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (workflowExists.length === 0) { + logger.warn(`Ignoring subblock update: workflow ${workflowId} no longer exists`, { + socketId: socket.id, + blockId, + subblockId, + }) + roomManager.cleanupUserFromRoom(socket.id, workflowId) + return + } + + let updateSuccessful = false + await db.transaction(async (tx) => { + const [block] = await tx + .select({ subBlocks: workflowBlocks.subBlocks }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!block) { + // Block was deleted - this is a normal race condition in collaborative editing + logger.debug( + `Ignoring subblock update for deleted block: ${workflowId}/${blockId}.${subblockId}` + ) + return + } + + const subBlocks = (block.subBlocks as any) || {} + + if (!subBlocks[subblockId]) { + // Create new subblock with minimal structure + subBlocks[subblockId] = { + id: subblockId, + type: 'unknown', // Will be corrected by next collaborative update + value: value, + } + } else { + // Preserve existing id and type, only update value + subBlocks[subblockId] = { + ...subBlocks[subblockId], + value: value, + } + } + + await tx + .update(workflowBlocks) + .set({ + subBlocks: subBlocks, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + + updateSuccessful = true + }) + + // Only broadcast to other clients if the update was successful + if (updateSuccessful) { + socket.to(workflowId).emit('subblock-update', { + blockId, + subblockId, + value, + timestamp, + senderId: socket.id, + userId: session.userId, + }) + } + + logger.debug(`Subblock update in workflow ${workflowId}: ${blockId}.${subblockId}`) + } catch (error) { + logger.error('Error handling subblock update:', error) + + socket.emit('operation-error', { + type: 'SUBBLOCK_UPDATE_FAILED', + message: `Failed to update subblock ${blockId}.${subblockId}: ${error instanceof Error ? error.message : 'Unknown error'}`, + operation: 'subblock-update', + target: 'subblock', + }) + } + }) +} diff --git a/apps/sim/socket-server/handlers/workflow.ts b/apps/sim/socket-server/handlers/workflow.ts new file mode 100644 index 00000000000..0fba11ddba8 --- /dev/null +++ b/apps/sim/socket-server/handlers/workflow.ts @@ -0,0 +1,149 @@ +import { createLogger } from '../../lib/logs/console-logger' +import { getWorkflowState } from '../database/operations' +import type { AuthenticatedSocket } from '../middleware/auth' +import { verifyWorkflowAccess } from '../middleware/permissions' +import type { RoomManager, UserPresence, WorkflowRoom } from '../rooms/manager' + +const logger = createLogger('WorkflowHandlers') + +export type { UserPresence, WorkflowRoom } + +export interface HandlerDependencies { + roomManager: RoomManager +} + +export const createWorkflowRoom = (workflowId: string): WorkflowRoom => ({ + workflowId, + users: new Map(), + lastModified: Date.now(), + activeConnections: 0, +}) + +export const cleanupUserFromRoom = ( + socketId: string, + workflowId: string, + roomManager: RoomManager +) => { + roomManager.cleanupUserFromRoom(socketId, workflowId) +} + +export function setupWorkflowHandlers( + socket: AuthenticatedSocket, + deps: HandlerDependencies | RoomManager +) { + const roomManager = + deps instanceof Object && 'roomManager' in deps ? deps.roomManager : (deps as RoomManager) + socket.on('join-workflow', async ({ workflowId }) => { + try { + const userId = socket.userId + const userName = socket.userName + + if (!userId || !userName) { + logger.warn(`Join workflow rejected: Socket ${socket.id} not authenticated`) + socket.emit('join-workflow-error', { error: 'Authentication required' }) + return + } + + logger.info(`Join workflow request from ${userId} (${userName}) for workflow ${workflowId}`) + + try { + const accessInfo = await verifyWorkflowAccess(userId, workflowId) + if (!accessInfo.hasAccess) { + logger.warn(`User ${userId} (${userName}) denied access to workflow ${workflowId}`) + socket.emit('join-workflow-error', { error: 'Access denied to workflow' }) + return + } + } catch (error) { + logger.warn(`Error verifying workflow access for ${userId}:`, error) + socket.emit('join-workflow-error', { error: 'Failed to verify workflow access' }) + return + } + + // Ensure user only joins one workflow at a time + const currentWorkflowId = roomManager.getWorkflowIdForSocket(socket.id) + if (currentWorkflowId) { + socket.leave(currentWorkflowId) + roomManager.cleanupUserFromRoom(socket.id, currentWorkflowId) + + // Broadcast updated presence list to all remaining users + roomManager.broadcastPresenceUpdate(currentWorkflowId) + } + + socket.join(workflowId) + + if (!roomManager.hasWorkflowRoom(workflowId)) { + roomManager.setWorkflowRoom(workflowId, roomManager.createWorkflowRoom(workflowId)) + } + + const room = roomManager.getWorkflowRoom(workflowId)! + room.activeConnections++ + + const userPresence: UserPresence = { + userId, + workflowId, + userName, + socketId: socket.id, + joinedAt: Date.now(), + lastActivity: Date.now(), + } + + room.users.set(socket.id, userPresence) + roomManager.setWorkflowForSocket(socket.id, workflowId) + roomManager.setUserSession(socket.id, { userId, userName }) + + const workflowState = await getWorkflowState(workflowId) + socket.emit('workflow-state', workflowState) + + // Send complete presence list to all users in the room (including the new user) + roomManager.broadcastPresenceUpdate(workflowId) + + logger.info( + `User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${room.activeConnections} users.` + ) + } catch (error) { + logger.error('Error joining workflow:', error) + socket.emit('error', { + type: 'JOIN_ERROR', + message: 'Failed to join workflow', + }) + } + }) + + socket.on('request-sync', async ({ workflowId }) => { + try { + if (!socket.userId) { + socket.emit('error', { type: 'NOT_AUTHENTICATED', message: 'Not authenticated' }) + return + } + + const accessInfo = await verifyWorkflowAccess(socket.userId, workflowId) + if (!accessInfo.hasAccess) { + socket.emit('error', { type: 'ACCESS_DENIED', message: 'Access denied' }) + return + } + + const workflowState = await getWorkflowState(workflowId) + socket.emit('workflow-state', workflowState) + + logger.info(`Sent sync data to ${socket.userId} for workflow ${workflowId}`) + } catch (error) { + logger.error('Error handling sync request:', error) + socket.emit('error', { type: 'SYNC_FAILED', message: 'Failed to sync workflow state' }) + } + }) + + socket.on('leave-workflow', () => { + const workflowId = roomManager.getWorkflowIdForSocket(socket.id) + const session = roomManager.getUserSession(socket.id) + + if (workflowId && session) { + socket.leave(workflowId) + roomManager.cleanupUserFromRoom(socket.id, workflowId) + + // Broadcast updated presence list to all remaining users + roomManager.broadcastPresenceUpdate(workflowId) + + logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`) + } + }) +} diff --git a/apps/sim/socket-server/index.test.ts b/apps/sim/socket-server/index.test.ts new file mode 100644 index 00000000000..e09c6f1b90f --- /dev/null +++ b/apps/sim/socket-server/index.test.ts @@ -0,0 +1,316 @@ +/** + * Tests for the socket server index.ts + * + * @vitest-environment node + */ +import { createServer } from 'http' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLogger } from '../lib/logs/console-logger' +import { createSocketIOServer } from './config/socket' +import { RoomManager } from './rooms/manager' +import { createHttpHandler } from './routes/http' + +vi.mock('../lib/auth', () => ({ + auth: { + api: { + verifyOneTimeToken: vi.fn(), + }, + }, +})) + +vi.mock('../db', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + }, +})) + +vi.mock('./middleware/auth', () => ({ + authenticateSocket: vi.fn((socket, next) => { + socket.userId = 'test-user-id' + socket.userName = 'Test User' + socket.userEmail = 'test@example.com' + next() + }), +})) + +vi.mock('./middleware/permissions', () => ({ + verifyWorkflowAccess: vi.fn().mockResolvedValue({ + hasAccess: true, + role: 'owner', + }), + verifyOperationPermission: vi.fn().mockResolvedValue({ + allowed: true, + }), +})) + +vi.mock('./database/operations', () => ({ + getWorkflowState: vi.fn().mockResolvedValue({ + id: 'test-workflow', + name: 'Test Workflow', + lastModified: Date.now(), + }), + persistWorkflowOperation: vi.fn().mockResolvedValue(undefined), +})) + +describe('Socket Server Index Integration', () => { + let httpServer: any + let io: any + let roomManager: RoomManager + let logger: any + let PORT: number + + beforeAll(() => { + logger = createLogger('SocketServerTest') + }) + + beforeEach(async () => { + // Use a random port for each test to avoid conflicts + PORT = 3333 + Math.floor(Math.random() * 1000) + + // Create HTTP server + httpServer = createServer() + + // Create Socket.IO server using extracted config + io = createSocketIOServer(httpServer) + + // Initialize room manager after io is created + roomManager = new RoomManager(io) + + // Configure HTTP request handler + const httpHandler = createHttpHandler(roomManager, logger) + httpServer.on('request', httpHandler) + + // Start server + await new Promise((resolve) => { + httpServer.listen(PORT, '0.0.0.0', () => { + resolve() + }) + }) + }) + + afterEach(async () => { + if (io) { + io.close() + } + if (httpServer) { + httpServer.close() + } + vi.clearAllMocks() + }) + + describe('HTTP Server Configuration', () => { + it('should create HTTP server successfully', () => { + expect(httpServer).toBeDefined() + expect(httpServer.listening).toBe(true) + }) + + it('should handle health check endpoint', async () => { + try { + const response = await fetch(`http://localhost:${PORT}/health`) + expect(response.status).toBe(200) + + const data = await response.json() + expect(data).toHaveProperty('status', 'ok') + expect(data).toHaveProperty('timestamp') + expect(data).toHaveProperty('connections') + } catch (error) { + // Skip this test if fetch fails (likely due to test environment) + console.warn('Health check test skipped due to fetch error:', error) + } + }) + }) + + describe('Socket.IO Server Configuration', () => { + it('should create Socket.IO server with proper configuration', () => { + expect(io).toBeDefined() + expect(io.engine).toBeDefined() + }) + + it('should have proper CORS configuration', () => { + const corsOptions = io.engine.opts.cors + expect(corsOptions).toBeDefined() + expect(corsOptions.methods).toContain('GET') + expect(corsOptions.methods).toContain('POST') + expect(corsOptions.credentials).toBe(true) + }) + + it('should have proper transport configuration', () => { + const transports = io.engine.opts.transports + expect(transports).toContain('polling') + expect(transports).toContain('websocket') + }) + }) + + describe('Room Manager Integration', () => { + it('should create room manager successfully', () => { + expect(roomManager).toBeDefined() + expect(roomManager.getTotalActiveConnections()).toBe(0) + }) + + it('should create workflow rooms', () => { + const workflowId = 'test-workflow-123' + const room = roomManager.createWorkflowRoom(workflowId) + roomManager.setWorkflowRoom(workflowId, room) + + expect(roomManager.hasWorkflowRoom(workflowId)).toBe(true) + const retrievedRoom = roomManager.getWorkflowRoom(workflowId) + expect(retrievedRoom).toBeDefined() + expect(retrievedRoom?.workflowId).toBe(workflowId) + }) + + it('should manage user sessions', () => { + const socketId = 'test-socket-123' + const workflowId = 'test-workflow-456' + const session = { userId: 'user-123', userName: 'Test User' } + + roomManager.setWorkflowForSocket(socketId, workflowId) + roomManager.setUserSession(socketId, session) + + expect(roomManager.getWorkflowIdForSocket(socketId)).toBe(workflowId) + expect(roomManager.getUserSession(socketId)).toEqual(session) + }) + + it('should clean up rooms properly', () => { + const workflowId = 'test-workflow-789' + const socketId = 'test-socket-789' + + const room = roomManager.createWorkflowRoom(workflowId) + roomManager.setWorkflowRoom(workflowId, room) + + // Add user to room + room.users.set(socketId, { + userId: 'user-789', + workflowId, + userName: 'Test User', + socketId, + joinedAt: Date.now(), + lastActivity: Date.now(), + }) + room.activeConnections = 1 + + roomManager.setWorkflowForSocket(socketId, workflowId) + + // Clean up user + roomManager.cleanupUserFromRoom(socketId, workflowId) + + expect(roomManager.hasWorkflowRoom(workflowId)).toBe(false) + expect(roomManager.getWorkflowIdForSocket(socketId)).toBeUndefined() + }) + }) + + describe('Module Integration', () => { + it.concurrent('should properly import all extracted modules', async () => { + // Test that all modules can be imported without errors + const { createSocketIOServer } = await import('./config/socket') + const { createHttpHandler } = await import('./routes/http') + const { RoomManager } = await import('./rooms/manager') + const { authenticateSocket } = await import('./middleware/auth') + const { verifyWorkflowAccess } = await import('./middleware/permissions') + const { getWorkflowState } = await import('./database/operations') + const { WorkflowOperationSchema } = await import('./validation/schemas') + + expect(createSocketIOServer).toBeTypeOf('function') + expect(createHttpHandler).toBeTypeOf('function') + expect(RoomManager).toBeTypeOf('function') + expect(authenticateSocket).toBeTypeOf('function') + expect(verifyWorkflowAccess).toBeTypeOf('function') + expect(getWorkflowState).toBeTypeOf('function') + expect(WorkflowOperationSchema).toBeDefined() + }) + + it.concurrent('should maintain all original functionality after refactoring', () => { + // Verify that the main components are properly instantiated + expect(httpServer).toBeDefined() + expect(io).toBeDefined() + expect(roomManager).toBeDefined() + + // Verify core methods exist and are callable + expect(typeof roomManager.createWorkflowRoom).toBe('function') + expect(typeof roomManager.cleanupUserFromRoom).toBe('function') + expect(typeof roomManager.handleWorkflowDeletion).toBe('function') + expect(typeof roomManager.validateWorkflowConsistency).toBe('function') + }) + }) + + describe('Error Handling', () => { + it('should have global error handlers configured', () => { + expect(typeof process.on).toBe('function') + }) + + it('should handle server setup', () => { + expect(httpServer).toBeDefined() + expect(io).toBeDefined() + }) + }) + + describe('Authentication Middleware', () => { + it('should apply authentication middleware to Socket.IO', () => { + expect(io._parser).toBeDefined() + }) + }) + + describe('Graceful Shutdown', () => { + it('should have shutdown capability', () => { + expect(typeof httpServer.close).toBe('function') + expect(typeof io.close).toBe('function') + }) + }) + + describe('Validation and Utils', () => { + it.concurrent('should validate workflow operations', async () => { + const { WorkflowOperationSchema } = await import('./validation/schemas') + + const validOperation = { + operation: 'add', + target: 'block', + payload: { + id: 'test-block', + type: 'action', + name: 'Test Block', + position: { x: 100, y: 200 }, + }, + timestamp: Date.now(), + } + + expect(() => WorkflowOperationSchema.parse(validOperation)).not.toThrow() + }) + + it.concurrent('should validate edge operations', async () => { + const { WorkflowOperationSchema } = await import('./validation/schemas') + + const validEdgeOperation = { + operation: 'add', + target: 'edge', + payload: { + id: 'test-edge', + source: 'block-1', + target: 'block-2', + }, + timestamp: Date.now(), + } + + expect(() => WorkflowOperationSchema.parse(validEdgeOperation)).not.toThrow() + }) + + it.concurrent('should validate subflow operations', async () => { + const { WorkflowOperationSchema } = await import('./validation/schemas') + + const validSubflowOperation = { + operation: 'update', + target: 'subflow', + payload: { + id: 'test-subflow', + type: 'loop', + config: { iterations: 5 }, + }, + timestamp: Date.now(), + } + + expect(() => WorkflowOperationSchema.parse(validSubflowOperation)).not.toThrow() + }) + }) +}) diff --git a/apps/sim/socket-server/index.ts b/apps/sim/socket-server/index.ts index f91e709b2e7..be9588aadb5 100644 --- a/apps/sim/socket-server/index.ts +++ b/apps/sim/socket-server/index.ts @@ -1,1245 +1,26 @@ import { createServer } from 'http' -import { Server, type Socket } from 'socket.io' - -// Extend Socket interface to include user data -interface AuthenticatedSocket extends Socket { - userId?: string - userName?: string - userEmail?: string - activeOrganizationId?: string -} - -import { and, eq, isNull, or } from 'drizzle-orm' -import { z } from 'zod' -import { db } from '../db' -import { - workflow, - workflowBlocks, - workflowEdges, - workflowSubflows, - workspaceMember, -} from '../db/schema' -import { auth } from '../lib/auth' import { createLogger } from '../lib/logs/console-logger' +import { createSocketIOServer } from './config/socket' +import { setupAllHandlers } from './handlers' +import { type AuthenticatedSocket, authenticateSocket } from './middleware/auth' +import { RoomManager } from './rooms/manager' +import { createHttpHandler } from './routes/http' const logger = createLogger('CollaborativeSocketServer') -// Enhanced server configuration -const httpServer = createServer((req, res) => { - // Handle health check for Railway - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - status: 'ok', - timestamp: new Date().toISOString(), - connections: Array.from(workflowRooms.values()).reduce( - (total, room) => total + room.activeConnections, - 0 - ), - }) - ) - return - } - - // Handle workflow deletion notifications from the main API - if (req.method === 'POST' && req.url === '/api/workflow-deleted') { - let body = '' - req.on('data', (chunk) => { - body += chunk.toString() - }) - req.on('end', () => { - try { - const { workflowId } = JSON.parse(body) - handleWorkflowDeletion(workflowId) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) - } catch (error) { - logger.error('Error handling workflow deletion notification:', error) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Failed to process deletion notification' })) - } - }) - return - } - - // Default response for other requests - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Not found' })) -}) - -// Configure allowed origins -const allowedOrigins = [ - process.env.NEXT_PUBLIC_APP_URL, - process.env.VERCEL_URL, - 'http://localhost:3000', - 'http://localhost:3001', - ...(process.env.ALLOWED_ORIGINS?.split(',') || []), -].filter((url): url is string => Boolean(url)) - -logger.info('Socket.IO CORS configuration:', { allowedOrigins }) - -const io = new Server(httpServer, { - cors: { - origin: allowedOrigins, - methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'socket.io'], - credentials: true, // Enable credentials to accept cookies - }, - transports: ['polling', 'websocket'], - allowEIO3: true, - pingTimeout: 60000, - pingInterval: 25000, - maxHttpBufferSize: 1e6, - cookie: { - name: 'io', - path: '/', - httpOnly: true, - sameSite: 'none', // Required for cross-origin cookies - secure: process.env.NODE_ENV === 'production', // HTTPS in production - }, -}) - -// Enhanced connection and presence tracking -interface UserPresence { - userId: string - workflowId: string - userName: string - socketId: string - joinedAt: number - lastActivity: number - cursor?: { x: number; y: number } - selection?: { type: 'block' | 'edge' | 'none'; id?: string } -} - -interface WorkflowRoom { - workflowId: string - users: Map // socketId -> UserPresence - lastModified: number - activeConnections: number -} - -// Global state management -const workflowRooms = new Map() // workflowId -> WorkflowRoom -const socketToWorkflow = new Map() // socketId -> workflowId -const userSessions = new Map() // socketId -> session - -// Enhanced database operation queue for batching and performance -const pendingDbOperations = new Map() // workflowId -> operations[] -const batchTimeouts = new Map() // workflowId -> timeout -const BATCH_DELAY = 100 // ms - batch operations within 100ms window -const MAX_BATCH_SIZE = 50 // Maximum operations per batch - -// Validation schemas for workflow operations -const PositionSchema = z.object({ - x: z.number(), - y: z.number(), -}) - -const BlockOperationSchema = z.object({ - operation: z.enum([ - 'add', - 'remove', - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'duplicate', - ]), - target: z.literal('block'), - payload: z.object({ - id: z.string(), - type: z.string().optional(), - name: z.string().optional(), - position: PositionSchema.optional(), - data: z.record(z.any()).optional(), - parentId: z.string().optional(), - extent: z.enum(['parent']).optional(), - enabled: z.boolean().optional(), - }), - timestamp: z.number(), -}) - -const EdgeOperationSchema = z.object({ - operation: z.enum(['add', 'remove']), - target: z.literal('edge'), - payload: z.object({ - id: z.string(), - source: z.string().optional(), - target: z.string().optional(), - sourceHandle: z.string().nullable().optional(), - targetHandle: z.string().nullable().optional(), - }), - timestamp: z.number(), -}) - -// Constants -const DEFAULT_LOOP_ITERATIONS = 5 - -// Enum for subflow types -enum SubflowType { - LOOP = 'loop', - PARALLEL = 'parallel', -} - -// Helper function to check if a block type is a subflow type -function isSubflowBlockType(blockType: string): blockType is SubflowType { - return Object.values(SubflowType).includes(blockType as SubflowType) -} - -// Helper function to update subflow node lists when child blocks are added/removed -async function updateSubflowNodeList(dbOrTx: any, workflowId: string, parentId: string) { - try { - // Get all child blocks of this parent - const childBlocks = await dbOrTx - .select({ id: workflowBlocks.id }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.workflowId, workflowId), eq(workflowBlocks.parentId, parentId))) - - const childNodeIds = childBlocks.map((block: any) => block.id) - - // Get current subflow config - const subflowData = await dbOrTx - .select({ config: workflowSubflows.config }) - .from(workflowSubflows) - .where(and(eq(workflowSubflows.id, parentId), eq(workflowSubflows.workflowId, workflowId))) - .limit(1) - - if (subflowData.length > 0) { - const updatedConfig = { - ...subflowData[0].config, - nodes: childNodeIds, - } - - await dbOrTx - .update(workflowSubflows) - .set({ - config: updatedConfig, - updatedAt: new Date(), - }) - .where(and(eq(workflowSubflows.id, parentId), eq(workflowSubflows.workflowId, workflowId))) - - logger.debug(`Updated subflow ${parentId} node list: [${childNodeIds.join(', ')}]`) - } - } catch (error) { - logger.error(`Error updating subflow node list for ${parentId}:`, error) - } -} - -const SubflowOperationSchema = z.object({ - operation: z.enum(['add', 'remove', 'update']), - target: z.literal('subflow'), - payload: z.object({ - id: z.string(), - type: z.enum(['loop', 'parallel']).optional(), - config: z.record(z.any()).optional(), - }), - timestamp: z.number(), -}) - -const WorkflowOperationSchema = z.union([ - BlockOperationSchema, - EdgeOperationSchema, - SubflowOperationSchema, -]) - -// Simplified conflict resolution - just last-write-wins since we have normalized tables -function shouldAcceptOperation(operation: any, roomLastModified: number): boolean { - // Accept all operations - with normalized tables, conflicts are very unlikely - // We could add basic timestamp validation if needed, but for now just accept everything - return true -} - -// Enhanced authentication middleware -async function authenticateSocket(socket: AuthenticatedSocket, next: any) { - try { - // Extract authentication data from socket handshake - const token = socket.handshake.auth?.token - const origin = socket.handshake.headers.origin - const referer = socket.handshake.headers.referer - - logger.info(`Socket ${socket.id} authentication attempt:`, { - hasToken: !!token, - origin, - referer, - allHeaders: Object.keys(socket.handshake.headers), - }) +// Enhanced server configuration - HTTP server will be configured with handler after all dependencies are set up +const httpServer = createServer() - if (!token) { - logger.warn(`Socket ${socket.id} rejected: No authentication token found`) - return next(new Error('Authentication required')) - } +const io = createSocketIOServer(httpServer) - // Validate one-time token with Better Auth - try { - logger.debug(`Attempting token validation for socket ${socket.id}`, { - tokenLength: token?.length || 0, - origin, - }) +// Initialize room manager after io is created +const roomManager = new RoomManager(io) - const session = await auth.api.verifyOneTimeToken({ - body: { - token, - }, - }) - - if (!session?.user?.id) { - logger.warn(`Socket ${socket.id} rejected: Invalid token - no user found`) - return next(new Error('Invalid session')) - } - - // Store user info in socket for later use - socket.userId = session.user.id - socket.userName = session.user.name || session.user.email || 'Unknown User' - socket.userEmail = session.user.email - socket.activeOrganizationId = session.session.activeOrganizationId || undefined - - next() - } catch (tokenError) { - const errorMessage = tokenError instanceof Error ? tokenError.message : String(tokenError) - const errorStack = tokenError instanceof Error ? tokenError.stack : undefined - - logger.warn(`Token validation failed for socket ${socket.id}:`, { - error: errorMessage, - stack: errorStack, - origin, - referer, - }) - return next(new Error('Token validation failed')) - } - } catch (error) { - logger.error(`Socket authentication error for ${socket.id}:`, error) - next(new Error('Authentication failed')) - } -} - -// Apply authentication middleware io.use(authenticateSocket) -// Utility functions -async function verifyWorkspaceMembership( - userId: string, - workspaceId: string -): Promise { - try { - const membership = await db - .select({ role: workspaceMember.role }) - .from(workspaceMember) - .where(and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, userId))) - .limit(1) - - return membership.length > 0 ? membership[0].role : null - } catch (error) { - logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error) - return null - } -} -async function verifyWorkflowAccess( - userId: string, - workflowId: string -): Promise<{ hasAccess: boolean; role?: string; workspaceId?: string }> { - try { - const workflowData = await db - .select({ - userId: workflow.userId, - workspaceId: workflow.workspaceId, - name: workflow.name, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData.length) { - logger.warn(`Workflow ${workflowId} not found`) - return { hasAccess: false } - } - - const { userId: workflowUserId, workspaceId, name: workflowName } = workflowData[0] - - // Check if user owns the workflow - if (workflowUserId === userId) { - logger.debug(`User ${userId} has owner access to workflow ${workflowId} (${workflowName})`) - return { hasAccess: true, role: 'owner', workspaceId: workspaceId || undefined } - } - - // Check workspace membership if workflow belongs to a workspace - if (workspaceId) { - const userRole = await verifyWorkspaceMembership(userId, workspaceId) - if (userRole) { - logger.debug( - `User ${userId} has ${userRole} access to workflow ${workflowId} via workspace ${workspaceId}` - ) - return { hasAccess: true, role: userRole, workspaceId } - } - logger.warn( - `User ${userId} is not a member of workspace ${workspaceId} for workflow ${workflowId}` - ) - return { hasAccess: false } - } - - // Workflow doesn't belong to a workspace and user doesn't own it - logger.warn(`User ${userId} has no access to workflow ${workflowId} (no workspace, not owner)`) - return { hasAccess: false } - } catch (error) { - logger.error( - `Error verifying workflow access for user ${userId}, workflow ${workflowId}:`, - error - ) - return { hasAccess: false } - } -} - -// Enhanced authorization for specific operations -async function verifyOperationPermission( - userId: string, - workflowId: string, - operation: string, - target: string -): Promise<{ allowed: boolean; reason?: string }> { - try { - const accessInfo = await verifyWorkflowAccess(userId, workflowId) - - if (!accessInfo.hasAccess) { - return { allowed: false, reason: 'No access to workflow' } - } - - // Define operation permissions based on role - const rolePermissions = { - owner: [ - 'add', - 'remove', - 'update', - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'duplicate', - ], - admin: [ - 'add', - 'remove', - 'update', - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'duplicate', - ], - member: [ - 'add', - 'remove', - 'update', - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'duplicate', - ], - viewer: ['update-position'], // Viewers can only move things around - } - - const allowedOperations = rolePermissions[accessInfo.role as keyof typeof rolePermissions] || [] - - if (!allowedOperations.includes(operation)) { - return { - allowed: false, - reason: `Role '${accessInfo.role}' not permitted to perform '${operation}' on '${target}'`, - } - } - - return { allowed: true } - } catch (error) { - logger.error(`Error verifying operation permission:`, error) - return { allowed: false, reason: 'Permission check failed' } - } -} - -function createWorkflowRoom(workflowId: string): WorkflowRoom { - return { - workflowId, - users: new Map(), - lastModified: Date.now(), - activeConnections: 0, - } -} - -function cleanupUserFromRoom(socketId: string, workflowId: string) { - const room = workflowRooms.get(workflowId) - if (room) { - room.users.delete(socketId) - room.activeConnections = Math.max(0, room.activeConnections - 1) - - if (room.activeConnections === 0) { - workflowRooms.delete(workflowId) - logger.info(`Cleaned up empty workflow room: ${workflowId}`) - } - } - - socketToWorkflow.delete(socketId) - userSessions.delete(socketId) -} - -function clearPendingOperations(socketId: string) { - // Clear any pending operations for this socket - // This would be used if we implement operation queuing - logger.debug(`Cleared pending operations for socket ${socketId}`) -} - -// Handle workflow deletion notifications -function handleWorkflowDeletion(workflowId: string) { - logger.info(`Handling workflow deletion notification for ${workflowId}`) - - const room = workflowRooms.get(workflowId) - if (!room) { - logger.debug(`No active room found for deleted workflow ${workflowId}`) - return - } - - // Notify all users in the room that the workflow has been deleted - io.to(workflowId).emit('workflow-deleted', { - workflowId, - message: 'This workflow has been deleted', - timestamp: Date.now(), - }) - - // Disconnect all sockets from the workflow room - const socketsToDisconnect: string[] = [] - room.users.forEach((presence, socketId) => { - socketsToDisconnect.push(socketId) - }) - - // Clean up each socket connection - socketsToDisconnect.forEach((socketId) => { - const socket = io.sockets.sockets.get(socketId) - if (socket) { - socket.leave(workflowId) - logger.debug(`Disconnected socket ${socketId} from deleted workflow ${workflowId}`) - } - cleanupUserFromRoom(socketId, workflowId) - }) - - // Clean up the room completely - workflowRooms.delete(workflowId) - logger.info( - `Cleaned up workflow room ${workflowId} after deletion (${socketsToDisconnect.length} users disconnected)` - ) -} - -// Database helper functions -async function getWorkflowState(workflowId: string) { - try { - const workflowData = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData.length) { - throw new Error(`Workflow ${workflowId} not found`) - } - - return { - ...workflowData[0], - lastModified: Date.now(), - } - } catch (error) { - logger.error(`Error fetching workflow state for ${workflowId}:`, error) - throw error - } -} - -async function persistWorkflowOperation(workflowId: string, operation: any) { - // Use database transaction for consistency - try { - const { operation: op, target, payload, timestamp, userId } = operation - - await db.transaction(async (tx) => { - // Update the workflow's last modified timestamp first - await tx - .update(workflow) - .set({ updatedAt: new Date(timestamp) }) - .where(eq(workflow.id, workflowId)) - - // Handle different operation types within the transaction - switch (target) { - case 'block': - await handleBlockOperationTx(tx, workflowId, op, payload, userId) - break - case 'edge': - await handleEdgeOperationTx(tx, workflowId, op, payload, userId) - break - case 'subflow': - await handleSubflowOperationTx(tx, workflowId, op, payload, userId) - break - default: - throw new Error(`Unknown operation target: ${target}`) - } - }) - } catch (error) { - logger.error( - `❌ Error persisting workflow operation (${operation.operation} on ${operation.target}):`, - error - ) - throw error - } -} - -// Add data consistency validation -async function validateWorkflowConsistency( - workflowId: string -): Promise<{ valid: boolean; issues: string[] }> { - try { - const issues: string[] = [] - - // Check for orphaned edges (edges pointing to non-existent blocks) - const orphanedEdges = await db - .select({ - id: workflowEdges.id, - sourceBlockId: workflowEdges.sourceBlockId, - targetBlockId: workflowEdges.targetBlockId, - }) - .from(workflowEdges) - .leftJoin(workflowBlocks, eq(workflowEdges.sourceBlockId, workflowBlocks.id)) - .where( - and( - eq(workflowEdges.workflowId, workflowId), - isNull(workflowBlocks.id) // Source block doesn't exist - ) - ) - - if (orphanedEdges.length > 0) { - issues.push(`Found ${orphanedEdges.length} orphaned edges with missing source blocks`) - } - - // Could add more consistency checks here as needed - - return { valid: issues.length === 0, issues } - } catch (error) { - logger.error('Error validating workflow consistency:', error) - return { valid: false, issues: ['Consistency check failed'] } - } -} - -// Transaction-based operation handlers for data consistency -async function handleBlockOperationTx( - tx: any, - workflowId: string, - operation: string, - payload: any, - userId: string -) { - return handleBlockOperationImpl(tx, workflowId, operation, payload, userId) -} - -async function handleEdgeOperationTx( - tx: any, - workflowId: string, - operation: string, - payload: any, - userId: string -) { - return handleEdgeOperationImpl(tx, workflowId, operation, payload, userId) -} - -async function handleSubflowOperationTx( - tx: any, - workflowId: string, - operation: string, - payload: any, - userId: string -) { - return handleSubflowOperationImpl(tx, workflowId, operation, payload, userId) -} - -// Implementation functions that work with both db and transaction -async function handleEdgeOperationImpl( - dbOrTx: any, - workflowId: string, - operation: string, - payload: any, - userId: string -) { - // Move the existing handleEdgeOperation logic here - return handleEdgeOperation(workflowId, operation, payload, userId) -} - -async function handleSubflowOperationImpl( - dbOrTx: any, - workflowId: string, - operation: string, - payload: any, - userId: string -) { - try { - switch (operation) { - case 'add': - // Validate required fields - if (!payload.id || !payload.type || !payload.config) { - throw new Error('Missing required fields for add subflow operation') - } - - // Validate subflow type - if (!['loop', 'parallel'].includes(payload.type)) { - throw new Error(`Invalid subflow type: ${payload.type}`) - } - - // Validate config structure based on type - if (payload.type === 'loop') { - if (!payload.config.nodes || !Array.isArray(payload.config.nodes)) { - throw new Error('Loop subflow requires nodes array in config') - } - if (!payload.config.loopType || !['for', 'forEach'].includes(payload.config.loopType)) { - throw new Error('Loop subflow requires valid loopType (for or forEach)') - } - } else if (payload.type === 'parallel') { - if (!payload.config.nodes || !Array.isArray(payload.config.nodes)) { - throw new Error('Parallel subflow requires nodes array in config') - } - } - - await dbOrTx.insert(workflowSubflows).values({ - id: payload.id, - workflowId, - type: payload.type, - config: payload.config, - }) - - logger.debug(`Added ${payload.type} subflow ${payload.id} to workflow ${workflowId}`) - break - - case 'update': { - if (!payload.id || !payload.config) { - throw new Error('Missing required fields for update subflow operation') - } - - logger.debug(`[SERVER] Updating subflow ${payload.id} with config:`, payload.config) - - // Update the subflow configuration - const updateResult = await dbOrTx - .update(workflowSubflows) - .set({ - config: payload.config, - updatedAt: new Date(), - }) - .where( - and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId)) - ) - .returning({ id: workflowSubflows.id }) - - if (updateResult.length === 0) { - throw new Error(`Subflow ${payload.id} not found in workflow ${workflowId}`) - } - - // Also update the corresponding block's data to keep UI in sync - if (payload.type === 'loop' && payload.config.iterations !== undefined) { - // Update the loop block's data.count property - await dbOrTx - .update(workflowBlocks) - .set({ - data: { - ...payload.config, - count: payload.config.iterations, - loopType: payload.config.loopType, - collection: payload.config.forEachItems, - width: 500, - height: 300, - type: 'loopNode', - }, - updatedAt: new Date(), - }) - .where( - and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)) - ) - } else if (payload.type === 'parallel') { - // Update the parallel block's data properties - const blockData = { - ...payload.config, - width: 500, - height: 300, - type: 'parallelNode', - } - - // Include count if provided - if (payload.config.count !== undefined) { - blockData.count = payload.config.count - } - - // Include collection if provided - if (payload.config.distribution !== undefined) { - blockData.collection = payload.config.distribution - } - - // Include parallelType if provided - if (payload.config.parallelType !== undefined) { - blockData.parallelType = payload.config.parallelType - } - - await dbOrTx - .update(workflowBlocks) - .set({ - data: blockData, - updatedAt: new Date(), - }) - .where( - and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)) - ) - } - - break - } - - case 'remove': { - if (!payload.id) { - throw new Error('Missing subflow ID for remove operation') - } - - const deleteResult = await dbOrTx - .delete(workflowSubflows) - .where( - and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId)) - ) - .returning({ id: workflowSubflows.id }) - - if (deleteResult.length === 0) { - throw new Error(`Subflow ${payload.id} not found in workflow ${workflowId}`) - } - - logger.debug(`Removed subflow ${payload.id} from workflow ${workflowId}`) - break - } - - default: - logger.warn(`Unknown subflow operation: ${operation}`) - throw new Error(`Unsupported subflow operation: ${operation}`) - } - } catch (error) { - logger.error(`Error in handleSubflowOperation (${operation}):`, error) - throw error - } -} - -// Enhanced operation handlers with comprehensive validation -async function handleBlockOperation( - workflowId: string, - operation: string, - payload: any, - userId: string -) { - return handleBlockOperationImpl(db, workflowId, operation, payload, userId) -} - -async function handleBlockOperationImpl( - dbOrTx: any, - workflowId: string, - operation: string, - payload: any, - userId: string -) { - try { - switch (operation) { - case 'add': { - // Validate required fields for add operation - if (!payload.id || !payload.type || !payload.name || !payload.position) { - throw new Error('Missing required fields for add block operation') - } - - logger.debug(`[SERVER] Adding block: ${payload.type} (${payload.id})`, { - isSubflowType: isSubflowBlockType(payload.type), - payload, - }) - - // Extract parentId and extent from payload.data if they exist there, otherwise from payload directly - const parentId = payload.parentId || payload.data?.parentId || null - const extent = payload.extent || payload.data?.extent || null - - logger.debug(`[SERVER] Block parent info:`, { - blockId: payload.id, - hasParent: !!parentId, - parentId, - extent, - payloadParentId: payload.parentId, - dataParentId: payload.data?.parentId, - }) - - await dbOrTx.insert(workflowBlocks).values({ - id: payload.id, - workflowId, - type: payload.type, - name: payload.name, - positionX: payload.position.x, - positionY: payload.position.y, - data: payload.data || {}, - parentId, - extent, - enabled: true, // Default to enabled - }) - - // Auto-create subflow entry for loop/parallel blocks - if (isSubflowBlockType(payload.type)) { - try { - const subflowConfig = - payload.type === SubflowType.LOOP - ? { - id: payload.id, - nodes: [], // Empty initially, will be populated when child blocks are added - iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS, - loopType: payload.data?.loopType || 'for', - forEachItems: payload.data?.collection || '', - } - : { - id: payload.id, - nodes: [], // Empty initially, will be populated when child blocks are added - distribution: payload.data?.collection || '', - } - - logger.debug( - `[SERVER] Auto-creating ${payload.type} subflow ${payload.id}:`, - subflowConfig - ) - - await dbOrTx.insert(workflowSubflows).values({ - id: payload.id, - workflowId, - type: payload.type, - config: subflowConfig, - }) - } catch (subflowError) { - logger.error( - `[SERVER] ❌ Failed to create ${payload.type} subflow ${payload.id}:`, - subflowError - ) - throw subflowError - } - } - - // If this block has a parent, update the parent's subflow node list - if (parentId) { - await updateSubflowNodeList(dbOrTx, workflowId, parentId) - } - - logger.debug(`Added block ${payload.id} (${payload.type}) to workflow ${workflowId}`) - break - } - - case 'update-position': { - if (!payload.id || !payload.position) { - throw new Error('Missing required fields for update position operation') - } - - const updateResult = await dbOrTx - .update(workflowBlocks) - .set({ - positionX: payload.position.x, - positionY: payload.position.y, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - .returning({ id: workflowBlocks.id }) - - if (updateResult.length === 0) { - throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) - } - break - } - - case 'update-name': - if (!payload.id || !payload.name) { - throw new Error('Missing required fields for update name operation') - } - - await db - .update(workflowBlocks) - .set({ - name: payload.name, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - break - - case 'update-parent': { - if (!payload.id) { - throw new Error('Missing block ID for update parent operation') - } - - // Get the current parent before updating - const currentBlock = await dbOrTx - .select({ parentId: workflowBlocks.parentId }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) +const httpHandler = createHttpHandler(roomManager, logger) +httpServer.on('request', httpHandler) - const oldParentId = currentBlock.length > 0 ? currentBlock[0].parentId : null - - await dbOrTx - .update(workflowBlocks) - .set({ - parentId: payload.parentId || null, - extent: payload.extent || null, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - - // Update subflow node lists for both old and new parents - if (oldParentId) { - await updateSubflowNodeList(dbOrTx, workflowId, oldParentId) - } - if (payload.parentId && payload.parentId !== oldParentId) { - await updateSubflowNodeList(dbOrTx, workflowId, payload.parentId) - } - break - } - - case 'remove': { - if (!payload.id) { - throw new Error('Missing block ID for remove operation') - } - - // Check if this is a subflow block that needs cascade deletion - const blockToRemove = await dbOrTx - .select({ type: workflowBlocks.type, parentId: workflowBlocks.parentId }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) { - // Cascade delete: Remove all child blocks first - const childBlocks = await dbOrTx - .select({ id: workflowBlocks.id, type: workflowBlocks.type }) - .from(workflowBlocks) - .where( - and( - eq(workflowBlocks.workflowId, workflowId), - eq(workflowBlocks.parentId, payload.id) - ) - ) - - logger.debug( - `[SERVER] Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})` - ) - logger.debug( - `[SERVER] Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]` - ) - - // Remove edges connected to child blocks - for (const childBlock of childBlocks) { - await dbOrTx - .delete(workflowEdges) - .where( - and( - eq(workflowEdges.workflowId, workflowId), - or( - eq(workflowEdges.sourceBlockId, childBlock.id), - eq(workflowEdges.targetBlockId, childBlock.id) - ) - ) - ) - } - - // Remove child blocks from database - await dbOrTx - .delete(workflowBlocks) - .where( - and( - eq(workflowBlocks.workflowId, workflowId), - eq(workflowBlocks.parentId, payload.id) - ) - ) - - // Remove the subflow entry - await dbOrTx - .delete(workflowSubflows) - .where( - and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId)) - ) - } - - // Remove any edges connected to this block - await dbOrTx - .delete(workflowEdges) - .where( - and( - eq(workflowEdges.workflowId, workflowId), - or( - eq(workflowEdges.sourceBlockId, payload.id), - eq(workflowEdges.targetBlockId, payload.id) - ) - ) - ) - - // Finally remove the block itself - await dbOrTx - .delete(workflowBlocks) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - - // If this block had a parent, update the parent's subflow node list - if (blockToRemove.length > 0 && blockToRemove[0].parentId) { - await updateSubflowNodeList(dbOrTx, workflowId, blockToRemove[0].parentId) - } - - logger.debug(`Removed block ${payload.id} and its connections from workflow ${workflowId}`) - break - } - - case 'toggle-enabled': - if (!payload.id || payload.enabled === undefined) { - throw new Error('Missing required fields for toggle enabled operation') - } - - await db - .update(workflowBlocks) - .set({ - enabled: payload.enabled, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - break - - case 'duplicate': { - if (!payload.id || !payload.newId || !payload.position) { - throw new Error('Missing required fields for duplicate operation') - } - - // Get the original block - const originalBlock = await db - .select() - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (originalBlock.length === 0) { - throw new Error(`Original block ${payload.id} not found`) - } - - // Create duplicate with new ID and position - await db.insert(workflowBlocks).values({ - ...originalBlock[0], - id: payload.newId, - name: `${originalBlock[0].name} (Copy)`, - positionX: payload.position.x, - positionY: payload.position.y, - createdAt: new Date(), - updatedAt: new Date(), - }) - break - } - - default: - logger.warn(`Unknown block operation: ${operation}`) - throw new Error(`Unsupported block operation: ${operation}`) - } - } catch (error) { - logger.error(`Error in handleBlockOperation (${operation}):`, error) - throw error - } -} - -async function handleEdgeOperation( - workflowId: string, - operation: string, - payload: any, - userId: string -) { - try { - switch (operation) { - case 'add': { - // Validate required fields - if (!payload.id || !payload.source || !payload.target) { - throw new Error('Missing required fields for add edge operation') - } - - // Check if source and target blocks exist - const sourceBlock = await db - .select({ id: workflowBlocks.id }) - .from(workflowBlocks) - .where( - and(eq(workflowBlocks.id, payload.source), eq(workflowBlocks.workflowId, workflowId)) - ) - .limit(1) - - const targetBlock = await db - .select({ id: workflowBlocks.id }) - .from(workflowBlocks) - .where( - and(eq(workflowBlocks.id, payload.target), eq(workflowBlocks.workflowId, workflowId)) - ) - .limit(1) - - if (sourceBlock.length === 0) { - // For new workflows, blocks might not be persisted yet - log warning but don't fail - logger.warn( - `Source block ${payload.source} not found in database - may be a new workflow` - ) - throw new Error(`Source block ${payload.source} not found`) - } - if (targetBlock.length === 0) { - logger.warn( - `Target block ${payload.target} not found in database - may be a new workflow` - ) - throw new Error(`Target block ${payload.target} not found`) - } - - // Check for duplicate edges - const existingEdge = await db - .select({ id: workflowEdges.id }) - .from(workflowEdges) - .where( - and( - eq(workflowEdges.workflowId, workflowId), - eq(workflowEdges.sourceBlockId, payload.source), - eq(workflowEdges.targetBlockId, payload.target), - eq(workflowEdges.sourceHandle, payload.sourceHandle || ''), - eq(workflowEdges.targetHandle, payload.targetHandle || '') - ) - ) - .limit(1) - - if (existingEdge.length > 0) { - logger.warn(`Duplicate edge detected: ${payload.source} -> ${payload.target}`) - return // Skip duplicate edge creation - } - - await db.insert(workflowEdges).values({ - id: payload.id, - workflowId, - sourceBlockId: payload.source, - targetBlockId: payload.target, - sourceHandle: payload.sourceHandle || null, - targetHandle: payload.targetHandle || null, - }) - - logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`) - break - } - - case 'remove': { - if (!payload.id) { - throw new Error('Missing edge ID for remove operation') - } - - const deleteResult = await db - .delete(workflowEdges) - .where(and(eq(workflowEdges.id, payload.id), eq(workflowEdges.workflowId, workflowId))) - .returning({ id: workflowEdges.id }) - - if (deleteResult.length === 0) { - throw new Error(`Edge ${payload.id} not found in workflow ${workflowId}`) - } - - logger.debug(`Removed edge ${payload.id} from workflow ${workflowId}`) - break - } - - default: - logger.warn(`Unknown edge operation: ${operation}`) - throw new Error(`Unsupported edge operation: ${operation}`) - } - } catch (error) { - logger.error(`Error in handleEdgeOperation (${operation}):`, error) - throw error - } -} - -// Global error handling process.on('uncaughtException', (error) => { logger.error('Uncaught Exception:', error) // Don't exit in production, just log @@ -1249,7 +30,6 @@ process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', promise, 'reason:', reason) }) -// Socket server error handling httpServer.on('error', (error) => { logger.error('HTTP server error:', error) }) @@ -1264,517 +44,11 @@ io.engine.on('connection_error', (err) => { }) io.on('connection', (socket: AuthenticatedSocket) => { - // Set up error handling for this socket - socket.on('error', (error) => { - logger.error(`Socket ${socket.id} error:`, error) - }) - - socket.conn.on('error', (error) => { - logger.error(`Socket ${socket.id} connection error:`, error) - }) + logger.info(`New socket connection: ${socket.id}`) - // Handle joining a workflow room with enhanced authentication - socket.on('join-workflow', async ({ workflowId }) => { - try { - // Use authenticated user info from socket - const userId = socket.userId - const userName = socket.userName - - if (!userId || !userName) { - logger.warn(`Join workflow rejected: Socket ${socket.id} not authenticated`) - socket.emit('join-workflow-error', { error: 'Authentication required' }) - return - } - - logger.info(`Join workflow request from ${userId} (${userName}) for workflow ${workflowId}`) - - // Verify workflow access - try { - const accessInfo = await verifyWorkflowAccess(userId, workflowId) - if (!accessInfo.hasAccess) { - logger.warn(`User ${userId} (${userName}) denied access to workflow ${workflowId}`) - socket.emit('join-workflow-error', { error: 'Access denied to workflow' }) - return - } - } catch (error) { - logger.warn(`Error verifying workflow access for ${userId}:`, error) - socket.emit('join-workflow-error', { error: 'Failed to verify workflow access' }) - return - } - - // Leave any previous workflow room - const currentWorkflowId = socketToWorkflow.get(socket.id) - if (currentWorkflowId) { - socket.leave(currentWorkflowId) - cleanupUserFromRoom(socket.id, currentWorkflowId) - - // Notify previous room about user leaving - socket.to(currentWorkflowId).emit('user-left', { - userId, - socketId: socket.id, - }) - } - - // Join the new workflow room - socket.join(workflowId) - - // Create or get workflow room - if (!workflowRooms.has(workflowId)) { - workflowRooms.set(workflowId, createWorkflowRoom(workflowId)) - } - - const room = workflowRooms.get(workflowId)! - room.activeConnections++ - - // Store user presence - const userPresence: UserPresence = { - userId, - workflowId, - userName, - socketId: socket.id, - joinedAt: Date.now(), - lastActivity: Date.now(), - } - - room.users.set(socket.id, userPresence) - socketToWorkflow.set(socket.id, workflowId) - userSessions.set(socket.id, { userId, userName }) - - // Get current room presence for the new user - const roomPresence = Array.from(room.users.values()) - - // Send current workflow state and presence to the new user - const workflowState = await getWorkflowState(workflowId) - socket.emit('workflow-state', workflowState) - socket.emit('presence-update', roomPresence) - - // Notify others in the room about new user - socket.to(workflowId).emit('user-joined', { - userId, - userName, - socketId: socket.id, - }) - - logger.info( - `User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${room.activeConnections} users.` - ) - } catch (error) { - logger.error('Error joining workflow:', error) - socket.emit('error', { - type: 'JOIN_ERROR', - message: 'Failed to join workflow', - }) - } - }) - - // Handle workflow operations (blocks, edges, subflows) with enhanced validation and conflict resolution - socket.on('workflow-operation', async (data) => { - const workflowId = socketToWorkflow.get(socket.id) - const session = userSessions.get(socket.id) - - if (!workflowId || !session) { - socket.emit('error', { - type: 'NOT_JOINED', - message: 'Not joined to any workflow', - }) - return - } - - const room = workflowRooms.get(workflowId) - if (!room) { - socket.emit('error', { - type: 'ROOM_NOT_FOUND', - message: 'Workflow room not found', - }) - return - } - - try { - // Validate operation schema - const validatedOperation = WorkflowOperationSchema.parse(data) - const { operation, target, payload, timestamp } = validatedOperation - - // Check if operation should be accepted (simplified conflict resolution) - if (!shouldAcceptOperation(validatedOperation, room.lastModified)) { - socket.emit('operation-rejected', { - type: 'OPERATION_REJECTED', - message: 'Operation rejected', - operation, - target, - serverTimestamp: Date.now(), - }) - return - } - - // Check operation permissions (temporarily bypassed for testing) - const permissionCheck = await verifyOperationPermission( - session.userId, - workflowId, - operation, - target - ) - if (!permissionCheck.allowed) { - logger.warn( - `User ${session.userId} forbidden from ${operation} on ${target}: ${permissionCheck.reason}` - ) - socket.emit('operation-forbidden', { - type: 'INSUFFICIENT_PERMISSIONS', - message: permissionCheck.reason || 'Insufficient permissions for this operation', - operation, - target, - }) - return - } - - // Update user activity - const userPresence = room.users.get(socket.id) - if (userPresence) { - userPresence.lastActivity = Date.now() - } - - // Persist to database with transaction (last-write-wins) - const serverTimestamp = Date.now() - await persistWorkflowOperation(workflowId, { - operation, - target, - payload, - timestamp: serverTimestamp, // Use server timestamp for consistency - userId: session.userId, - }) - - // Update room's last modified timestamp - room.lastModified = serverTimestamp - - // Broadcast to all other clients in the room (excluding sender) - const broadcastData = { - operation, - target, - payload, - timestamp: serverTimestamp, - senderId: socket.id, - userId: session.userId, - userName: session.userName, - // Add operation metadata for better client handling - metadata: { - workflowId, - operationId: crypto.randomUUID(), // Unique operation ID for tracking - }, - } - - socket.to(workflowId).emit('workflow-operation', broadcastData) - - // Send confirmation back to sender with operation ID for tracking - socket.emit('operation-confirmed', { - operation, - target, - operationId: broadcastData.metadata.operationId, - serverTimestamp, - }) - } catch (error) { - if (error instanceof z.ZodError) { - socket.emit('operation-error', { - type: 'VALIDATION_ERROR', - message: 'Invalid operation data', - errors: error.errors, - operation: data.operation, - target: data.target, - }) - logger.warn(`Validation error for operation from ${session.userId}:`, error.errors) - } else if (error instanceof Error) { - // Handle specific database errors - if (error.message.includes('not found')) { - socket.emit('operation-error', { - type: 'RESOURCE_NOT_FOUND', - message: error.message, - operation: data.operation, - target: data.target, - }) - } else if (error.message.includes('duplicate') || error.message.includes('unique')) { - socket.emit('operation-error', { - type: 'DUPLICATE_RESOURCE', - message: 'Resource already exists', - operation: data.operation, - target: data.target, - }) - } else { - socket.emit('operation-error', { - type: 'OPERATION_FAILED', - message: error.message, - operation: data.operation, - target: data.target, - }) - } - logger.error( - `Operation error for ${session.userId} (${data.operation} on ${data.target}):`, - error - ) - } else { - socket.emit('operation-error', { - type: 'UNKNOWN_ERROR', - message: 'An unknown error occurred', - operation: data.operation, - target: data.target, - }) - logger.error('Unknown error handling workflow operation:', error) - } - } - }) - - // Handle subblock value updates - socket.on('subblock-update', async (data) => { - const workflowId = socketToWorkflow.get(socket.id) - const session = userSessions.get(socket.id) - - if (!workflowId || !session) { - logger.debug(`Ignoring subblock update: socket not connected to any workflow room`, { - socketId: socket.id, - hasWorkflowId: !!workflowId, - hasSession: !!session, - }) - return - } - - const { blockId, subblockId, value, timestamp } = data - const room = workflowRooms.get(workflowId) - - if (!room) { - logger.debug(`Ignoring subblock update: workflow room not found`, { - socketId: socket.id, - workflowId, - blockId, - subblockId, - }) - return - } - - try { - // Update user activity - const userPresence = room.users.get(socket.id) - if (userPresence) { - userPresence.lastActivity = Date.now() - } - - // First, verify that the workflow still exists in the database - const workflowExists = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (workflowExists.length === 0) { - logger.warn(`Ignoring subblock update: workflow ${workflowId} no longer exists`, { - socketId: socket.id, - blockId, - subblockId, - }) - // Clean up the socket from this non-existent workflow - cleanupUserFromRoom(socket.id, workflowId) - return - } - - // Persist subblock update to database - let updateSuccessful = false - await db.transaction(async (tx) => { - // Get the current block subBlocks data - const [block] = await tx - .select({ subBlocks: workflowBlocks.subBlocks }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (!block) { - // Block was deleted - this is a normal race condition in collaborative editing - // Log it as debug info and gracefully ignore the update - logger.debug( - `Ignoring subblock update for deleted block: ${workflowId}/${blockId}.${subblockId}` - ) - return // Exit transaction gracefully without error - } - - // Parse the current subBlocks data - const subBlocks = (block.subBlocks as any) || {} - - // Update the subblock value in the subBlocks data - if (!subBlocks[subblockId]) { - subBlocks[subblockId] = {} - } - subBlocks[subblockId].value = value - - // Save the updated subBlocks data back to the database - await tx - .update(workflowBlocks) - .set({ - subBlocks: subBlocks, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - - updateSuccessful = true - }) - - // Only broadcast to other clients if the update was successful - if (updateSuccessful) { - socket.to(workflowId).emit('subblock-update', { - blockId, - subblockId, - value, - timestamp, - senderId: socket.id, - userId: session.userId, - }) - } - - logger.debug(`Subblock update in workflow ${workflowId}: ${blockId}.${subblockId}`) - } catch (error) { - logger.error('Error handling subblock update:', error) - - // Send error back to client - socket.emit('operation-error', { - type: 'SUBBLOCK_UPDATE_FAILED', - message: `Failed to update subblock ${blockId}.${subblockId}: ${error instanceof Error ? error.message : 'Unknown error'}`, - operation: 'subblock-update', - target: 'subblock', - }) - } - }) - - // Handle cursor/presence updates - socket.on('cursor-update', ({ cursor }) => { - const workflowId = socketToWorkflow.get(socket.id) - const session = userSessions.get(socket.id) - - if (!workflowId || !session) return - - const room = workflowRooms.get(workflowId) - if (!room) return - - // Update stored cursor position - const userPresence = room.users.get(socket.id) - if (userPresence) { - userPresence.cursor = cursor - userPresence.lastActivity = Date.now() - } - - // Broadcast cursor position to others in the room - socket.to(workflowId).emit('cursor-update', { - socketId: socket.id, - userId: session.userId, - userName: session.userName, - cursor, - }) - }) - - // Handle user selection (for showing what block/element a user has selected) - socket.on('selection-update', ({ selection }) => { - const workflowId = socketToWorkflow.get(socket.id) - const session = userSessions.get(socket.id) - - if (!workflowId || !session) return - - const room = workflowRooms.get(workflowId) - if (!room) return - - // Update stored selection - const userPresence = room.users.get(socket.id) - if (userPresence) { - userPresence.selection = selection - userPresence.lastActivity = Date.now() - } - - socket.to(workflowId).emit('selection-update', { - socketId: socket.id, - userId: session.userId, - userName: session.userName, - selection, // { type: 'block' | 'edge' | 'none', id?: string } - }) - }) - - // Handle disconnect with enhanced cleanup and recovery - socket.on('disconnect', (reason) => { - const workflowId = socketToWorkflow.get(socket.id) - const session = userSessions.get(socket.id) - - logger.info(`Socket ${socket.id} disconnected: ${reason}`) - - if (workflowId && session) { - // Clean up user from room - cleanupUserFromRoom(socket.id, workflowId) - - // Notify others in the room - socket.to(workflowId).emit('user-left', { - userId: session.userId, - socketId: socket.id, - reason: reason, - }) - - logger.info( - `User ${session.userId} (${session.userName}) disconnected from workflow ${workflowId} - reason: ${reason}` - ) - } - - // Clear any pending operations for this socket - clearPendingOperations(socket.id) - }) - - // Handle connection errors - socket.on('error', (error) => { - logger.error(`Socket ${socket.id} error:`, error) - const session = userSessions.get(socket.id) - if (session) { - logger.error(`Error for user ${session.userId} (${session.userName}):`, error) - } - }) - - // Handle ping/pong for connection health - socket.on('ping', () => { - socket.emit('pong') - }) - - // Handle manual reconnection requests - socket.on('request-sync', async ({ workflowId }) => { - try { - if (!socket.userId) { - socket.emit('error', { type: 'NOT_AUTHENTICATED', message: 'Not authenticated' }) - return - } - - const accessInfo = await verifyWorkflowAccess(socket.userId, workflowId) - if (!accessInfo.hasAccess) { - socket.emit('error', { type: 'ACCESS_DENIED', message: 'Access denied' }) - return - } - - // Send current workflow state - const workflowState = await getWorkflowState(workflowId) - socket.emit('workflow-state', workflowState) - - logger.info(`Sent sync data to ${socket.userId} for workflow ${workflowId}`) - } catch (error) { - logger.error('Error handling sync request:', error) - socket.emit('error', { type: 'SYNC_FAILED', message: 'Failed to sync workflow state' }) - } - }) - - // Handle explicit leave workflow - socket.on('leave-workflow', () => { - const workflowId = socketToWorkflow.get(socket.id) - const session = userSessions.get(socket.id) - - if (workflowId && session) { - socket.leave(workflowId) - cleanupUserFromRoom(socket.id, workflowId) - - socket.to(workflowId).emit('user-left', { - userId: session.userId, - socketId: socket.id, - }) - - logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`) - } - }) + setupAllHandlers(socket, roomManager) }) -// Add detailed request logging httpServer.on('request', (req, res) => { logger.info(`🌐 HTTP Request: ${req.method} ${req.url}`, { method: req.method, @@ -1786,7 +60,6 @@ httpServer.on('request', (req, res) => { }) }) -// Enhanced connection logging io.engine.on('connection_error', (err) => { logger.error('❌ Engine.IO Connection error:', { code: err.code, @@ -1802,7 +75,6 @@ io.engine.on('connection_error', (err) => { }) }) -// Start the server const PORT = Number(process.env.PORT || process.env.SOCKET_PORT || 3002) logger.info('Starting Socket.IO server...', { @@ -1822,7 +94,6 @@ httpServer.on('error', (error) => { process.exit(1) }) -// Graceful shutdown process.on('SIGINT', () => { logger.info('Shutting down Socket.IO server...') httpServer.close(() => { diff --git a/apps/sim/socket-server/middleware/auth.ts b/apps/sim/socket-server/middleware/auth.ts new file mode 100644 index 00000000000..1611b9d0855 --- /dev/null +++ b/apps/sim/socket-server/middleware/auth.ts @@ -0,0 +1,76 @@ +import type { Socket } from 'socket.io' +import { auth } from '../../lib/auth' +import { createLogger } from '../../lib/logs/console-logger' + +const logger = createLogger('SocketAuth') + +// Extend Socket interface to include user data +export interface AuthenticatedSocket extends Socket { + userId?: string + userName?: string + userEmail?: string + activeOrganizationId?: string +} + +// Enhanced authentication middleware +export async function authenticateSocket(socket: AuthenticatedSocket, next: any) { + try { + // Extract authentication data from socket handshake + const token = socket.handshake.auth?.token + const origin = socket.handshake.headers.origin + const referer = socket.handshake.headers.referer + + logger.info(`Socket ${socket.id} authentication attempt:`, { + hasToken: !!token, + origin, + referer, + allHeaders: Object.keys(socket.handshake.headers), + }) + + if (!token) { + logger.warn(`Socket ${socket.id} rejected: No authentication token found`) + return next(new Error('Authentication required')) + } + + // Validate one-time token with Better Auth + try { + logger.debug(`Attempting token validation for socket ${socket.id}`, { + tokenLength: token?.length || 0, + origin, + }) + + const session = await auth.api.verifyOneTimeToken({ + body: { + token, + }, + }) + + if (!session?.user?.id) { + logger.warn(`Socket ${socket.id} rejected: Invalid token - no user found`) + return next(new Error('Invalid session')) + } + + // Store user info in socket for later use + socket.userId = session.user.id + socket.userName = session.user.name || session.user.email || 'Unknown User' + socket.userEmail = session.user.email + socket.activeOrganizationId = session.session.activeOrganizationId || undefined + + next() + } catch (tokenError) { + const errorMessage = tokenError instanceof Error ? tokenError.message : String(tokenError) + const errorStack = tokenError instanceof Error ? tokenError.stack : undefined + + logger.warn(`Token validation failed for socket ${socket.id}:`, { + error: errorMessage, + stack: errorStack, + origin, + referer, + }) + return next(new Error('Token validation failed')) + } + } catch (error) { + logger.error(`Socket authentication error for ${socket.id}:`, error) + next(new Error('Authentication failed')) + } +} diff --git a/apps/sim/socket-server/middleware/permissions.ts b/apps/sim/socket-server/middleware/permissions.ts new file mode 100644 index 00000000000..4d4044b73e6 --- /dev/null +++ b/apps/sim/socket-server/middleware/permissions.ts @@ -0,0 +1,150 @@ +import { and, eq } from 'drizzle-orm' +import { db } from '../../db' +import { workflow, workspaceMember } from '../../db/schema' +import { createLogger } from '../../lib/logs/console-logger' + +const logger = createLogger('SocketPermissions') + +export async function verifyWorkspaceMembership( + userId: string, + workspaceId: string +): Promise { + try { + const membership = await db + .select({ role: workspaceMember.role }) + .from(workspaceMember) + .where(and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, userId))) + .limit(1) + + return membership.length > 0 ? membership[0].role : null + } catch (error) { + logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error) + return null + } +} + +export async function verifyWorkflowAccess( + userId: string, + workflowId: string +): Promise<{ hasAccess: boolean; role?: string; workspaceId?: string }> { + try { + const workflowData = await db + .select({ + userId: workflow.userId, + workspaceId: workflow.workspaceId, + name: workflow.name, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowData.length) { + logger.warn(`Workflow ${workflowId} not found`) + return { hasAccess: false } + } + + const { userId: workflowUserId, workspaceId, name: workflowName } = workflowData[0] + + // Check if user owns the workflow + if (workflowUserId === userId) { + logger.debug(`User ${userId} has owner access to workflow ${workflowId} (${workflowName})`) + return { hasAccess: true, role: 'owner', workspaceId: workspaceId || undefined } + } + + // Check workspace membership if workflow belongs to a workspace + if (workspaceId) { + const userRole = await verifyWorkspaceMembership(userId, workspaceId) + if (userRole) { + logger.debug( + `User ${userId} has ${userRole} access to workflow ${workflowId} via workspace ${workspaceId}` + ) + return { hasAccess: true, role: userRole, workspaceId } + } + logger.warn( + `User ${userId} is not a member of workspace ${workspaceId} for workflow ${workflowId}` + ) + return { hasAccess: false } + } + + // Workflow doesn't belong to a workspace and user doesn't own it + logger.warn(`User ${userId} has no access to workflow ${workflowId} (no workspace, not owner)`) + return { hasAccess: false } + } catch (error) { + logger.error( + `Error verifying workflow access for user ${userId}, workflow ${workflowId}:`, + error + ) + return { hasAccess: false } + } +} + +// Enhanced authorization for specific operations +export async function verifyOperationPermission( + userId: string, + workflowId: string, + operation: string, + target: string +): Promise<{ allowed: boolean; reason?: string }> { + try { + const accessInfo = await verifyWorkflowAccess(userId, workflowId) + + if (!accessInfo.hasAccess) { + return { allowed: false, reason: 'No access to workflow' } + } + + // Define operation permissions based on role + const rolePermissions = { + owner: [ + 'add', + 'remove', + 'update', + 'update-position', + 'update-name', + 'toggle-enabled', + 'update-parent', + 'update-wide', + 'update-advanced-mode', + 'duplicate', + ], + admin: [ + 'add', + 'remove', + 'update', + 'update-position', + 'update-name', + 'toggle-enabled', + 'update-parent', + 'update-wide', + 'update-advanced-mode', + 'duplicate', + ], + member: [ + 'add', + 'remove', + 'update', + 'update-position', + 'update-name', + 'toggle-enabled', + 'update-parent', + 'update-wide', + 'update-advanced-mode', + 'duplicate', + ], + viewer: ['update-position'], // Viewers can only move things around + } + + const allowedOperations = rolePermissions[accessInfo.role as keyof typeof rolePermissions] || [] + + if (!allowedOperations.includes(operation)) { + return { + allowed: false, + reason: `Role '${accessInfo.role}' not permitted to perform '${operation}' on '${target}'`, + } + } + + return { allowed: true } + } catch (error) { + logger.error(`Error verifying operation permission:`, error) + return { allowed: false, reason: 'Permission check failed' } + } +} diff --git a/apps/sim/socket-server/rooms/manager.ts b/apps/sim/socket-server/rooms/manager.ts new file mode 100644 index 00000000000..c9a5efc3c3a --- /dev/null +++ b/apps/sim/socket-server/rooms/manager.ts @@ -0,0 +1,183 @@ +import { and, eq, isNull } from 'drizzle-orm' +import type { Server } from 'socket.io' +import { db } from '../../db' +import { workflowBlocks, workflowEdges } from '../../db/schema' +import { createLogger } from '../../lib/logs/console-logger' + +const logger = createLogger('RoomManager') + +export interface UserPresence { + userId: string + workflowId: string + userName: string + socketId: string + joinedAt: number + lastActivity: number + cursor?: { x: number; y: number } + selection?: { type: 'block' | 'edge' | 'none'; id?: string } +} + +export interface WorkflowRoom { + workflowId: string + users: Map // socketId -> UserPresence + lastModified: number + activeConnections: number +} + +export class RoomManager { + private workflowRooms = new Map() + private socketToWorkflow = new Map() + private userSessions = new Map() + private io: Server + + constructor(io: Server) { + this.io = io + } + + createWorkflowRoom(workflowId: string): WorkflowRoom { + return { + workflowId, + users: new Map(), + lastModified: Date.now(), + activeConnections: 0, + } + } + + cleanupUserFromRoom(socketId: string, workflowId: string) { + const room = this.workflowRooms.get(workflowId) + if (room) { + room.users.delete(socketId) + room.activeConnections = Math.max(0, room.activeConnections - 1) + + if (room.activeConnections === 0) { + this.workflowRooms.delete(workflowId) + logger.info(`Cleaned up empty workflow room: ${workflowId}`) + } + } + + this.socketToWorkflow.delete(socketId) + this.userSessions.delete(socketId) + } + + // This would be used if we implement operation queuing + clearPendingOperations(socketId: string) { + logger.debug(`Cleared pending operations for socket ${socketId}`) + } + + handleWorkflowDeletion(workflowId: string) { + logger.info(`Handling workflow deletion notification for ${workflowId}`) + + const room = this.workflowRooms.get(workflowId) + if (!room) { + logger.debug(`No active room found for deleted workflow ${workflowId}`) + return + } + + this.io.to(workflowId).emit('workflow-deleted', { + workflowId, + message: 'This workflow has been deleted', + timestamp: Date.now(), + }) + + const socketsToDisconnect: string[] = [] + room.users.forEach((presence, socketId) => { + socketsToDisconnect.push(socketId) + }) + + socketsToDisconnect.forEach((socketId) => { + const socket = this.io.sockets.sockets.get(socketId) + if (socket) { + socket.leave(workflowId) + logger.debug(`Disconnected socket ${socketId} from deleted workflow ${workflowId}`) + } + this.cleanupUserFromRoom(socketId, workflowId) + }) + + this.workflowRooms.delete(workflowId) + logger.info( + `Cleaned up workflow room ${workflowId} after deletion (${socketsToDisconnect.length} users disconnected)` + ) + } + + async validateWorkflowConsistency( + workflowId: string + ): Promise<{ valid: boolean; issues: string[] }> { + try { + const issues: string[] = [] + + const orphanedEdges = await db + .select({ + id: workflowEdges.id, + sourceBlockId: workflowEdges.sourceBlockId, + targetBlockId: workflowEdges.targetBlockId, + }) + .from(workflowEdges) + .leftJoin(workflowBlocks, eq(workflowEdges.sourceBlockId, workflowBlocks.id)) + .where(and(eq(workflowEdges.workflowId, workflowId), isNull(workflowBlocks.id))) + + if (orphanedEdges.length > 0) { + issues.push(`Found ${orphanedEdges.length} orphaned edges with missing source blocks`) + } + + return { valid: issues.length === 0, issues } + } catch (error) { + logger.error('Error validating workflow consistency:', error) + return { valid: false, issues: ['Consistency check failed'] } + } + } + + getWorkflowRooms(): ReadonlyMap { + return this.workflowRooms + } + + getSocketToWorkflow(): ReadonlyMap { + return this.socketToWorkflow + } + + getUserSessions(): ReadonlyMap { + return this.userSessions + } + + hasWorkflowRoom(workflowId: string): boolean { + return this.workflowRooms.has(workflowId) + } + + getWorkflowRoom(workflowId: string): WorkflowRoom | undefined { + return this.workflowRooms.get(workflowId) + } + + setWorkflowRoom(workflowId: string, room: WorkflowRoom): void { + this.workflowRooms.set(workflowId, room) + } + + getWorkflowIdForSocket(socketId: string): string | undefined { + return this.socketToWorkflow.get(socketId) + } + + setWorkflowForSocket(socketId: string, workflowId: string): void { + this.socketToWorkflow.set(socketId, workflowId) + } + + getUserSession(socketId: string): { userId: string; userName: string } | undefined { + return this.userSessions.get(socketId) + } + + setUserSession(socketId: string, session: { userId: string; userName: string }): void { + this.userSessions.set(socketId, session) + } + + getTotalActiveConnections(): number { + return Array.from(this.workflowRooms.values()).reduce( + (total, room) => total + room.activeConnections, + 0 + ) + } + + broadcastPresenceUpdate(workflowId: string): void { + const room = this.workflowRooms.get(workflowId) + if (room) { + const roomPresence = Array.from(room.users.values()) + this.io.to(workflowId).emit('presence-update', roomPresence) + } + } +} diff --git a/apps/sim/socket-server/routes/http.ts b/apps/sim/socket-server/routes/http.ts new file mode 100644 index 00000000000..10dc275057a --- /dev/null +++ b/apps/sim/socket-server/routes/http.ts @@ -0,0 +1,56 @@ +import type { IncomingMessage, ServerResponse } from 'http' +import type { RoomManager } from '../rooms/manager' + +interface Logger { + info: (message: string, ...args: any[]) => void + error: (message: string, ...args: any[]) => void + debug: (message: string, ...args: any[]) => void + warn: (message: string, ...args: any[]) => void +} + +/** + * Creates an HTTP request handler for the socket server + * @param roomManager - RoomManager instance for managing workflow rooms and state + * @param logger - Logger instance for logging requests and errors + * @returns HTTP request handler function + */ +export function createHttpHandler(roomManager: RoomManager, logger: Logger) { + return (req: IncomingMessage, res: ServerResponse) => { + // Handle health check for Railway + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + status: 'ok', + timestamp: new Date().toISOString(), + connections: roomManager.getTotalActiveConnections(), + }) + ) + return + } + + // Handle workflow deletion notifications from the main API + if (req.method === 'POST' && req.url === '/api/workflow-deleted') { + let body = '' + req.on('data', (chunk) => { + body += chunk.toString() + }) + req.on('end', () => { + try { + const { workflowId } = JSON.parse(body) + roomManager.handleWorkflowDeletion(workflowId) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true })) + } catch (error) { + logger.error('Error handling workflow deletion notification:', error) + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Failed to process deletion notification' })) + } + }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found' })) + } +} diff --git a/apps/sim/socket-server/validation/schemas.ts b/apps/sim/socket-server/validation/schemas.ts new file mode 100644 index 00000000000..3c4d713d245 --- /dev/null +++ b/apps/sim/socket-server/validation/schemas.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' + +const PositionSchema = z.object({ + x: z.number(), + y: z.number(), +}) + +export const BlockOperationSchema = z.object({ + operation: z.enum([ + 'add', + 'remove', + 'update-position', + 'update-name', + 'toggle-enabled', + 'update-parent', + 'update-wide', + 'update-advanced-mode', + 'duplicate', + ]), + target: z.literal('block'), + payload: z.object({ + id: z.string(), + type: z.string().optional(), + name: z.string().optional(), + position: PositionSchema.optional(), + data: z.record(z.any()).optional(), + subBlocks: z.record(z.any()).optional(), + outputs: z.record(z.any()).optional(), + parentId: z.string().optional(), + extent: z.enum(['parent']).optional(), + enabled: z.boolean().optional(), + horizontalHandles: z.boolean().optional(), + isWide: z.boolean().optional(), + advancedMode: z.boolean().optional(), + height: z.number().optional(), + }), + timestamp: z.number(), +}) + +export const EdgeOperationSchema = z.object({ + operation: z.enum(['add', 'remove']), + target: z.literal('edge'), + payload: z.object({ + id: z.string(), + source: z.string().optional(), + target: z.string().optional(), + sourceHandle: z.string().nullable().optional(), + targetHandle: z.string().nullable().optional(), + }), + timestamp: z.number(), +}) + +export const SubflowOperationSchema = z.object({ + operation: z.enum(['add', 'remove', 'update']), + target: z.literal('subflow'), + payload: z.object({ + id: z.string(), + type: z.enum(['loop', 'parallel']).optional(), + config: z.record(z.any()).optional(), + }), + timestamp: z.number(), +}) + +export const WorkflowOperationSchema = z.union([ + BlockOperationSchema, + EdgeOperationSchema, + SubflowOperationSchema, +]) + +export { PositionSchema } diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index efa25f2ef79..57ad937eb4f 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -71,7 +71,7 @@ interface FolderState { color?: string }) => Promise updateFolderAPI: (id: string, updates: Partial) => Promise - deleteFolder: (id: string) => Promise + deleteFolder: (id: string, workspaceId: string) => Promise // Helper functions isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => boolean @@ -304,7 +304,7 @@ export const useFolderStore = create()( return processedFolder }, - deleteFolder: async (id: string) => { + deleteFolder: async (id: string, workspaceId: string) => { const response = await fetch(`/api/folders/${id}`, { method: 'DELETE' }) if (!response.ok) { @@ -346,9 +346,9 @@ export const useFolderStore = create()( } } - if (workflowRegistry.activeWorkspaceId) { + if (workspaceId) { // Trigger workflow refresh through registry store - await workflowRegistry.switchToWorkspace(workflowRegistry.activeWorkspaceId) + await workflowRegistry.switchToWorkspace(workspaceId) } }, diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 347685dce12..8c265e03452 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -9,7 +9,6 @@ import { useNotificationStore } from './notifications/store' import { useConsoleStore } from './panel/console/store' import { useVariablesStore } from './panel/variables/store' import { useEnvironmentStore } from './settings/environment/store' -// Removed sync system imports - Socket.IO handles real-time sync import { useWorkflowRegistry } from './workflows/registry/store' import { useSubBlockStore } from './workflows/subblock/store' import { useWorkflowStore } from './workflows/workflow/store' @@ -41,12 +40,6 @@ async function initializeApplication(): Promise { // Load custom tools from server await useCustomToolsStore.getState().loadCustomTools() - // Extract workflow ID from URL for smart workspace selection - const workflowIdFromUrl = extractWorkflowIdFromUrl() - - // Load workspace based on workflow ID in URL, with fallback to last active workspace - await useWorkflowRegistry.getState().loadWorkspaceFromWorkflowId(workflowIdFromUrl) - // Load workflows from database (replaced sync system) await useWorkflowRegistry.getState().loadWorkflows() diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 161957de7c7..5fe909c51f6 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -88,7 +88,7 @@ export function getBlockWithValues(blockId: string): BlockState | null { * @returns An object containing workflows, with state only for the active workflow */ export function getAllWorkflowsWithValues() { - const { workflows, activeWorkspaceId } = useWorkflowRegistry.getState() + const { workflows } = useWorkflowRegistry.getState() const result: Record = {} const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() @@ -97,14 +97,6 @@ export function getAllWorkflowsWithValues() { if (activeWorkflowId && workflows[activeWorkflowId]) { const metadata = workflows[activeWorkflowId] - // Skip if workflow doesn't belong to the active workspace - if (activeWorkspaceId && metadata.workspaceId !== activeWorkspaceId) { - logger.debug( - `Skipping active workflow ${activeWorkflowId} - belongs to workspace ${metadata.workspaceId}, not active workspace ${activeWorkspaceId}` - ) - return result - } - // Get deployment status from registry const deploymentStatus = useWorkflowRegistry .getState() @@ -157,17 +149,10 @@ export function getAllWorkflowsWithValues() { return result } -// Removed syncWorkflows - Socket.IO handles real-time sync automatically - -// Workflows store exports - localStorage persistence removed - export { useWorkflowRegistry } from './registry/store' export type { WorkflowMetadata } from './registry/types' export { useSubBlockStore } from './subblock/store' export type { SubBlockStore } from './subblock/types' -// Re-export utilities export { mergeSubblockState } from './utils' -// Re-export store hooks export { useWorkflowStore } from './workflow/store' -// Re-export types export type { WorkflowState } from './workflow/types' diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 0ce08f959d5..4968c2dac29 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -4,7 +4,6 @@ import { createLogger } from '@/lib/logs/console-logger' import { clearWorkflowVariablesTracking } from '@/stores/panel/variables/store' import { API_ENDPOINTS } from '../../constants' import { useSubBlockStore } from '../subblock/store' -// Removed fetchWorkflowsFromDB import - moved to local function import { useWorkflowStore } from '../workflow/store' import type { BlockState } from '../workflow/types' import type { DeploymentStatus, WorkflowMetadata, WorkflowRegistry } from './types' @@ -12,11 +11,10 @@ import { generateUniqueName, getNextWorkflowColor } from './utils' const logger = createLogger('WorkflowRegistry') -// Simplified function to fetch workflows from DB (moved from sync.ts) let isFetching = false let lastFetchTimestamp = 0 -async function fetchWorkflowsFromDB(): Promise { +async function fetchWorkflowsFromDB(workspaceId?: string): Promise { if (typeof window === 'undefined') return // Prevent concurrent fetch operations @@ -31,11 +29,10 @@ async function fetchWorkflowsFromDB(): Promise { try { useWorkflowRegistry.getState().setLoading(true) - const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId const url = new URL(API_ENDPOINTS.SYNC, window.location.origin) - if (activeWorkspaceId) { - url.searchParams.append('workspaceId', activeWorkspaceId) + if (workspaceId) { + url.searchParams.append('workspaceId', workspaceId) } const response = await fetch(url.toString(), { method: 'GET' }) @@ -99,10 +96,7 @@ async function fetchWorkflowsFromDB(): Promise { apiKey, } = workflow - // Skip if workflow doesn't belong to active workspace - if (activeWorkspaceId && workspaceId !== activeWorkspaceId) { - return - } + // No need to filter by workspace since we're already fetching for specific workspace // Add to registry registryWorkflows[id] = { @@ -256,7 +250,6 @@ export const useWorkflowRegistry = create()( // Store state workflows: {}, activeWorkflowId: null, - activeWorkspaceId: null, // No longer persisted in localStorage isLoading: true, error: null, // Initialize deployment statuses @@ -270,24 +263,17 @@ export const useWorkflowRegistry = create()( }, // Simple method to load workflows (replaces sync system) - loadWorkflows: async () => { - await fetchWorkflowsFromDB() + loadWorkflows: async (workspaceId?: string) => { + await fetchWorkflowsFromDB(workspaceId) }, // Handle cleanup on workspace deletion handleWorkspaceDeletion: async (newWorkspaceId: string) => { - const currentWorkspaceId = get().activeWorkspaceId - - if (!newWorkspaceId || newWorkspaceId === currentWorkspaceId) { - logger.error('Cannot switch to invalid workspace after deletion') - return - } - // Set transition state setWorkspaceTransitioning(true) try { - logger.info(`Switching from deleted workspace ${currentWorkspaceId} to ${newWorkspaceId}`) + logger.info(`Switching to new workspace after deletion: ${newWorkspaceId}`) // Reset all workflow state resetWorkflowStores() @@ -296,12 +282,11 @@ export const useWorkflowRegistry = create()( set({ isLoading: true, workflows: {}, - activeWorkspaceId: newWorkspaceId, activeWorkflowId: null, }) // Properly await workflow fetching to prevent race conditions - await fetchWorkflowsFromDB() + await fetchWorkflowsFromDB(newWorkspaceId) set({ isLoading: false }) logger.info(`Successfully switched to workspace after deletion: ${newWorkspaceId}`) @@ -327,29 +312,17 @@ export const useWorkflowRegistry = create()( return } - const { activeWorkspaceId: currentWorkspaceId } = get() - - // Early return if switching to the same workspace (before setting flag) - if (currentWorkspaceId === workspaceId) { - logger.info(`Already in workspace ${workspaceId}`) - return - } - - // Only set transition flag AFTER validating the switch is needed + // Set transition flag setWorkspaceTransitioning(true) try { - logger.info(`Switching workspace from ${currentWorkspaceId || 'none'} to ${workspaceId}`) - - // Save to localStorage first before any async operations - get().setActiveWorkspaceId(workspaceId) + logger.info(`Switching to workspace: ${workspaceId}`) // Clear current workspace state resetWorkflowStores() - // Update workspace in state + // Update state set({ - activeWorkspaceId: workspaceId, activeWorkflowId: null, workflows: {}, isLoading: true, @@ -357,7 +330,7 @@ export const useWorkflowRegistry = create()( }) // Fetch workflows for the new workspace - await fetchWorkflowsFromDB() + await fetchWorkflowsFromDB(workspaceId) logger.info(`Successfully switched to workspace: ${workspaceId}`) } catch (error) { @@ -371,128 +344,6 @@ export const useWorkflowRegistry = create()( } }, - // Load user's last active workspace from localStorage - loadLastActiveWorkspace: async () => { - try { - const savedWorkspaceId = localStorage.getItem('lastActiveWorkspaceId') - if (!savedWorkspaceId || savedWorkspaceId === get().activeWorkspaceId) { - return // No saved workspace or already active - } - - logger.info(`Attempting to restore last active workspace: ${savedWorkspaceId}`) - - // Validate that the workspace exists by making a simple API call - try { - const response = await fetch('/api/workspaces') - if (response.ok) { - const data = await response.json() - const workspaces = data.workspaces || [] - const workspaceExists = workspaces.some((ws: any) => ws.id === savedWorkspaceId) - - if (workspaceExists) { - // Set the validated workspace ID - set({ activeWorkspaceId: savedWorkspaceId }) - logger.info(`Restored last active workspace from localStorage: ${savedWorkspaceId}`) - } else { - logger.warn( - `Saved workspace ${savedWorkspaceId} no longer exists, clearing from localStorage` - ) - localStorage.removeItem('lastActiveWorkspaceId') - } - } - } catch (apiError) { - logger.warn('Failed to validate saved workspace, will use default:', apiError) - // Don't remove from localStorage in case it's a temporary network issue - } - } catch (error) { - logger.warn('Failed to load last active workspace from localStorage:', error) - // This is non-critical, so we continue with default behavior - } - }, - - // Load workspace based on workflow ID from URL, with fallback to last active workspace - loadWorkspaceFromWorkflowId: async (workflowId: string | null) => { - try { - logger.info(`Loading workspace for workflow ID: ${workflowId}`) - - // If workflow ID provided, try to get its workspace - if (workflowId) { - try { - const response = await fetch(`/api/workflows/${workflowId}`) - if (response.ok) { - const data = await response.json() - const workflow = data.data - - if (workflow?.workspaceId) { - // Validate workspace access - const workspacesResponse = await fetch('/api/workspaces') - if (workspacesResponse.ok) { - const workspacesData = await workspacesResponse.json() - const workspaces = workspacesData.workspaces || [] - const workspaceExists = workspaces.some( - (ws: any) => ws.id === workflow.workspaceId - ) - - if (workspaceExists) { - set({ activeWorkspaceId: workflow.workspaceId }) - localStorage.setItem('lastActiveWorkspaceId', workflow.workspaceId) - logger.info(`Set active workspace from workflow: ${workflow.workspaceId}`) - return - } - } - } - } - } catch (error) { - logger.warn('Error fetching workflow:', error) - } - } - - // Fallback: use last active workspace or first available - const savedWorkspaceId = localStorage.getItem('lastActiveWorkspaceId') - const response = await fetch('/api/workspaces') - - if (response.ok) { - const data = await response.json() - const workspaces = data.workspaces || [] - - if (workspaces.length === 0) { - logger.warn('No workspaces found') - return - } - - // Try saved workspace first - let targetWorkspace = savedWorkspaceId - ? workspaces.find((ws: any) => ws.id === savedWorkspaceId) - : null - - // Fall back to first workspace - if (!targetWorkspace) { - targetWorkspace = workspaces[0] - if (savedWorkspaceId) { - localStorage.removeItem('lastActiveWorkspaceId') - } - } - - set({ activeWorkspaceId: targetWorkspace.id }) - localStorage.setItem('lastActiveWorkspaceId', targetWorkspace.id) - logger.info(`Set active workspace: ${targetWorkspace.id}`) - } - } catch (error) { - logger.error('Error in loadWorkspaceFromWorkflowId:', error) - } - }, - - // Simple method to set active workspace ID without triggering full switch - setActiveWorkspaceId: (id: string) => { - set({ activeWorkspaceId: id }) - // Save to localStorage as well - try { - localStorage.setItem('lastActiveWorkspaceId', id) - } catch (error) { - logger.warn('Failed to save workspace to localStorage:', error) - } - }, - // Method to get deployment status for a specific workflow getWorkflowDeploymentStatus: (workflowId: string | null): DeploymentStatus | null => { if (!workflowId) { @@ -735,14 +586,19 @@ export const useWorkflowRegistry = create()( * @returns The ID of the newly created workflow */ createWorkflow: async (options = {}) => { - const { workflows, activeWorkspaceId } = get() + const { workflows } = get() const id = crypto.randomUUID() - // Use provided workspace ID or fall back to active workspace ID - const workspaceId = options.workspaceId || activeWorkspaceId || undefined + // Use provided workspace ID (must be provided since we no longer track active workspace) + const workspaceId = options.workspaceId - logger.info(`Creating new workflow in workspace: ${workspaceId || 'none'}`) + if (!workspaceId) { + logger.error('Cannot create workflow without workspaceId') + set({ error: 'Workspace ID is required to create a workflow' }) + throw new Error('Workspace ID is required to create a workflow') + } + logger.info(`Creating new workflow in workspace: ${workspaceId || 'none'}`) // Generate workflow metadata with appropriate name and color const newWorkflow: WorkflowMetadata = { id, @@ -1153,7 +1009,7 @@ export const useWorkflowRegistry = create()( * Duplicates an existing workflow */ duplicateWorkflow: async (sourceId: string) => { - const { workflows, activeWorkspaceId } = get() + const { workflows } = get() const sourceWorkflow = workflows[sourceId] if (!sourceWorkflow) { @@ -1161,8 +1017,8 @@ export const useWorkflowRegistry = create()( return null } - // Get the workspace ID from the source workflow or fall back to active workspace - const workspaceId = sourceWorkflow.workspaceId || activeWorkspaceId || undefined + // Get the workspace ID from the source workflow (required) + const workspaceId = sourceWorkflow.workspaceId // Call the server to duplicate the workflow - server generates all IDs let duplicatedWorkflow @@ -1594,7 +1450,6 @@ export const useWorkflowRegistry = create()( set({ workflows: {}, activeWorkflowId: null, - activeWorkspaceId: null, isLoading: true, error: null, }) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index efc854b835b..5e9234b28bb 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -24,7 +24,6 @@ export interface WorkflowMetadata { export interface WorkflowRegistryState { workflows: Record activeWorkflowId: string | null - activeWorkspaceId: string | null isLoading: boolean error: string | null deploymentStatuses: Record @@ -34,10 +33,7 @@ export interface WorkflowRegistryActions { setLoading: (loading: boolean) => void setActiveWorkflow: (id: string) => Promise switchToWorkspace: (id: string) => void - setActiveWorkspaceId: (id: string) => void - loadLastActiveWorkspace: () => Promise - loadWorkspaceFromWorkflowId: (workflowId: string | null) => Promise - loadWorkflows: () => Promise + loadWorkflows: (workspaceId?: string) => Promise handleWorkspaceDeletion: (newWorkspaceId: string) => void removeWorkflow: (id: string) => Promise updateWorkflow: (id: string, metadata: Partial) => Promise diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 9a7ff740648..98946151498 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -581,6 +581,7 @@ export const useWorkflowStore = create()( // workflowValues: {[block_id]:{[subblock_id]:[subblock_value]}} const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {} const updatedWorkflowValues = { ...workflowValues } + const changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }> = [] // Loop through blocks Object.entries(workflowValues).forEach(([blockId, blockValues]) => { @@ -593,11 +594,17 @@ export const useWorkflowStore = create()( const regex = new RegExp(`<${oldBlockName}\\.`, 'g') // Use a recursive function to handle all object types - updatedWorkflowValues[blockId][subBlockId] = updateReferences( - value, - regex, - `<${newBlockName}.` - ) + const updatedValue = updateReferences(value, regex, `<${newBlockName}.`) + + // Check if the value actually changed + if (JSON.stringify(updatedValue) !== JSON.stringify(value)) { + updatedWorkflowValues[blockId][subBlockId] = updatedValue + changedSubblocks.push({ + blockId, + subBlockId, + newValue: updatedValue, + }) + } // Helper function to recursively update references in any data structure function updateReferences(value: any, regex: RegExp, replacement: string): any { @@ -633,6 +640,12 @@ export const useWorkflowStore = create()( [activeWorkflowId]: updatedWorkflowValues, }, }) + + // Store changed subblocks for collaborative sync + if (changedSubblocks.length > 0) { + // Store the changed subblocks for the collaborative function to pick up + ;(window as any).__pendingSubblockUpdates = changedSubblocks + } } set(newState) @@ -657,6 +670,38 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockWide: (id: string, isWide: boolean) => { + set((state) => ({ + blocks: { + ...state.blocks, + [id]: { + ...state.blocks[id], + isWide, + }, + }, + edges: [...state.edges], + loops: { ...state.loops }, + })) + get().updateLastSaved() + // Note: Socket.IO handles real-time sync automatically + }, + + setBlockAdvancedMode: (id: string, advancedMode: boolean) => { + set((state) => ({ + blocks: { + ...state.blocks, + [id]: { + ...state.blocks[id], + advancedMode, + }, + }, + edges: [...state.edges], + loops: { ...state.loops }, + })) + get().updateLastSaved() + // Note: Socket.IO handles real-time sync automatically + }, + updateBlockHeight: (id: string, height: number) => { set((state) => ({ blocks: { diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 2baf5a1fa07..65ef3624515 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -2,24 +2,17 @@ import type { Edge } from 'reactflow' import type { BlockOutput, SubBlockType } from '@/blocks/types' import type { DeploymentStatus } from '../registry/types' -// Centralized subflow type system - easy to extend without database changes export const SUBFLOW_TYPES = { LOOP: 'loop', PARALLEL: 'parallel', - // Future types can be added here: - // CONDITIONAL: 'conditional', - // RETRY: 'retry', - // BATCH: 'batch', } as const export type SubflowType = (typeof SUBFLOW_TYPES)[keyof typeof SUBFLOW_TYPES] -// Type guard for runtime validation export function isValidSubflowType(type: string): type is SubflowType { return Object.values(SUBFLOW_TYPES).includes(type as SubflowType) } -// Subflow configuration interfaces export interface LoopConfig { nodes: string[] iterations: number @@ -184,6 +177,7 @@ export interface WorkflowActions { toggleBlockHandles: (id: string) => void updateBlockName: (id: string, name: string) => void toggleBlockWide: (id: string) => void + setBlockWide: (id: string, isWide: boolean) => void updateBlockHeight: (id: string, height: number) => void triggerUpdate: () => void updateLoopCount: (loopId: string, count: number) => void diff --git a/apps/sim/test-socket-integration.html b/apps/sim/test-socket-integration.html deleted file mode 100644 index 61af75221a5..00000000000 --- a/apps/sim/test-socket-integration.html +++ /dev/null @@ -1,275 +0,0 @@ - - - - - - Socket Integration Test - - - - -
-

Socket.IO Collaborative Workflow Test

- -
- Disconnected -
- -
-

Presence Users:

-
None
-
- -
-

Test Workflow Operations:

- - - -
- -
-

Block Operations:

- - - -
- -
-

Edge Operations:

- - -
- -
-

Event Log:

-
-
-
- - - - diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index 426a0093027..e0d2679eb41 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -40,6 +40,17 @@ vi.mock('@/stores/execution/store', () => ({ }, })) +vi.mock('@/blocks/registry', () => ({ + getBlock: vi.fn(() => ({ + name: 'Mock Block', + description: 'Mock block description', + icon: () => null, + subBlocks: [], + outputs: {}, + })), + getAllBlocks: vi.fn(() => ({})), +})) + const originalConsoleError = console.error const originalConsoleWarn = console.warn