From 1152a264bc6367d0e69ebfe5f9fdbdb96c26198c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 May 2025 12:28:46 -0700 Subject: [PATCH] fix(subscriptions): fixed organization creation failure introduced by subscription updates (#361) * fix(subscriptions): fixed organization creation failure introduced by subscription updates * cleaned up tests * run format --- .prettierignore | 5 +- apps/docs/content/docs/tools/meta.json | 2 +- .../sim/app/(auth)/verify/use-verification.ts | 10 +- apps/sim/app/api/__test-utils__/utils.ts | 139 +++--- .../subscription/[id]/seats/route.test.ts | 251 +++++++++++ .../{update-seats => [id]/seats}/route.ts | 106 ++--- .../subscription/[id]/transfer/route.test.ts | 278 ++++++++++++ .../user/subscription/[id]/transfer/route.ts | 114 +++++ .../api/user/transfer-subscription/route.ts | 119 ----- apps/sim/app/api/webhooks/route.ts | 28 +- apps/sim/app/api/webhooks/test/route.ts | 38 +- .../app/api/webhooks/trigger/[path]/route.ts | 9 +- .../app/api/workflows/[id]/status/route.ts | 2 +- .../app/api/workflows/[id]/variables/route.ts | 6 +- apps/sim/app/api/workflows/sync/route.ts | 139 +++--- .../workspaces/invitations/accept/route.ts | 158 ++++--- .../workspaces/invitations/details/route.ts | 28 +- .../app/api/workspaces/invitations/route.ts | 125 +++--- apps/sim/app/globals.css | 30 +- .../components/oauth-required-modal.tsx | 6 +- .../file-selector/file-selector-input.tsx | 7 +- .../components/discord-server-selector.tsx | 24 +- .../workflow-block/workflow-block.tsx | 16 +- .../team-management/team-management.tsx | 413 ++++++++++-------- apps/sim/components/icons.tsx | 25 +- apps/sim/components/ui/command.tsx | 9 + apps/sim/drizzle.config.ts | 2 +- apps/sim/executor/resolver.ts | 8 +- apps/sim/lib/auth.ts | 7 +- apps/sim/lib/oauth.ts | 19 +- apps/sim/lib/variables/variable-manager.ts | 6 +- apps/sim/lib/webhooks/utils.ts | 14 +- apps/sim/middleware.ts | 52 ++- apps/sim/stores/index.ts | 37 +- apps/sim/stores/panel/variables/store.ts | 87 ++-- apps/sim/stores/sidebar/store.ts | 2 +- apps/sim/stores/sync-core.ts | 106 ++--- apps/sim/stores/sync-registry.ts | 24 +- apps/sim/stores/workflows/index.ts | 8 +- apps/sim/stores/workflows/registry/store.ts | 86 ++-- apps/sim/stores/workflows/sync.ts | 101 ++--- apps/sim/stores/workflows/workflow/store.ts | 6 +- apps/sim/stores/workflows/workflow/types.ts | 8 +- apps/sim/tools/registry.ts | 9 +- 44 files changed, 1678 insertions(+), 991 deletions(-) create mode 100644 apps/sim/app/api/user/subscription/[id]/seats/route.test.ts rename apps/sim/app/api/user/subscription/{update-seats => [id]/seats}/route.ts (54%) create mode 100644 apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts create mode 100644 apps/sim/app/api/user/subscription/[id]/transfer/route.ts delete mode 100644 apps/sim/app/api/user/transfer-subscription/route.ts diff --git a/.prettierignore b/.prettierignore index 70c1abe4ea..b4e992ffe1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -34,4 +34,7 @@ coverage **/public/sw.js **/public/workbox-*.js **/public/worker-*.js -**/public/fallback-*.js \ No newline at end of file +**/public/fallback-*.js + +# Documentation +apps/docs/**/*.mdx \ No newline at end of file diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index f331ca1a84..3d3ef15bc5 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -45,4 +45,4 @@ "x", "youtube" ] -} \ No newline at end of file +} diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index f3fddecd18..548776efa0 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -57,26 +57,26 @@ export function useVerification({ if (storedEmail) { setEmail(storedEmail) } - + // Check for redirect information const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl') if (storedRedirectUrl) { setRedirectUrl(storedRedirectUrl) } - + // Check if this is an invite flow const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow') if (storedIsInviteFlow === 'true') { setIsInviteFlow(true) } } - + // Also check URL parameters for redirect information const redirectParam = searchParams.get('redirectAfter') if (redirectParam) { setRedirectUrl(redirectParam) } - + // Check for invite_flow parameter const inviteFlowParam = searchParams.get('invite_flow') if (inviteFlowParam === 'true') { @@ -130,7 +130,7 @@ export function useVerification({ // Clear email from sessionStorage after successful verification if (typeof window !== 'undefined') { sessionStorage.removeItem('verificationEmail') - + // Also clear invite-related items if (isInviteFlow) { sessionStorage.removeItem('inviteRedirectUrl') diff --git a/apps/sim/app/api/__test-utils__/utils.ts b/apps/sim/app/api/__test-utils__/utils.ts index 1118e757ef..5b19ffc817 100644 --- a/apps/sim/app/api/__test-utils__/utils.ts +++ b/apps/sim/app/api/__test-utils__/utils.ts @@ -1,9 +1,6 @@ import { vi } from 'vitest' import { NextRequest } from 'next/server' -/** - * Mock sample workflow state for testing - */ export const sampleWorkflowState = { blocks: { 'starter-id': { @@ -65,51 +62,108 @@ export const sampleWorkflowState = { isDeployed: false, } -/** - * Mock database with test data - */ -export function mockDb() { - return { - select: vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => [ - { - id: 'workflow-id', - userId: 'user-id', - state: sampleWorkflowState, - }, - ]), - })), +export const mockDb = { + select: vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation(() => ({ + limit: vi.fn().mockImplementation(() => [ + { + id: 'workflow-id', + userId: 'user-id', + state: sampleWorkflowState, + }, + ]), })), })), - update: vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue([]), - })), + })), + update: vi.fn().mockImplementation(() => ({ + set: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockResolvedValue([]), })), - } + })), + eq: vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })), + and: vi.fn().mockImplementation((...conditions) => ({ + conditions, + type: 'and', + })), +} + +export const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} + +export const mockUser = { + id: 'user-123', + email: 'test@example.com', +} + +export const mockSubscription = { + id: 'sub-123', + plan: 'enterprise', + status: 'active', + seats: 5, + referenceId: 'user-123', + metadata: { + perSeatAllowance: 100, + totalAllowance: 500, + updatedAt: '2023-01-01T00:00:00.000Z', + }, +} + +export const mockOrganization = { + id: 'org-456', + name: 'Test Organization', + slug: 'test-org', +} + +export const mockAdminMember = { + id: 'member-123', + userId: 'user-123', + organizationId: 'org-456', + role: 'admin', +} + +export const mockRegularMember = { + id: 'member-456', + userId: 'user-123', + organizationId: 'org-456', + role: 'member', +} + +export const mockTeamSubscription = { + id: 'sub-456', + plan: 'team', + status: 'active', + seats: 5, + referenceId: 'org-123', +} + +export const mockPersonalSubscription = { + id: 'sub-789', + plan: 'enterprise', + status: 'active', + seats: 5, + referenceId: 'user-123', + metadata: { + perSeatAllowance: 100, + totalAllowance: 500, + updatedAt: '2023-01-01T00:00:00.000Z', + }, } -/** - * Mock environment variables for testing - */ export const mockEnvironmentVars = { OPENAI_API_KEY: 'encrypted:openai-api-key', SERPER_API_KEY: 'encrypted:serper-api-key', } -/** - * Mock decrypted environment variables for testing - */ export const mockDecryptedEnvVars = { OPENAI_API_KEY: 'sk-test123', SERPER_API_KEY: 'serper-test123', } -/** - * Create mock Next.js request for testing - */ export function createMockRequest( method: string = 'GET', body?: any, @@ -125,11 +179,7 @@ export function createMockRequest( }) } -/** - * Mock the executeWorkflow function dependencies - */ export function mockExecutionDependencies() { - // Mock decryptSecret function vi.mock('@/lib/utils', async () => { const actual = await vi.importActual('@/lib/utils') return { @@ -150,13 +200,11 @@ export function mockExecutionDependencies() { } }) - // Mock execution logger functions vi.mock('@/lib/logs/execution-logger', () => ({ persistExecutionLogs: vi.fn().mockResolvedValue(undefined), persistExecutionError: vi.fn().mockResolvedValue(undefined), })) - // Mock trace spans builder vi.mock('@/lib/logs/trace-spans', () => ({ buildTraceSpans: vi.fn().mockReturnValue({ traceSpans: [], @@ -164,12 +212,10 @@ export function mockExecutionDependencies() { }), })) - // Mock workflow utils vi.mock('@/lib/workflows/utils', () => ({ updateWorkflowRunCounts: vi.fn().mockResolvedValue(undefined), })) - // Mock serializer vi.mock('@/serializer', () => ({ Serializer: vi.fn().mockImplementation(() => ({ serializeWorkflow: vi.fn().mockReturnValue({ @@ -205,7 +251,6 @@ export function mockExecutionDependencies() { })), })) - // Mock executor vi.mock('@/executor', () => ({ Executor: vi.fn().mockImplementation(() => ({ execute: vi.fn().mockResolvedValue({ @@ -226,15 +271,11 @@ export function mockExecutionDependencies() { })), })) - // Mock database vi.mock('@/db', () => ({ - db: mockDb(), + db: mockDb, })) } -/** - * Mock the workflow access validation middleware - */ export function mockWorkflowAccessValidation(shouldSucceed = true) { if (shouldSucceed) { vi.mock('@/app/api/workflows/middleware', () => ({ @@ -258,11 +299,7 @@ export function mockWorkflowAccessValidation(shouldSucceed = true) { } } -/** - * Get mocked dependencies for validation - */ export async function getMockedDependencies() { - // Using dynamic imports to avoid module resolution issues const utilsModule = await import('@/lib/utils') const logsModule = await import('@/lib/logs/execution-logger') const traceSpansModule = await import('@/lib/logs/trace-spans') diff --git a/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts b/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts new file mode 100644 index 0000000000..4d0bdfe88f --- /dev/null +++ b/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for Subscription Seats Update API + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + mockDb, + mockLogger, + mockPersonalSubscription, + mockRegularMember, + mockSubscription, + mockTeamSubscription, + mockUser, +} from '@/app/api/__test-utils__/utils' + +describe('Subscription Seats Update API Routes', () => { + beforeEach(() => { + vi.resetModules() + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: mockUser, + }), + })) + + vi.doMock('@/lib/subscription/utils', () => ({ + checkEnterprisePlan: vi.fn().mockReturnValue(true), + })) + + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + vi.doMock('@/db', () => ({ + db: mockDb, + })) + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockSubscription]), + }) + + const mockSetFn = vi.fn().mockReturnThis() + const mockWhereFn = vi.fn().mockResolvedValue([{ affected: 1 }]) + mockDb.update.mockReturnValue({ + set: mockSetFn, + where: mockWhereFn, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('POST handler', () => { + it('should encounter a permission error when trying to update subscription seats', async () => { + vi.doMock('@/lib/subscription/utils', () => ({ + checkEnterprisePlan: vi.fn().mockReturnValue(true), + })) + + mockDb.select.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockSubscription]), + })) + + mockDb.select.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([]), + })) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty( + 'error', + 'Unauthorized - you do not have permission to modify this subscription' + ) + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should reject team plan subscription updates', async () => { + vi.doMock('@/lib/subscription/utils', () => ({ + checkEnterprisePlan: vi.fn().mockReturnValue(false), + })) + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockTeamSubscription]), + }) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toHaveProperty( + 'error', + 'Only enterprise subscriptions can be updated through this endpoint' + ) + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should encounter permission issues with personal subscription updates', async () => { + vi.doMock('@/lib/subscription/utils', () => ({ + checkEnterprisePlan: vi.fn().mockReturnValue(true), + })) + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockPersonalSubscription]), + }) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error') + }) + + it('should reject updates from non-admin members', async () => { + vi.doMock('@/lib/subscription/utils', () => ({ + checkEnterprisePlan: vi.fn().mockReturnValue(true), + })) + + const mockSelectImpl = vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockSubscription]), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockRegularMember]), + }) + + mockDb.select.mockImplementation(mockSelectImpl) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error') + }) + + it('should reject invalid request parameters', async () => { + const req = createMockRequest('POST', { + seats: -5, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toHaveProperty('error', 'Invalid request parameters') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should handle subscription not found with permission error', async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([]), + }) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error') + }) + + it('should handle authentication error', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toHaveProperty('error', 'Unauthorized') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should handle internal server error', async () => { + mockDb.select.mockImplementation(() => { + throw new Error('Database error') + }) + + const req = createMockRequest('POST', { + seats: 10, + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data).toHaveProperty('error', 'Failed to update subscription seats') + expect(mockLogger.error).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/user/subscription/update-seats/route.ts b/apps/sim/app/api/user/subscription/[id]/seats/route.ts similarity index 54% rename from apps/sim/app/api/user/subscription/update-seats/route.ts rename to apps/sim/app/api/user/subscription/[id]/seats/route.ts index 4e4b27c620..0294ef26fb 100644 --- a/apps/sim/app/api/user/subscription/update-seats/route.ts +++ b/apps/sim/app/api/user/subscription/[id]/seats/route.ts @@ -1,5 +1,5 @@ -import { NextResponse } from 'next/server' -import { and, eq, or } from 'drizzle-orm' +import { NextRequest, NextResponse } from 'next/server' +import { and, eq } from 'drizzle-orm' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' @@ -7,11 +7,10 @@ import { checkEnterprisePlan } from '@/lib/subscription/utils' import { db } from '@/db' import { member, subscription } from '@/db/schema' -const logger = createLogger('UpdateSubscriptionSeatsAPI') +const logger = createLogger('SubscriptionSeatsUpdateAPI') const updateSeatsSchema = z.object({ - subscriptionId: z.string().uuid(), - seats: z.number().int().positive(), + seats: z.number().int().min(1), }) const subscriptionMetadataSchema = z @@ -29,17 +28,29 @@ interface SubscriptionMetadata { [key: string]: any } -export async function POST(req: Request) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { + const subscriptionId = (await params).id const session = await getSession() if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + logger.warn('Unauthorized seats update attempt') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const rawBody = await req.json() - const validationResult = updateSeatsSchema.safeParse(rawBody) + let body + try { + body = await request.json() + } catch (parseError) { + return NextResponse.json( + { + error: 'Invalid JSON in request body', + }, + { status: 400 } + ) + } + const validationResult = updateSeatsSchema.safeParse(body) if (!validationResult.success) { return NextResponse.json( { @@ -50,58 +61,44 @@ export async function POST(req: Request) { ) } - const { subscriptionId, seats } = validationResult.data + const { seats } = validationResult.data - const subscriptions = await db + const sub = await db .select() .from(subscription) .where(eq(subscription.id, subscriptionId)) - .limit(1) + .then((rows) => rows[0]) - if (subscriptions.length === 0) { + if (!sub) { return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) } - const sub = subscriptions[0] - if (!checkEnterprisePlan(sub)) { return NextResponse.json( - { - error: 'Only enterprise subscriptions can be updated through this endpoint', - }, + { error: 'Only enterprise subscriptions can be updated through this endpoint' }, { status: 400 } ) } - let hasPermission = sub.referenceId === session.user.id + const isPersonalSubscription = sub.referenceId === session.user.id - if (!hasPermission) { - const memberships = await db + let hasAccess = isPersonalSubscription + + if (!isPersonalSubscription) { + const mem = await db .select() .from(member) - .where( - and( - eq(member.userId, session.user.id), - eq(member.organizationId, sub.referenceId), - or(eq(member.role, 'owner'), eq(member.role, 'admin')) - ) - ) - .limit(1) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, sub.referenceId))) + .then((rows) => rows[0]) - hasPermission = memberships.length > 0 + hasAccess = mem && (mem.role === 'owner' || mem.role === 'admin') + } - if (!hasPermission) { - logger.warn('Unauthorized subscription update attempt', { - userId: session.user.id, - subscriptionId, - referenceId: sub.referenceId, - }) - - return NextResponse.json( - { error: 'You must be an admin or owner to update subscription settings' }, - { status: 403 } - ) - } + if (!hasAccess) { + return NextResponse.json( + { error: 'Unauthorized - you do not have permission to modify this subscription' }, + { status: 403 } + ) } let validatedMetadata: SubscriptionMetadata @@ -132,30 +129,23 @@ export async function POST(req: Request) { }) .where(eq(subscription.id, subscriptionId)) - logger.info('Updated subscription seats', { + logger.info('Subscription seats updated', { subscriptionId, - previousSeats: sub.seats, + oldSeats: sub.seats, newSeats: seats, userId: session.user.id, }) return NextResponse.json({ success: true, - message: 'Subscription seats updated', - data: { - subscriptionId, - seats, - plan: sub.plan, - metadata: validatedMetadata, - }, + message: 'Subscription seats updated successfully', + seats, + metadata: validatedMetadata, }) } catch (error) { - logger.error('Error updating subscription seats:', error) - return NextResponse.json( - { - error: 'Failed to update subscription seats', - }, - { status: 500 } - ) + logger.error('Error updating subscription seats', { + error: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json({ error: 'Failed to update subscription seats' }, { status: 500 }) } } diff --git a/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts new file mode 100644 index 0000000000..d3bc3f9e19 --- /dev/null +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for Subscription Transfer API + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + mockAdminMember, + mockDb, + mockLogger, + mockOrganization, + mockRegularMember, + mockSubscription, + mockUser, +} from '@/app/api/__test-utils__/utils' + +describe('Subscription Transfer API Routes', () => { + beforeEach(() => { + vi.resetModules() + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: mockUser, + }), + })) + + vi.doMock('@/lib/logs/console-logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + })) + + vi.doMock('@/db', () => ({ + db: mockDb, + })) + + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockSubscription]), + }) + + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([{ affected: 1 }]), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('POST handler', () => { + it('should successfully transfer a personal subscription to an organization', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { + ...mockUser, + id: 'user-123', + }, + }), + })) + + vi.doMock('@/db/schema', () => ({ + subscription: { id: 'id', referenceId: 'referenceId' }, + organization: { id: 'id' }, + member: { userId: 'userId', organizationId: 'organizationId', role: 'role' }, + })) + + const mockSubscriptionWithReferenceId = { + ...mockSubscription, + referenceId: 'user-123', + } + + mockDb.select.mockImplementation(() => { + return { + from: () => ({ + where: () => { + if (mockDb.select.mock.calls.length === 1) { + return Promise.resolve([mockSubscriptionWithReferenceId]) + } else if (mockDb.select.mock.calls.length === 2) { + return Promise.resolve([mockOrganization]) + } else { + return Promise.resolve([mockAdminMember]) + } + }, + }), + } + }) + + mockDb.update.mockReturnValue({ + set: () => ({ + where: () => Promise.resolve({ affected: 1 }), + }), + }) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toHaveProperty('success', true) + expect(data).toHaveProperty('message', 'Subscription transferred successfully') + expect(mockDb.update).toHaveBeenCalled() + }) + + it('should test behavior when subscription not found', async () => { + mockDb.select.mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([]), + }) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user') + }) + + it('should test behavior when organization not found', async () => { + const mockSelectImpl = vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockSubscription]), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([]), + }) + + mockDb.select.mockImplementation(mockSelectImpl) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user') + }) + + it('should reject transfer if user is not the subscription owner', async () => { + const differentOwnerSubscription = { + ...mockSubscription, + referenceId: 'different-user-123', + } + + mockDb.select.mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([differentOwnerSubscription]), + }) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should reject non-personal transfer if user is not admin of organization', async () => { + const orgOwnedSubscription = { + ...mockSubscription, + referenceId: 'other-org-789', + } + + const mockSelectImpl = vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([orgOwnedSubscription]), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockOrganization]), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockRegularMember]), + }) + + mockDb.select.mockImplementation(mockSelectImpl) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should reject invalid request parameters', async () => { + const req = createMockRequest('POST', {}) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toHaveProperty('error', 'Invalid request parameters') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should handle authentication error', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toHaveProperty('error', 'Unauthorized') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should handle internal server error', async () => { + mockDb.select.mockImplementation(() => { + throw new Error('Database error') + }) + + const req = createMockRequest('POST', { + organizationId: 'org-456', + }) + + const { POST } = await import('./route') + + const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data).toHaveProperty('error', 'Failed to transfer subscription') + expect(mockLogger.error).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/user/subscription/[id]/transfer/route.ts b/apps/sim/app/api/user/subscription/[id]/transfer/route.ts new file mode 100644 index 0000000000..7c94468c79 --- /dev/null +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { member, organization, subscription } from '@/db/schema' + +const logger = createLogger('SubscriptionTransferAPI') + +const transferSubscriptionSchema = z.object({ + organizationId: z.string().min(1), +}) + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const subscriptionId = (await params).id + const session = await getSession() + + if (!session?.user?.id) { + logger.warn('Unauthorized subscription transfer attempt') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body + try { + body = await request.json() + } catch (parseError) { + return NextResponse.json( + { + error: 'Invalid JSON in request body', + }, + { status: 400 } + ) + } + + const validationResult = transferSubscriptionSchema.safeParse(body) + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: validationResult.error.format(), + }, + { status: 400 } + ) + } + + const { organizationId } = validationResult.data + logger.info('Processing subscription transfer', { subscriptionId, organizationId }) + + const sub = await db + .select() + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + .then((rows) => rows[0]) + + if (!sub) { + return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) + } + + if (sub.referenceId !== session.user.id) { + return NextResponse.json( + { error: 'Unauthorized - subscription does not belong to user' }, + { status: 403 } + ) + } + + const org = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .then((rows) => rows[0]) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const mem = await db + .select() + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .then((rows) => rows[0]) + + const isPersonalTransfer = sub.referenceId === session.user.id + + if (!isPersonalTransfer && (!mem || (mem.role !== 'owner' && mem.role !== 'admin'))) { + return NextResponse.json( + { error: 'Unauthorized - user is not admin of organization' }, + { status: 403 } + ) + } + + await db + .update(subscription) + .set({ referenceId: organizationId }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Subscription transfer completed', { + subscriptionId, + organizationId, + userId: session.user.id, + }) + + return NextResponse.json({ + success: true, + message: 'Subscription transferred successfully', + }) + } catch (error) { + logger.error('Error transferring subscription', { + error: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/user/transfer-subscription/route.ts b/apps/sim/app/api/user/transfer-subscription/route.ts deleted file mode 100644 index 63f9be68bf..0000000000 --- a/apps/sim/app/api/user/transfer-subscription/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { eq } from 'drizzle-orm' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import * as schema from '@/db/schema' - -const logger = createLogger('TransferSubscriptionAPI') - -const transferSubscriptionSchema = z.object({ - subscriptionId: z.string().uuid(), - organizationId: z.string().uuid(), -}) - -export async function POST(request: NextRequest) { - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn('Unauthorized subscription transfer attempt') - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const validationResult = transferSubscriptionSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationResult.error.format(), - }, - { status: 400 } - ) - } - - const { subscriptionId, organizationId } = validationResult.data - - logger.info('Transferring subscription to organization', { - userId: session.user.id, - subscriptionId, - organizationId, - }) - - const subscription = await db - .select() - .from(schema.subscription) - .where(eq(schema.subscription.id, subscriptionId)) - .then((rows) => rows[0]) - - if (!subscription) { - logger.warn('Subscription not found', { subscriptionId }) - return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) - } - - if (subscription.referenceId !== session.user.id) { - logger.warn('Unauthorized subscription transfer - subscription does not belong to user', { - userId: session.user.id, - subscriptionReferenceId: subscription.referenceId, - }) - return NextResponse.json( - { error: 'Unauthorized - subscription does not belong to user' }, - { status: 403 } - ) - } - - const organization = await db - .select() - .from(schema.organization) - .where(eq(schema.organization.id, organizationId)) - .then((rows) => rows[0]) - - if (!organization) { - logger.warn('Organization not found', { organizationId }) - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } - - const member = await db - .select() - .from(schema.member) - .where( - eq(schema.member.userId, session.user.id) && - eq(schema.member.organizationId, organizationId) - ) - .then((rows) => rows[0]) - - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - logger.warn('Unauthorized subscription transfer - user is not admin of organization', { - userId: session.user.id, - organizationId, - memberRole: member?.role, - }) - return NextResponse.json( - { error: 'Unauthorized - user is not admin of organization' }, - { status: 403 } - ) - } - - await db - .update(schema.subscription) - .set({ referenceId: organizationId }) - .where(eq(schema.subscription.id, subscriptionId)) - - logger.info('Successfully transferred subscription to organization', { - subscriptionId, - organizationId, - userId: session.user.id, - }) - - return NextResponse.json({ - success: true, - message: 'Subscription transferred successfully', - }) - } catch (error) { - logger.error('Error transferring subscription', { error }) - return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 3dbbd00454..5cec995a27 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -184,13 +184,11 @@ export async function POST(request: NextRequest) { // --- Gmail webhook setup --- if (savedWebhook && provider === 'gmail') { - logger.info( - `[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.` - ) + logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) try { const { configureGmailPolling } = await import('@/lib/webhooks/utils') const success = await configureGmailPolling(userId, savedWebhook, requestId) - + if (!success) { logger.error(`[${requestId}] Failed to configure Gmail polling`) return NextResponse.json( @@ -201,7 +199,7 @@ export async function POST(request: NextRequest) { { status: 500 } ) } - + logger.info(`[${requestId}] Successfully configured Gmail polling`) } catch (err) { logger.error(`[${requestId}] Error setting up Gmail webhook configuration`, err) @@ -390,7 +388,7 @@ async function createTelegramWebhookSubscription( method: 'POST', headers: { 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0' + 'User-Agent': 'TelegramBot/1.0', }, body: JSON.stringify(requestBody), }) @@ -409,28 +407,28 @@ async function createTelegramWebhookSubscription( logger.info( `[${requestId}] Successfully created Telegram webhook for webhook ${webhookData.id}.` ) - + // Get webhook info to ensure it's properly set up try { const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo` const webhookInfo = await fetch(webhookInfoUrl, { headers: { - 'User-Agent': 'TelegramBot/1.0' - } - }); - const webhookInfoJson = await webhookInfo.json(); - + 'User-Agent': 'TelegramBot/1.0', + }, + }) + const webhookInfoJson = await webhookInfo.json() + if (webhookInfoJson.ok) { logger.info(`[${requestId}] Telegram webhook info:`, { url: webhookInfoJson.result.url, has_custom_certificate: webhookInfoJson.result.has_custom_certificate, pending_update_count: webhookInfoJson.result.pending_update_count, - webhookId: webhookData.id - }); + webhookId: webhookData.id, + }) } } catch (error) { // Non-critical error, just log - logger.warn(`[${requestId}] Failed to get webhook info`, error); + logger.warn(`[${requestId}] Failed to get webhook info`, error) } } catch (error: any) { logger.error( diff --git a/apps/sim/app/api/webhooks/test/route.ts b/apps/sim/app/api/webhooks/test/route.ts index 77a21cf729..b4a7f80c0e 100644 --- a/apps/sim/app/api/webhooks/test/route.ts +++ b/apps/sim/app/api/webhooks/test/route.ts @@ -146,23 +146,23 @@ export async function GET(request: NextRequest) { message_id: 67890, from: { id: 123456789, - first_name: "Test", - username: "testbot" + first_name: 'Test', + username: 'testbot', }, chat: { id: 123456789, - first_name: "Test", - username: "testbot", - type: "private" + first_name: 'Test', + username: 'testbot', + type: 'private', }, date: Math.floor(Date.now() / 1000), - text: "This is a test message" - } + text: 'This is a test message', + }, } logger.debug(`[${requestId}] Testing Telegram webhook connection`, { webhookId, - url: webhookUrl + url: webhookUrl, }) // Make a test request to the webhook endpoint @@ -170,16 +170,16 @@ export async function GET(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0' + 'User-Agent': 'TelegramBot/1.0', }, - body: JSON.stringify(testMessage) + body: JSON.stringify(testMessage), }) // Get the response details const status = response.status - let responseText = ''; + let responseText = '' try { - responseText = await response.text(); + responseText = await response.text() } catch (e) { // Ignore if we can't get response text } @@ -192,7 +192,7 @@ export async function GET(request: NextRequest) { } else { logger.warn(`[${requestId}] Telegram webhook test failed: ${webhookId}`, { status, - responseText + responseText, }) } @@ -202,8 +202,8 @@ export async function GET(request: NextRequest) { const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo` const infoResponse = await fetch(webhookInfoUrl, { headers: { - 'User-Agent': 'TelegramBot/1.0' - } + 'User-Agent': 'TelegramBot/1.0', + }, }) if (infoResponse.ok) { const infoJson = await infoResponse.json() @@ -220,7 +220,7 @@ export async function GET(request: NextRequest) { `curl -X POST "${webhookUrl}"`, `-H "Content-Type: application/json"`, `-H "User-Agent: TelegramBot/1.0"`, - `-d '${JSON.stringify(testMessage, null, 2)}'` + `-d '${JSON.stringify(testMessage, null, 2)}'`, ].join(' \\\n') return NextResponse.json({ @@ -230,18 +230,18 @@ export async function GET(request: NextRequest) { url: webhookUrl, botToken: `${botToken.substring(0, 5)}...${botToken.substring(botToken.length - 5)}`, // Show partial token for security triggerPhrase, - isActive: foundWebhook.isActive + isActive: foundWebhook.isActive, }, test: { status, responseText, - webhookInfo + webhookInfo, }, message: success ? 'Telegram webhook appears to be working. Your bot should now receive messages.' : 'Telegram webhook test failed. Please check server logs for more details.', curlCommand, - info: "To fix issues with Telegram webhooks getting 403 Forbidden responses, ensure the webhook request includes a User-Agent header." + info: 'To fix issues with Telegram webhooks getting 403 Forbidden responses, ensure the webhook request includes a User-Agent header.', }) } diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 625cfae900..c1ddc249d1 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -196,16 +196,19 @@ export async function POST( logger.info(`[${requestId}] Received Telegram webhook request:`, { userAgent, path, - clientIp: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown', + clientIp: + request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown', method: request.method, contentType: request.headers.get('content-type'), - hasUpdate: !!body?.update_id + hasUpdate: !!body?.update_id, }) // Ensure User-Agent headers for Telegram in future requests from the bot // We can't modify the incoming request, but we can recommend adding it for future setup if (!userAgent || userAgent === 'empty') { - logger.warn(`[${requestId}] Telegram webhook request missing User-Agent header. Recommend reconfiguring webhook with 'TelegramBot/1.0' User-Agent.`) + logger.warn( + `[${requestId}] Telegram webhook request missing User-Agent header. Recommend reconfiguring webhook with 'TelegramBot/1.0' User-Agent.` + ) } } diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index 8b853ee372..81d9a72ba6 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -26,7 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ validation.workflow.deployedState as any ) } - + return createSuccessResponse({ isDeployed: validation.workflow.isDeployed, deployedAt: validation.workflow.deployedAt, diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index df1934c50b..64f894cf23 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -87,14 +87,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // Get existing variables to merge with the incoming ones const existingVariables = (workflowRecord[0].variables as Record) || {} - + // Create a timestamp based on the current request - + // Merge variables: Keep existing ones and update/add new ones // This prevents variables from being deleted during race conditions const mergedVariables = { ...existingVariables, - ...variablesRecord + ...variablesRecord, } // Update workflow with variables diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index 5f9d45a66b..bd6cdef158 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -48,37 +48,40 @@ const SyncPayloadSchema = z.object({ }) // Cache for workspace membership to reduce DB queries -const workspaceMembershipCache = new Map(); -const CACHE_TTL = 60000; // 1 minute cache expiration -const MAX_CACHE_SIZE = 1000; // Maximum number of entries to prevent unbounded growth +const workspaceMembershipCache = new Map() +const CACHE_TTL = 60000 // 1 minute cache expiration +const MAX_CACHE_SIZE = 1000 // Maximum number of entries to prevent unbounded growth /** * Cleans up expired entries from the workspace membership cache */ function cleanupExpiredCacheEntries(): void { - const now = Date.now(); - let expiredCount = 0; - + const now = Date.now() + let expiredCount = 0 + // Remove expired entries for (const [key, value] of workspaceMembershipCache.entries()) { if (value.expires <= now) { - workspaceMembershipCache.delete(key); - expiredCount++; + workspaceMembershipCache.delete(key) + expiredCount++ } } - + // If we're still over the limit after removing expired entries, // remove the oldest entries (those that will expire soonest) if (workspaceMembershipCache.size > MAX_CACHE_SIZE) { - const entries = Array.from(workspaceMembershipCache.entries()) - .sort((a, b) => a[1].expires - b[1].expires); - - const toRemove = entries.slice(0, workspaceMembershipCache.size - MAX_CACHE_SIZE); - toRemove.forEach(([key]) => workspaceMembershipCache.delete(key)); - - logger.debug(`Cache cleanup: removed ${expiredCount} expired entries and ${toRemove.length} additional entries due to size limit`); + const entries = Array.from(workspaceMembershipCache.entries()).sort( + (a, b) => a[1].expires - b[1].expires + ) + + const toRemove = entries.slice(0, workspaceMembershipCache.size - MAX_CACHE_SIZE) + toRemove.forEach(([key]) => workspaceMembershipCache.delete(key)) + + logger.debug( + `Cache cleanup: removed ${expiredCount} expired entries and ${toRemove.length} additional entries due to size limit` + ) } else if (expiredCount > 0) { - logger.debug(`Cache cleanup: removed ${expiredCount} expired entries`); + logger.debug(`Cache cleanup: removed ${expiredCount} expired entries`) } } @@ -88,46 +91,46 @@ function cleanupExpiredCacheEntries(): void { * @param workspaceId Workspace ID to check * @returns Role if user is a member, null otherwise */ -async function verifyWorkspaceMembership(userId: string, workspaceId: string): Promise { +async function verifyWorkspaceMembership( + userId: string, + workspaceId: string +): Promise { // Opportunistic cleanup of expired cache entries if (workspaceMembershipCache.size > MAX_CACHE_SIZE / 2) { - cleanupExpiredCacheEntries(); + cleanupExpiredCacheEntries() } - + // Create cache key from userId and workspaceId - const cacheKey = `${userId}:${workspaceId}`; - + const cacheKey = `${userId}:${workspaceId}` + // Check cache first - const cached = workspaceMembershipCache.get(cacheKey); + const cached = workspaceMembershipCache.get(cacheKey) if (cached && cached.expires > Date.now()) { - return cached.role; + return cached.role } - + // If not in cache or expired, query the database try { const membership = await db .select({ role: workspaceMember.role }) .from(workspaceMember) - .where(and( - eq(workspaceMember.workspaceId, workspaceId), - eq(workspaceMember.userId, userId) - )) - .then((rows) => rows[0]); - + .where(and(eq(workspaceMember.workspaceId, workspaceId), eq(workspaceMember.userId, userId))) + .then((rows) => rows[0]) + if (!membership) { - return null; + return null } - + // Cache the result workspaceMembershipCache.set(cacheKey, { role: membership.role, - expires: Date.now() + CACHE_TTL - }); - - return membership.role; + expires: Date.now() + CACHE_TTL, + }) + + return membership.role } catch (error) { - logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error); - return null; + logger.error(`Error verifying workspace membership for ${userId} in ${workspaceId}:`, error) + return null } } @@ -168,7 +171,7 @@ export async function GET(request: Request) { // Verify the user is a member of the workspace using our optimized function const userRole = await verifyWorkspaceMembership(userId, workspaceId) - + if (!userRole) { logger.warn( `[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership` @@ -180,7 +183,7 @@ export async function GET(request: Request) { } // Migrate any orphaned workflows to this workspace (in background) - migrateOrphanedWorkflows(userId, workspaceId).catch(error => { + migrateOrphanedWorkflows(userId, workspaceId).catch((error) => { logger.error(`[${requestId}] Error migrating orphaned workflows:`, error) }) } @@ -191,18 +194,17 @@ export async function GET(request: Request) { if (workspaceId) { // Filter by workspace ID only, not user ID // This allows sharing workflows across workspace members - workflows = await db - .select() - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId)) } else { // Filter by user ID only, including workflows without workspace IDs workflows = await db.select().from(workflow).where(eq(workflow.userId, userId)) } const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Workflow fetch completed in ${elapsed}ms for ${workflows.length} workflows`) - + logger.info( + `[${requestId}] Workflow fetch completed in ${elapsed}ms for ${workflows.length} workflows` + ) + // Return the workflows return NextResponse.json({ data: workflows }, { status: 200 }) } catch (error: any) { @@ -239,11 +241,13 @@ async function migrateOrphanedWorkflows(userId: string, workspaceId: string) { updatedAt: new Date(), }) .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) - - logger.info(`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`) + + logger.info( + `Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}` + ) } catch (batchError) { logger.warn('Batch migration failed, falling back to individual updates:', batchError) - + // Fallback to individual updates if batch update fails for (const { id } of orphanedWorkflows) { try { @@ -316,8 +320,8 @@ export async function POST(req: NextRequest) { } // Validate workspace membership and permissions - let userRole: string | null = null; - + let userRole: string | null = null + if (workspaceId) { const workspaceExists = await db .select({ id: workspace.id }) @@ -357,10 +361,7 @@ export async function POST(req: NextRequest) { let dbWorkflows if (workspaceId) { - dbWorkflows = await db - .select() - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + dbWorkflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId)) } else { dbWorkflows = await db.select().from(workflow).where(eq(workflow.userId, session.user.id)) } @@ -405,16 +406,17 @@ export async function POST(req: NextRequest) { ) } else { // Check if user has permission to update this workflow - const canUpdate = dbWorkflow.userId === session.user.id || - (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')); - + const canUpdate = + dbWorkflow.userId === session.user.id || + (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')) + if (!canUpdate) { logger.warn( `[${requestId}] User ${session.user.id} attempted to update workflow ${id} without permission` ) - continue; // Skip this workflow update and move to the next one + continue // Skip this workflow update and move to the next one } - + // Existing workflow - update if needed const needsUpdate = JSON.stringify(dbWorkflow.state) !== JSON.stringify(clientWorkflow.state) || @@ -454,9 +456,10 @@ export async function POST(req: NextRequest) { ) { // Check if the user has permission to delete this workflow // Users can delete their own workflows, or any workflow if they're a workspace owner/admin - const canDelete = dbWorkflow.userId === session.user.id || - (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')); - + const canDelete = + dbWorkflow.userId === session.user.id || + (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')) + if (canDelete) { operations.push(db.delete(workflow).where(eq(workflow.id, dbWorkflow.id))) } else { @@ -471,14 +474,14 @@ export async function POST(req: NextRequest) { await Promise.all(operations) const elapsed = Date.now() - startTime - - return NextResponse.json({ + + return NextResponse.json({ success: true, stats: { elapsed, operations: operations.length, - workflows: Object.keys(clientWorkflows).length - } + workflows: Object.keys(clientWorkflows).length, + }, }) } catch (validationError) { if (validationError instanceof z.ZodError) { diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts index 4205960a82..3f683e15bf 100644 --- a/apps/sim/app/api/workspaces/invitations/accept/route.ts +++ b/apps/sim/app/api/workspaces/invitations/accept/route.ts @@ -1,94 +1,133 @@ -import { and, eq } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' import { randomUUID } from 'crypto' +import { and, eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { db } from '@/db' -import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema' +import { user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema' // GET /api/workspaces/invitations/accept - Accept an invitation via token export async function GET(req: NextRequest) { const token = req.nextUrl.searchParams.get('token') - + if (!token) { // Redirect to a page explaining the error - return NextResponse.redirect(new URL('/invite/invite-error?reason=missing-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + '/invite/invite-error?reason=missing-token', + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + const session = await getSession() - + if (!session?.user?.id) { // No need to encode API URL as callback, just redirect to invite page // The middleware will handle proper login flow and return to invite page - return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + `/invite/${token}?token=${token}`, + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + try { // Find the invitation by token const invitation = await db .select() .from(workspaceInvitation) .where(eq(workspaceInvitation.token, token)) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (!invitation) { - return NextResponse.redirect(new URL('/invite/invite-error?reason=invalid-token', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + '/invite/invite-error?reason=invalid-token', + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + // Check if invitation has expired if (new Date() > new Date(invitation.expiresAt)) { - return NextResponse.redirect(new URL('/invite/invite-error?reason=expired', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + '/invite/invite-error?reason=expired', + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + // Check if invitation is already accepted if (invitation.status !== 'pending') { - return NextResponse.redirect(new URL('/invite/invite-error?reason=already-processed', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + '/invite/invite-error?reason=already-processed', + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + // Get the user's email from the session const userEmail = session.user.email.toLowerCase() const invitationEmail = invitation.email.toLowerCase() - + // Check if the logged-in user's email matches the invitation // We'll use exact matching as the primary check const isExactMatch = userEmail === invitationEmail - + // For SSO or company email variants, check domain and normalized username // This handles cases like john.doe@company.com vs john@company.com const normalizeUsername = (email: string): string => { - return email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '').toLowerCase() + return email + .split('@')[0] + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() } - + const isSameDomain = userEmail.split('@')[1] === invitationEmail.split('@')[1] const normalizedUserEmail = normalizeUsername(userEmail) const normalizedInvitationEmail = normalizeUsername(invitationEmail) - const isSimilarUsername = normalizedUserEmail === normalizedInvitationEmail || - (normalizedUserEmail.includes(normalizedInvitationEmail) || - normalizedInvitationEmail.includes(normalizedUserEmail)) - + const isSimilarUsername = + normalizedUserEmail === normalizedInvitationEmail || + normalizedUserEmail.includes(normalizedInvitationEmail) || + normalizedInvitationEmail.includes(normalizedUserEmail) + const isValidMatch = isExactMatch || (isSameDomain && isSimilarUsername) - + if (!isValidMatch) { // Get user info to include in the error message const userData = await db .select() .from(user) .where(eq(user.id, session.user.id)) - .then(rows => rows[0]) - - return NextResponse.redirect(new URL(`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData?.email || session.user.email}`)}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + .then((rows) => rows[0]) + + return NextResponse.redirect( + new URL( + `/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData?.email || session.user.email}`)}`, + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + // Get the workspace details const workspaceDetails = await db .select() .from(workspace) .where(eq(workspace.id, invitation.workspaceId)) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (!workspaceDetails) { - return NextResponse.redirect(new URL('/invite/invite-error?reason=workspace-not-found', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + '/invite/invite-error?reason=workspace-not-found', + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + // Check if user is already a member const existingMembership = await db .select() @@ -99,8 +138,8 @@ export async function GET(req: NextRequest) { eq(workspaceMember.userId, session.user.id) ) ) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (existingMembership) { // User is already a member, just mark the invitation as accepted and redirect await db @@ -110,22 +149,25 @@ export async function GET(req: NextRequest) { updatedAt: new Date(), }) .where(eq(workspaceInvitation.id, invitation.id)) - - return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + + return NextResponse.redirect( + new URL( + `/w/${invitation.workspaceId}`, + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } - + // Add user to workspace - await db - .insert(workspaceMember) - .values({ - id: randomUUID(), - workspaceId: invitation.workspaceId, - userId: session.user.id, - role: invitation.role, - joinedAt: new Date(), - updatedAt: new Date(), - }) - + await db.insert(workspaceMember).values({ + id: randomUUID(), + workspaceId: invitation.workspaceId, + userId: session.user.id, + role: invitation.role, + joinedAt: new Date(), + updatedAt: new Date(), + }) + // Mark invitation as accepted await db .update(workspaceInvitation) @@ -134,11 +176,21 @@ export async function GET(req: NextRequest) { updatedAt: new Date(), }) .where(eq(workspaceInvitation.id, invitation.id)) - + // Redirect to the workspace - return NextResponse.redirect(new URL(`/w/${invitation.workspaceId}`, process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + `/w/${invitation.workspaceId}`, + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } catch (error) { console.error('Error accepting invitation:', error) - return NextResponse.redirect(new URL('/invite/invite-error?reason=server-error', process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')) + return NextResponse.redirect( + new URL( + '/invite/invite-error?reason=server-error', + process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + ) + ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/workspaces/invitations/details/route.ts b/apps/sim/app/api/workspaces/invitations/details/route.ts index 10f52ae07b..5d66e4c6dc 100644 --- a/apps/sim/app/api/workspaces/invitations/details/route.ts +++ b/apps/sim/app/api/workspaces/invitations/details/route.ts @@ -1,5 +1,5 @@ -import { and, eq } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' +import { and, eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { db } from '@/db' import { workspace, workspaceInvitation } from '@/db/schema' @@ -7,52 +7,52 @@ import { workspace, workspaceInvitation } from '@/db/schema' // GET /api/workspaces/invitations/details - Get invitation details by token export async function GET(req: NextRequest) { const token = req.nextUrl.searchParams.get('token') - + if (!token) { return NextResponse.json({ error: 'Token is required' }, { status: 400 }) } - + const session = await getSession() - + if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - + try { // Find the invitation by token const invitation = await db .select() .from(workspaceInvitation) .where(eq(workspaceInvitation.token, token)) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (!invitation) { return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) } - + // Check if invitation has expired if (new Date() > new Date(invitation.expiresAt)) { return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) } - + // Get workspace details const workspaceDetails = await db .select() .from(workspace) .where(eq(workspace.id, invitation.workspaceId)) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (!workspaceDetails) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - + // Return the invitation with workspace name return NextResponse.json({ ...invitation, - workspaceName: workspaceDetails.name + workspaceName: workspaceDetails.name, }) } catch (error) { console.error('Error fetching workspace invitation:', error) return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 63c5a0c696..800a8fccfa 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,12 +1,12 @@ -import { and, eq, sql, inArray } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { db } from '@/db' -import { workspace, workspaceMember, workspaceInvitation, user } from '@/db/schema' +import { render } from '@react-email/render' import { randomUUID } from 'crypto' +import { and, eq, inArray, sql } from 'drizzle-orm' import { Resend } from 'resend' import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation' -import { render } from '@react-email/render' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema' // Initialize Resend for email sending const resend = new Resend(process.env.RESEND_API_KEY) @@ -14,11 +14,11 @@ const resend = new Resend(process.env.RESEND_API_KEY) // GET /api/workspaces/invitations - Get all invitations for the user's workspaces export async function GET(req: NextRequest) { const session = await getSession() - + if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - + try { // First get all workspaces where the user is a member with owner role const userWorkspaces = await db @@ -32,22 +32,20 @@ export async function GET(req: NextRequest) { eq(workspaceMember.role, 'owner') ) ) - + if (userWorkspaces.length === 0) { return NextResponse.json({ invitations: [] }) } - + // Get all workspaceIds where the user is an owner - const workspaceIds = userWorkspaces.map(w => w.id) - + const workspaceIds = userWorkspaces.map((w) => w.id) + // Find all invitations for those workspaces const invitations = await db .select() .from(workspaceInvitation) - .where( - inArray(workspaceInvitation.workspaceId, workspaceIds) - ) - + .where(inArray(workspaceInvitation.workspaceId, workspaceIds)) + return NextResponse.json({ invitations }) } catch (error) { console.error('Error fetching workspace invitations:', error) @@ -58,18 +56,18 @@ export async function GET(req: NextRequest) { // POST /api/workspaces/invitations - Create a new invitation export async function POST(req: NextRequest) { const session = await getSession() - + if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - + try { const { workspaceId, email, role = 'member' } = await req.json() - + if (!workspaceId || !email) { return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 }) } - + // Check if user is authorized to invite to this workspace (must be owner) const membership = await db .select() @@ -80,31 +78,34 @@ export async function POST(req: NextRequest) { eq(workspaceMember.userId, session.user.id) ) ) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (!membership || membership.role !== 'owner') { - return NextResponse.json({ error: 'You are not authorized to invite to this workspace' }, { status: 403 }) + return NextResponse.json( + { error: 'You are not authorized to invite to this workspace' }, + { status: 403 } + ) } - + // Get the workspace details for the email const workspaceDetails = await db .select() .from(workspace) .where(eq(workspace.id, workspaceId)) - .then(rows => rows[0]) + .then((rows) => rows[0]) if (!workspaceDetails) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - + // Check if the user is already a member // First find if a user with this email exists const existingUser = await db .select() .from(user) .where(eq(user.email, email)) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (existingUser) { // Check if the user is already a member of this workspace const existingMembership = await db @@ -116,16 +117,19 @@ export async function POST(req: NextRequest) { eq(workspaceMember.userId, existingUser.id) ) ) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (existingMembership) { - return NextResponse.json({ - error: `${email} is already a member of this workspace`, - email - }, { status: 400 }) + return NextResponse.json( + { + error: `${email} is already a member of this workspace`, + email, + }, + { status: 400 } + ) } } - + // Check if there's already a pending invitation const existingInvitation = await db .select() @@ -137,20 +141,23 @@ export async function POST(req: NextRequest) { eq(workspaceInvitation.status, 'pending') ) ) - .then(rows => rows[0]) - + .then((rows) => rows[0]) + if (existingInvitation) { - return NextResponse.json({ - error: `${email} has already been invited to this workspace`, - email - }, { status: 400 }) + return NextResponse.json( + { + error: `${email} has already been invited to this workspace`, + email, + }, + { status: 400 } + ) } - + // Generate a unique token and set expiry date (1 week from now) const token = randomUUID() const expiresAt = new Date() expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry - + // Create the invitation const invitation = await db .insert(workspaceInvitation) @@ -167,8 +174,8 @@ export async function POST(req: NextRequest) { updatedAt: new Date(), }) .returning() - .then(rows => rows[0]) - + .then((rows) => rows[0]) + // Send the invitation email await sendInvitationEmail({ to: email, @@ -176,7 +183,7 @@ export async function POST(req: NextRequest) { workspaceName: workspaceDetails.name, token: token, }) - + return NextResponse.json({ success: true, invitation }) } catch (error) { console.error('Error creating workspace invitation:', error) @@ -185,22 +192,22 @@ export async function POST(req: NextRequest) { } // Helper function to send invitation email using the Resend API -async function sendInvitationEmail({ - to, - inviterName, - workspaceName, - token -}: { - to: string; - inviterName: string; - workspaceName: string; - token: string; +async function sendInvitationEmail({ + to, + inviterName, + workspaceName, + token, +}: { + to: string + inviterName: string + workspaceName: string + token: string }) { try { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' // Always use the client-side invite route with token parameter const invitationLink = `${baseUrl}/invite/${token}?token=${token}` - + const emailHtml = await render( WorkspaceInvitationEmail({ workspaceName, @@ -208,17 +215,17 @@ async function sendInvitationEmail({ invitationLink, }) ) - + await resend.emails.send({ from: process.env.RESEND_FROM_EMAIL || 'noreply@simstudio.ai', to, subject: `You've been invited to join "${workspaceName}" on Sim Studio`, html: emailHtml, }) - + console.log(`Invitation email sent to ${to}`) } catch (error) { console.error('Error sending invitation email:', error) // Continue even if email fails - the invitation is still created } -} \ No newline at end of file +} diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index f1d16a55fb..15289fc886 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -140,7 +140,6 @@ /* Custom Animations */ @layer utilities { - /* Animation containment to avoid layout shifts */ .animation-container { contain: paint layout style; @@ -206,11 +205,13 @@ @keyframes orbit { 0% { - transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg)); + transform: rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) + rotate(calc(var(--angle) * -1deg)); } 100% { - transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg)); + transform: rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) + rotate(calc((var(--angle) * -1deg) - 360deg)); } } @@ -241,19 +242,23 @@ .streaming-effect::after { content: ''; @apply pointer-events-none absolute left-0 top-0 h-full w-full; - background: linear-gradient(90deg, - rgba(128, 128, 128, 0) 0%, - rgba(128, 128, 128, 0.1) 50%, - rgba(128, 128, 128, 0) 100%); + background: linear-gradient( + 90deg, + rgba(128, 128, 128, 0) 0%, + rgba(128, 128, 128, 0.1) 50%, + rgba(128, 128, 128, 0) 100% + ); animation: code-shimmer 1.5s infinite; z-index: 10; } .dark .streaming-effect::after { - background: linear-gradient(90deg, - rgba(180, 180, 180, 0) 0%, - rgba(180, 180, 180, 0.1) 50%, - rgba(180, 180, 180, 0) 100%); + background: linear-gradient( + 90deg, + rgba(180, 180, 180, 0) 0%, + rgba(180, 180, 180, 0.1) 50%, + rgba(180, 180, 180, 0) 100% + ); } @keyframes fadeIn { @@ -298,7 +303,6 @@ input[type='search']::-ms-clear { /* Code Prompt Bar Placeholder Animation */ @keyframes placeholder-pulse { - 0%, 100% { opacity: 0.5; @@ -331,4 +335,4 @@ input[type='search']::-ms-clear { .main-content-overlay { z-index: 40; /* Higher z-index to appear above content */ -} \ No newline at end of file +} 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/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index b204eff80f..260238693c 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -87,10 +87,10 @@ const SCOPE_DESCRIPTIONS: Record = { 'read:user:jira': 'Read your Jira user', 'read:field-configuration:jira': 'Read your Jira field configuration', 'read:issue-details:jira': 'Read your Jira issue details', - 'identify': 'Read your Discord user', - 'bot': 'Read your Discord bot', + identify: 'Read your Discord user', + bot: 'Read your Discord bot', 'messages.read': 'Read your Discord messages', - 'guilds': 'Read your Discord guilds', + guilds: 'Read your Discord guilds', 'guilds.members.read': 'Read your Discord guild members', } 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/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index 14cfc12327..693ba46526 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -1,13 +1,13 @@ 'use client' import { useEffect, useState } from 'react' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { SubBlockConfig } from '@/blocks/types' import { ConfluenceFileInfo, ConfluenceFileSelector } from './components/confluence-file-selector' +import { DiscordChannelInfo, DiscordChannelSelector } from './components/discord-channel-selector' import { FileInfo, GoogleDrivePicker } from './components/google-drive-picker' import { JiraIssueInfo, JiraIssueSelector } from './components/jira-issue-selector' -import { DiscordChannelInfo, DiscordChannelSelector } from './components/discord-channel-selector' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' interface FileSelectorInputProps { blockId: string @@ -32,7 +32,8 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS // For Confluence and Jira, we need the domain and credentials const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : '' - const credentials = isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : '' + const credentials = + isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : '' // For Discord, we need the bot token and server ID const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : '' const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : '' 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/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx index bee8b39668..70126232af 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx @@ -97,7 +97,7 @@ export function DiscordServerSelector({ // Handle open change - only fetch servers when the dropdown is opened const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen) - + // Only fetch servers when opening the dropdown and if we have a valid token if (isOpen && botToken && (!initialFetchDone || servers.length === 0)) { fetchServers() @@ -108,10 +108,10 @@ export function DiscordServerSelector({ // This is more efficient than fetching all servers const fetchSelectedServerInfo = useCallback(async () => { if (!botToken || !selectedServerId) return - + setIsLoading(true) setError(null) - + try { // Only fetch the specific server by ID instead of all servers const response = await fetch('/api/auth/oauth/discord/servers', { @@ -119,17 +119,17 @@ export function DiscordServerSelector({ headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ + body: JSON.stringify({ botToken, - serverId: selectedServerId + serverId: selectedServerId, }), }) - + if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || 'Failed to fetch Discord server') } - + const data = await response.json() if (data.server) { setSelectedServer(data.server) @@ -160,10 +160,10 @@ export function DiscordServerSelector({ useEffect(() => { if (value !== selectedServerId) { setSelectedServerId(value) - + // Find server info for the new value if (value && servers.length > 0) { - const serverInfo = servers.find(server => server.id === value) + const serverInfo = servers.find((server) => server.id === value) setSelectedServer(serverInfo || null) } else if (value) { // If we have a value but no server info, we might need to fetch it @@ -314,13 +314,11 @@ export function DiscordServerSelector({

{selectedServer.name}

-
- Server ID: {selectedServer.id} -
+
Server ID: {selectedServer.id}
)} ) -} \ No newline at end of file +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx b/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx index bd48033329..3471f952e8 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx @@ -474,13 +474,13 @@ export function WorkflowBlock({ id, data }: NodeProps) { {config.docsLink ? ( - - + - + }} + disabled={isLoading} + > + Add Seat + + + )} ) : (
@@ -969,7 +999,7 @@ export function TeamManagement() {
{/* Pending Invitations - only show to admins/owners */} - {isAdminOrOwner && activeOrganization.invitations?.length > 0 && ( + {isAdminOrOwner && (activeOrganization.invitations?.length ?? 0) > 0 && (

Pending Invitations

@@ -1166,7 +1196,6 @@ export function TeamManagement() { ) } -// Skeleton component for team management loading state function TeamManagementSkeleton() { return (
@@ -1219,12 +1248,10 @@ function TeamManagementSkeleton() { ) } -// Skeleton component for loading state in buttons function ButtonSkeleton() { return } -// Skeleton component for loading state in team seats function TeamSeatsSkeleton() { return (
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index cb09565439..942107e350 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1359,13 +1359,24 @@ export function GoogleIcon(props: SVGProps) { export function DiscordIcon(props: SVGProps) { return ( - - - - - - - + + + + + ) } diff --git a/apps/sim/components/ui/command.tsx b/apps/sim/components/ui/command.tsx index 866a1f4849..553817a4d4 100644 --- a/apps/sim/components/ui/command.tsx +++ b/apps/sim/components/ui/command.tsx @@ -78,6 +78,15 @@ import { cn } from '@/lib/utils' // This file is not typed correctly from shadcn, so we're disabling the type checker // @ts-nocheck +// This file is not typed correctly from shadcn, so we're disabling the type checker +// @ts-nocheck + +// This file is not typed correctly from shadcn, so we're disabling the type checker +// @ts-nocheck + +// This file is not typed correctly from shadcn, so we're disabling the type checker +// @ts-nocheck + const Command = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { diff --git a/apps/sim/drizzle.config.ts b/apps/sim/drizzle.config.ts index 65f5503f06..8ca4d5358e 100644 --- a/apps/sim/drizzle.config.ts +++ b/apps/sim/drizzle.config.ts @@ -10,4 +10,4 @@ export default { dbCredentials: { url: process.env.DATABASE_URL!, }, -} satisfies Config \ No newline at end of file +} satisfies Config diff --git a/apps/sim/executor/resolver.ts b/apps/sim/executor/resolver.ts index 50cadf0a2f..a9cb2d6f91 100644 --- a/apps/sim/executor/resolver.ts +++ b/apps/sim/executor/resolver.ts @@ -244,7 +244,7 @@ export class InputResolver { try { // Handle 'string' type the same as 'plain' for backward compatibility const type = variable.type === 'string' ? 'plain' : variable.type - + // Use the centralized VariableManager to resolve variable values return VariableManager.resolveForExecution(variable.value, type) } catch (error) { @@ -271,12 +271,12 @@ export class InputResolver { try { // Handle 'string' type the same as 'plain' for backward compatibility const normalizedType = type === 'string' ? 'plain' : type - + // For plain text, use exactly what's entered without modifications if (normalizedType === 'plain' && typeof value === 'string') { return value } - + // Determine if this needs special handling for code contexts const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, String(value)) const isFunctionBlock = currentBlock?.metadata?.id === 'function' @@ -1081,7 +1081,7 @@ export class InputResolver { if (block.metadata.id === 'function') { return true } - + // Specifically for condition blocks, stringifyForCondition handles quoting // so we don't need extra quoting here unless it's within an expression. if (block.metadata.id === 'condition' && !expression) { diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 6ce904cf69..eea3af4bf8 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -868,15 +868,14 @@ export const auth = betterAuth({ organization({ // Allow team plan subscribers to create organizations allowUserToCreateOrganization: async (user) => { - // Get subscription data const dbSubscriptions = await db .select() .from(schema.subscription) .where(eq(schema.subscription.referenceId, user.id)) - // Check if user has active team subscription const hasTeamPlan = dbSubscriptions.some( - (sub) => sub.status === 'active' && sub.plan === 'team' + (sub) => + sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'enterprise') ) return hasTeamPlan @@ -885,7 +884,6 @@ export const auth = betterAuth({ membershipLimit: 50, // Validate seat limits before sending invitations beforeInvite: async ({ organization }: { organization: { id: string } }) => { - // Get subscription for this organization const subscriptions = await db .select() .from(schema.subscription) @@ -902,7 +900,6 @@ export const auth = betterAuth({ throw new Error('No active team subscription for this organization') } - // Count current members + pending invitations const members = await db .select() .from(schema.member) diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index 486ca8a9a5..f374f9790f 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -2,6 +2,7 @@ import { ReactNode } from 'react' import { AirtableIcon, ConfluenceIcon, + DiscordIcon, GithubIcon, GmailIcon, GoogleCalendarIcon, @@ -13,7 +14,6 @@ import { NotionIcon, SupabaseIcon, xIcon, - DiscordIcon, } from '@/components/icons' import { createLogger } from '@/lib/logs/console-logger' @@ -45,7 +45,7 @@ export type OAuthService = | 'notion' | 'jira' | 'discord' - + // Define the interface for OAuth provider configuration export interface OAuthProviderConfig { id: OAuthProvider @@ -251,13 +251,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'discord', icon: (props) => DiscordIcon(props), baseProviderIcon: (props) => DiscordIcon(props), - scopes: [ - 'identify', - 'bot', - 'messages.read', - 'guilds', - 'guilds.members.read', - ], + scopes: ['identify', 'bot', 'messages.read', 'guilds', 'guilds.members.read'], }, }, defaultService: 'discord', @@ -501,7 +495,12 @@ export async function refreshOAuthToken( } else { throw new Error('Both client ID and client secret are required for Airtable OAuth') } - } else if (provider === 'x' || provider === 'confluence' || provider === 'jira' || provider === 'discord') { + } else if ( + provider === 'x' || + provider === 'confluence' || + provider === 'jira' || + provider === 'discord' + ) { const authString = `${clientId}:${clientSecret}` const basicAuth = Buffer.from(authString).toString('base64') headers['Authorization'] = `Basic ${basicAuth}` diff --git a/apps/sim/lib/variables/variable-manager.ts b/apps/sim/lib/variables/variable-manager.ts index 1bea5cfb93..abe710abcc 100644 --- a/apps/sim/lib/variables/variable-manager.ts +++ b/apps/sim/lib/variables/variable-manager.ts @@ -226,9 +226,11 @@ export class VariableManager { return typeof value === 'string' ? value : String(value) } else if (type === 'string') { // For backwards compatibility, add quotes only for string type in code context - return typeof value === 'string' ? JSON.stringify(value) : this.formatValue(value, type, 'code') + return typeof value === 'string' + ? JSON.stringify(value) + : this.formatValue(value, type, 'code') } - + return this.formatValue(value, type, 'code') } diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index edabc5f63a..a04c5cd65b 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -679,24 +679,26 @@ export function verifyProviderWebhook( // Log the user agent for debugging purposes const userAgent = request.headers.get('user-agent') || '' logger.debug(`[${requestId}] Telegram webhook request received with User-Agent: ${userAgent}`) - + // Check if the user agent is empty and warn about it if (!userAgent) { - logger.warn(`[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.`) + logger.warn( + `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` + ) } // We'll accept the request anyway since we're in the provider-specific logic, // but we'll log the information for debugging - + // Telegram uses IP addresses in specific ranges // This is optional verification that could be added if IP verification is needed - const clientIp = + const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || request.headers.get('x-real-ip') || 'unknown' - + logger.debug(`[${requestId}] Telegram webhook request from IP: ${clientIp}`) - + break case 'generic': // Generic auth logic: requireAuth, token, secretHeaderName, allowedIps diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index 275b8aa020..70b4bebdcf 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -56,44 +56,43 @@ export async function middleware(request: NextRequest) { // Allow access to invitation links if (request.nextUrl.pathname.startsWith('/invite/')) { - // If this is an invitation and the user is not logged in, + // If this is an invitation and the user is not logged in, // and this isn't a login/signup-related request, redirect to login - if (!hasActiveSession && - !request.nextUrl.pathname.endsWith('/login') && - !request.nextUrl.pathname.endsWith('/signup') && - !request.nextUrl.search.includes('callbackUrl')) { - + if ( + !hasActiveSession && + !request.nextUrl.pathname.endsWith('/login') && + !request.nextUrl.pathname.endsWith('/signup') && + !request.nextUrl.search.includes('callbackUrl') + ) { // Prepare invitation URL for callback after login - const token = request.nextUrl.searchParams.get('token'); - const inviteId = request.nextUrl.pathname.split('/').pop(); - + const token = request.nextUrl.searchParams.get('token') + const inviteId = request.nextUrl.pathname.split('/').pop() + // Build the callback URL - retain the invitation path with token const callbackParam = encodeURIComponent( `/invite/${inviteId}${token ? `?token=${token}` : ''}` - ); - + ) + // Redirect to login with callback return NextResponse.redirect( new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url) - ); + ) } - - return NextResponse.next(); + + return NextResponse.next() } - + // Allow access to workspace invitation API endpoint if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) { // If the endpoint is for accepting an invitation and user is not logged in if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) { - const token = request.nextUrl.searchParams.get('token'); + const token = request.nextUrl.searchParams.get('token') if (token) { // Redirect to the client-side invite page instead of directly to login - return NextResponse.redirect( - new URL(`/invite/${token}?token=${token}`, request.url) - ); + return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url)) } } - return NextResponse.next(); + return NextResponse.next() } // Handle protected routes that require authentication @@ -115,10 +114,17 @@ export async function middleware(request: NextRequest) { } // Handle waitlist protection for login and signup in production - if (url.pathname === '/login' || url.pathname === '/signup' || - url.pathname === '/auth/login' || url.pathname === '/auth/signup') { + if ( + url.pathname === '/login' || + url.pathname === '/signup' || + url.pathname === '/auth/login' || + url.pathname === '/auth/signup' + ) { // If this is the login page and user has logged in before, allow access - if (hasPreviouslyLoggedIn && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/auth/login')) { + if ( + hasPreviouslyLoggedIn && + (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/auth/login') + ) { return NextResponse.next() } diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index a37be92ae9..98934c0f5e 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -8,12 +8,14 @@ import { useNotificationStore } from './notifications/store' import { useConsoleStore } from './panel/console/store' import { useVariablesStore } from './panel/variables/store' import { useEnvironmentStore } from './settings/environment/store' -import { - getSyncManagers, - initializeSyncManagers, - resetSyncManagers, - isSyncInitialized +import { + getSyncManagers, + initializeSyncManagers, + isSyncInitialized, + resetSyncManagers, } from './sync-registry' +// Import the syncWorkflows function directly +import { syncWorkflows } from './workflows' import { loadRegistry, loadSubblockValues, @@ -23,17 +25,10 @@ import { } from './workflows/persistence' import { useWorkflowRegistry } from './workflows/registry/store' import { useSubBlockStore } from './workflows/subblock/store' -import { - workflowSync, - isRegistryInitialized, - markWorkflowsDirty -} from './workflows/sync' +import { isRegistryInitialized, markWorkflowsDirty, workflowSync } from './workflows/sync' import { useWorkflowStore } from './workflows/workflow/store' import { BlockState } from './workflows/workflow/types' -// Import the syncWorkflows function directly -import { syncWorkflows } from './workflows' - const logger = createLogger('Stores') // Track initialization state @@ -53,7 +48,7 @@ async function initializeApplication(): Promise { isInitializing = true appFullyInitialized = false - + // Track initialization start time const initStartTime = Date.now() @@ -105,11 +100,11 @@ async function initializeApplication(): Promise { // 2. Register cleanup window.addEventListener('beforeunload', handleBeforeUnload) - + // Log initialization timing information const initDuration = Date.now() - initStartTime logger.info(`Application initialization completed in ${initDuration}ms`) - + // Mark application as fully initialized appFullyInitialized = true } catch (error) { @@ -125,7 +120,7 @@ async function initializeApplication(): Promise { * Checks if application is fully initialized */ export function isAppInitialized(): boolean { - return appFullyInitialized && isRegistryInitialized() && isSyncInitialized(); + return appFullyInitialized && isRegistryInitialized() && isSyncInitialized() } function initializeWorkflowState(workflowId: string): void { @@ -200,7 +195,7 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void { } // Mark workflows as dirty to ensure sync on exit - syncWorkflows(); + syncWorkflows() // 2. Final sync for managers that need it getSyncManagers() @@ -348,7 +343,7 @@ export async function reinitializeAfterLogin(): Promise { try { // Reset application initialization state appFullyInitialized = false - + // Reset sync managers to prevent any active syncs during reinitialization resetSyncManagers() @@ -491,8 +486,8 @@ function createFirstWorkflowWithAgentBlock(): void { saveWorkflowState(workflowId, updatedState) // Mark as dirty to ensure sync - syncWorkflows(); - + syncWorkflows() + // Resume sync managers after initialization setTimeout(() => { const syncManagers = getSyncManagers() diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index 8fb0573578..8d004b6422 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -173,18 +173,18 @@ export const useVariablesStore = create()( // Use the same debounced save mechanism as updateVariable const workflowId = variable.workflowId - + // Clear existing timer for this workflow if it exists if (saveTimers.has(workflowId)) { clearTimeout(saveTimers.get(workflowId)) } - + // Set new debounced save timer const timer = setTimeout(() => { get().saveVariables(workflowId) saveTimers.delete(workflowId) }, SAVE_DEBOUNCE_DELAY) - + saveTimers.set(workflowId, timer) return id @@ -290,14 +290,14 @@ export const useVariablesStore = create()( if (update.type === 'string') { update = { ...update, type: 'plain' } } - + // Create updated variable to check for validation const updatedVariable: Variable = { ...state.variables[id], ...update, validationError: undefined, // Initialize property to be updated later } - + // If the type or value changed, check for validation errors if (update.type || update.value !== undefined) { // Only add validation feedback - never modify the value @@ -341,13 +341,13 @@ export const useVariablesStore = create()( if (saveTimers.has(workflowId)) { clearTimeout(saveTimers.get(workflowId)) } - + // Set new debounced save timer const timer = setTimeout(() => { get().saveVariables(workflowId) saveTimers.delete(workflowId) }, SAVE_DEBOUNCE_DELAY) - + saveTimers.set(workflowId, timer) return { variables: rest } @@ -372,7 +372,7 @@ export const useVariablesStore = create()( uniqueName = `${baseName} (${nameIndex})` nameIndex++ } - + // Mark this duplicated variable as recently added recentlyAddedVariables.set(newId, Date.now()) @@ -391,18 +391,18 @@ export const useVariablesStore = create()( // Use the same debounced save mechanism const workflowId = variable.workflowId - + // Clear existing timer for this workflow if it exists if (saveTimers.has(workflowId)) { clearTimeout(saveTimers.get(workflowId)) } - + // Set new debounced save timer const timer = setTimeout(() => { get().saveVariables(workflowId) saveTimers.delete(workflowId) }, SAVE_DEBOUNCE_DELAY) - + saveTimers.set(workflowId, timer) return newId @@ -413,20 +413,22 @@ export const useVariablesStore = create()( // we check for the special case of recently added variables first if (loadedWorkflows.has(workflowId)) { // Even if workflow is loaded, check if we have recent variables to protect - const workflowVariables = Object.values(get().variables) - .filter((v) => v.workflowId === workflowId) - - const now = Date.now() - const hasRecentVariables = workflowVariables.some(v => - recentlyAddedVariables.has(v.id) && - (now - (recentlyAddedVariables.get(v.id) || 0) < RECENT_VARIABLE_WINDOW) + const workflowVariables = Object.values(get().variables).filter( + (v) => v.workflowId === workflowId ) - + + const now = Date.now() + const hasRecentVariables = workflowVariables.some( + (v) => + recentlyAddedVariables.has(v.id) && + now - (recentlyAddedVariables.get(v.id) || 0) < RECENT_VARIABLE_WINDOW + ) + // No force reload needed if no recent variables and we've already loaded if (!hasRecentVariables) { return } - + // Otherwise continue and do a full load+merge to protect recent variables } @@ -438,23 +440,28 @@ export const useVariablesStore = create()( // Capture current variables for this workflow before we modify anything const currentWorkflowVariables = Object.values(get().variables) .filter((v) => v.workflowId === workflowId) - .reduce((acc, v) => { - acc[v.id] = v - return acc - }, {} as Record) - + .reduce( + (acc, v) => { + acc[v.id] = v + return acc + }, + {} as Record + ) + // Check which variables were recently added (within the last few seconds) const now = Date.now() const protectedVariableIds = new Set() - + // Identify variables that should be protected from being overwritten - Object.keys(currentWorkflowVariables).forEach(id => { + Object.keys(currentWorkflowVariables).forEach((id) => { // Protect recently added variables - if (recentlyAddedVariables.has(id) && - (now - (recentlyAddedVariables.get(id) || 0) < RECENT_VARIABLE_WINDOW)) { + if ( + recentlyAddedVariables.has(id) && + now - (recentlyAddedVariables.get(id) || 0) < RECENT_VARIABLE_WINDOW + ) { protectedVariableIds.add(id) } - + // Also protect variables that are currently being edited (have pending changes) if (saveTimers.has(workflowId)) { protectedVariableIds.add(id) @@ -475,9 +482,9 @@ export const useVariablesStore = create()( }, {} as Record ) - + // Add back protected variables that should not be removed - Object.keys(currentWorkflowVariables).forEach(id => { + Object.keys(currentWorkflowVariables).forEach((id) => { if (protectedVariableIds.has(id)) { otherVariables[id] = currentWorkflowVariables[id] } @@ -545,12 +552,12 @@ export const useVariablesStore = create()( }, {} as Record ) - + // Create the final variables object, prioritizing protected variables const finalVariables = { ...otherVariables, ...migratedData } - + // Restore any protected variables that shouldn't be overwritten - Object.keys(currentWorkflowVariables).forEach(id => { + Object.keys(currentWorkflowVariables).forEach((id) => { if (protectedVariableIds.has(id)) { finalVariables[id] = currentWorkflowVariables[id] } @@ -576,9 +583,9 @@ export const useVariablesStore = create()( }, {} as Record ) - + // Add back protected variables that should not be removed - Object.keys(currentWorkflowVariables).forEach(id => { + Object.keys(currentWorkflowVariables).forEach((id) => { if (protectedVariableIds.has(id)) { otherVariables[id] = currentWorkflowVariables[id] } @@ -617,9 +624,9 @@ export const useVariablesStore = create()( const workflowVariables = Object.values(get().variables).filter( (variable) => variable.workflowId === workflowId ) - + // Record the last save attempt timestamp for each variable to track sync state - workflowVariables.forEach(variable => { + workflowVariables.forEach((variable) => { // Mark save attempt time for all variables being saved recentlyAddedVariables.set(variable.id, Date.now()) }) @@ -671,7 +678,7 @@ export const useVariablesStore = create()( // Reset the loaded workflow tracking resetLoaded: () => { loadedWorkflows.clear() - + // Clean up stale entries from recentlyAddedVariables const now = Date.now() recentlyAddedVariables.forEach((timestamp, id) => { diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts index 4c25178941..1046fe6c92 100644 --- a/apps/sim/stores/sidebar/store.ts +++ b/apps/sim/stores/sidebar/store.ts @@ -40,4 +40,4 @@ export const useSidebarStore = create()( name: 'sidebar-state', } ) -) \ No newline at end of file +) diff --git a/apps/sim/stores/sync-core.ts b/apps/sim/stores/sync-core.ts index fe592bef7d..792368f1f2 100644 --- a/apps/sim/stores/sync-core.ts +++ b/apps/sim/stores/sync-core.ts @@ -36,7 +36,7 @@ export interface SyncConfig { syncInterval?: number onSyncSuccess?: (response: any) => void onSyncError?: (error: any) => void - + // Enhanced retry configuration maxRetries?: number retryBackoff?: number @@ -61,29 +61,29 @@ export interface SyncOperations { const syncState = { inProgress: new Map(), lastSyncTime: new Map(), -}; +} // Returns true if a particular endpoint is currently syncing export function isSyncing(endpoint: string): boolean { - return syncState.inProgress.get(endpoint) === true; + return syncState.inProgress.get(endpoint) === true } // Returns the timestamp of the last successful sync for an endpoint export function getLastSyncTime(endpoint: string): number | undefined { - return syncState.lastSyncTime.get(endpoint); + return syncState.lastSyncTime.get(endpoint) } // Performs sync operation with automatic retry export async function performSync(config: SyncConfig): Promise { // Skip if sync already in progress for this endpoint if (syncState.inProgress.get(config.endpoint)) { - logger.info(`Sync skipped - already in progress for ${config.endpoint}`); - return true; + logger.info(`Sync skipped - already in progress for ${config.endpoint}`) + return true } - + // Mark sync as in progress - syncState.inProgress.set(config.endpoint, true); - + syncState.inProgress.set(config.endpoint, true) + try { // In localStorage mode, just return success immediately - no need to sync to server if (isLocalStorageMode()) { @@ -94,9 +94,9 @@ export async function performSync(config: SyncConfig): Promise { message: 'Skipped sync in localStorage mode', }) } - + // Update last sync time - syncState.lastSyncTime.set(config.endpoint, Date.now()); + syncState.lastSyncTime.set(config.endpoint, Date.now()) return true } @@ -106,18 +106,18 @@ export async function performSync(config: SyncConfig): Promise { // Skip sync if the payload indicates it should be skipped if (payload && payload.skipSync === true) { // Release lock and return success - syncState.inProgress.set(config.endpoint, false); + syncState.inProgress.set(config.endpoint, false) return true } // Normal API sync flow with retries const result = await sendWithRetry(config.endpoint, payload, config) - + // If successful, update last sync time if (result) { - syncState.lastSyncTime.set(config.endpoint, Date.now()); + syncState.lastSyncTime.set(config.endpoint, Date.now()) } - + return result } catch (error) { if (config.onSyncError) { @@ -127,62 +127,64 @@ export async function performSync(config: SyncConfig): Promise { return false } finally { // Always release the lock when done - syncState.inProgress.set(config.endpoint, false); + syncState.inProgress.set(config.endpoint, false) } } // Sends data to endpoint with configurable retries async function sendWithRetry(endpoint: string, payload: any, config: SyncConfig): Promise { - const maxRetries = config.maxRetries || DEFAULT_SYNC_CONFIG.maxRetries || 3; - const baseBackoff = config.retryBackoff || DEFAULT_SYNC_CONFIG.retryBackoff || 1000; - - let lastError: Error | null = null; - + const maxRetries = config.maxRetries || DEFAULT_SYNC_CONFIG.maxRetries || 3 + const baseBackoff = config.retryBackoff || DEFAULT_SYNC_CONFIG.retryBackoff || 1000 + + let lastError: Error | null = null + for (let attempt = 0; attempt < maxRetries; attempt++) { try { - const startTime = Date.now(); - const result = await sendRequest(endpoint, payload, config); - const elapsed = Date.now() - startTime; - + const startTime = Date.now() + const result = await sendRequest(endpoint, payload, config) + const elapsed = Date.now() - startTime + if (result) { // Only log retries if they happened if (attempt > 0) { - logger.info(`Sync succeeded on attempt ${attempt + 1} for ${endpoint} after ${elapsed}ms`); + logger.info(`Sync succeeded on attempt ${attempt + 1} for ${endpoint} after ${elapsed}ms`) } - return true; + return true } } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - + lastError = error instanceof Error ? error : new Error(String(error)) + // Calculate exponential backoff with jitter - const jitter = Math.random() * 0.3 + 0.85; // Random between 0.85 and 1.15 - const backoff = baseBackoff * Math.pow(2, attempt) * jitter; - - logger.warn(`Sync attempt ${attempt + 1}/${maxRetries} failed for ${endpoint}. Retrying in ${Math.round(backoff)}ms: ${lastError.message}`); - + const jitter = Math.random() * 0.3 + 0.85 // Random between 0.85 and 1.15 + const backoff = baseBackoff * Math.pow(2, attempt) * jitter + + logger.warn( + `Sync attempt ${attempt + 1}/${maxRetries} failed for ${endpoint}. Retrying in ${Math.round(backoff)}ms: ${lastError.message}` + ) + // Only wait if we're going to retry if (attempt < maxRetries - 1) { - await new Promise(resolve => setTimeout(resolve, backoff)); + await new Promise((resolve) => setTimeout(resolve, backoff)) } } } - + // If we got here, all retries failed if (lastError) { if (config.onSyncError) { - config.onSyncError(lastError); + config.onSyncError(lastError) } - logger.error(`All ${maxRetries} sync attempts failed for ${endpoint}: ${lastError.message}`); + logger.error(`All ${maxRetries} sync attempts failed for ${endpoint}: ${lastError.message}`) } - - return false; + + return false } // Sends a single request to the endpoint async function sendRequest(endpoint: string, payload: any, config: SyncConfig): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout - + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout + try { const response = await fetch(endpoint, { method: config.method || 'POST', @@ -191,27 +193,27 @@ async function sendRequest(endpoint: string, payload: any, config: SyncConfig): signal: controller.signal, // Add cache control for GET requests to prevent caching cache: config.method === 'GET' ? 'no-store' : undefined, - }); + }) if (!response.ok) { - const errorText = await response.text().catch(() => 'Failed to read error response'); - throw new Error(`Sync failed: ${response.status} ${response.statusText} - ${errorText}`); + const errorText = await response.text().catch(() => 'Failed to read error response') + throw new Error(`Sync failed: ${response.status} ${response.statusText} - ${errorText}`) } - const data = await response.json(); + const data = await response.json() if (config.onSyncSuccess) { - config.onSyncSuccess(data); + config.onSyncSuccess(data) } - return true; + return true } catch (error) { // Handle abort (timeout) explicitly if (error instanceof DOMException && error.name === 'AbortError') { - throw new Error(`Sync request timed out after 30 seconds`); + throw new Error(`Sync request timed out after 30 seconds`) } - throw error; + throw error } finally { - clearTimeout(timeoutId); + clearTimeout(timeoutId) } } diff --git a/apps/sim/stores/sync-registry.ts b/apps/sim/stores/sync-registry.ts index 666ac39b96..eaa0815268 100644 --- a/apps/sim/stores/sync-registry.ts +++ b/apps/sim/stores/sync-registry.ts @@ -3,11 +3,11 @@ import { createLogger } from '@/lib/logs/console-logger' import { SyncManager } from './sync' import { isLocalStorageMode } from './sync-core' -import { - fetchWorkflowsFromDB, - workflowSync, - isRegistryInitialized, - resetRegistryInitialization +import { + fetchWorkflowsFromDB, + isRegistryInitialized, + resetRegistryInitialization, + workflowSync, } from './workflows/sync' const logger = createLogger('SyncRegistry') @@ -46,24 +46,24 @@ export async function initializeSyncManagers(): Promise { managers = [workflowSync] // Reset registry initialization state before fetching - resetRegistryInitialization(); + resetRegistryInitialization() // Fetch data from DB try { // Remove environment variables fetch await fetchWorkflowsFromDB() - + // Wait for a short period to ensure registry is properly initialized if (!isRegistryInitialized()) { - logger.info('Waiting for registry initialization to complete...'); - await new Promise(resolve => setTimeout(resolve, 500)); + logger.info('Waiting for registry initialization to complete...') + await new Promise((resolve) => setTimeout(resolve, 500)) } - + // Verify initialization complete if (!isRegistryInitialized()) { - logger.warn('Registry initialization may not have completed properly'); + logger.warn('Registry initialization may not have completed properly') } else { - logger.info('Registry initialization verified'); + logger.info('Registry initialization verified') } } catch (error) { logger.error('Error fetching data from DB:', { error }) diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index c6182031a2..5404b7d63b 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -147,7 +147,7 @@ export function getAllWorkflowsWithValues() { }, } } - + return result } @@ -156,9 +156,9 @@ export function getAllWorkflowsWithValues() { * This is a shortcut for other files to trigger sync operations */ export function syncWorkflows() { - const workflowStore = useWorkflowStore.getState(); - workflowStore.sync.markDirty(); - workflowStore.sync.forceSync(); + const workflowStore = useWorkflowStore.getState() + workflowStore.sync.markDirty() + workflowStore.sync.forceSync() } export { useWorkflowRegistry, useWorkflowStore, useSubBlockStore } diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 00230508aa..608c3595c6 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -11,11 +11,11 @@ import { saveWorkflowState, } from '../persistence' import { useSubBlockStore } from '../subblock/store' -import { - fetchWorkflowsFromDB, - workflowSync, +import { + fetchWorkflowsFromDB, + markWorkflowsDirty, resetRegistryInitialization, - markWorkflowsDirty + workflowSync, } from '../sync' import { useWorkflowStore } from '../workflow/store' import { WorkflowMetadata, WorkflowRegistry } from './types' @@ -27,8 +27,8 @@ const logger = createLogger('WorkflowRegistry') const ACTIVE_WORKSPACE_KEY = 'active-workspace-id' // Track workspace transitions to prevent race conditions -let isWorkspaceTransitioning = false; -const TRANSITION_TIMEOUT = 5000; // 5 seconds maximum for workspace transitions +let isWorkspaceTransitioning = false +const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions // Helps clean up any localStorage data that isn't needed for the current workspace function cleanupLocalStorageForWorkspace(workspaceId: string): void { @@ -164,16 +164,16 @@ function resetWorkflowStores() { * @param isTransitioning Whether workspace is currently transitioning */ function setWorkspaceTransitioning(isTransitioning: boolean): void { - isWorkspaceTransitioning = isTransitioning; - + isWorkspaceTransitioning = isTransitioning + // Set a safety timeout to prevent permanently stuck in transition state if (isTransitioning) { setTimeout(() => { if (isWorkspaceTransitioning) { - logger.warn('Forcing workspace transition to complete due to timeout'); - isWorkspaceTransitioning = false; + logger.warn('Forcing workspace transition to complete due to timeout') + isWorkspaceTransitioning = false } - }, TRANSITION_TIMEOUT); + }, TRANSITION_TIMEOUT) } } @@ -182,7 +182,7 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void { * @returns True if workspace is transitioning */ export function isWorkspaceInTransition(): boolean { - return isWorkspaceTransitioning; + return isWorkspaceTransitioning } export const useWorkflowRegistry = create()( @@ -214,15 +214,15 @@ export const useWorkflowRegistry = create()( } // Set transition state - setWorkspaceTransitioning(true); - + setWorkspaceTransitioning(true) + logger.info(`Switching from deleted workspace ${currentWorkspaceId} to ${newWorkspaceId}`) // Reset all workflow state resetWorkflowStores() - + // Reset registry initialization state - resetRegistryInitialization(); + resetRegistryInitialization() // Save to localStorage for persistence if (typeof window !== 'undefined') { @@ -244,9 +244,9 @@ export const useWorkflowRegistry = create()( // Clean up any stale localStorage data cleanupLocalStorageForWorkspace(newWorkspaceId) - + // End transition state - setWorkspaceTransitioning(false); + setWorkspaceTransitioning(false) }) .catch((error) => { logger.error('Error fetching workflows after workspace deletion:', { @@ -254,9 +254,9 @@ export const useWorkflowRegistry = create()( workspaceId: newWorkspaceId, }) set({ isLoading: false, error: 'Failed to load workspace data' }) - + // End transition state even on error - setWorkspaceTransitioning(false); + setWorkspaceTransitioning(false) }) }, @@ -268,23 +268,23 @@ export const useWorkflowRegistry = create()( if (id === currentWorkspaceId) { return } - + // Prevent multiple workspace transitions at once if (isWorkspaceTransitioning) { - logger.warn('Workspace already transitioning, ignoring new request'); - return; + logger.warn('Workspace already transitioning, ignoring new request') + return } // Set transition state - setWorkspaceTransitioning(true); + setWorkspaceTransitioning(true) logger.info(`Switching workspace from ${currentWorkspaceId} to ${id}`) // Reset all workflow state resetWorkflowStores() - + // Reset registry initialization state - resetRegistryInitialization(); + resetRegistryInitialization() // Save to localStorage for persistence if (typeof window !== 'undefined') { @@ -309,16 +309,16 @@ export const useWorkflowRegistry = create()( // Clean up any stale localStorage data for this workspace cleanupLocalStorageForWorkspace(id) - + // End transition state - setWorkspaceTransitioning(false); + setWorkspaceTransitioning(false) }) .catch((error) => { logger.error('Error fetching workflows for workspace:', { error, workspaceId: id }) set({ isLoading: false, error: 'Failed to load workspace data' }) - + // End transition state even on error - setWorkspaceTransitioning(false); + setWorkspaceTransitioning(false) }) }, @@ -638,8 +638,8 @@ export const useWorkflowRegistry = create()( } // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty(); - + useWorkflowStore.getState().sync.markDirty() + // Trigger sync useWorkflowStore.getState().sync.forceSync() @@ -721,8 +721,8 @@ export const useWorkflowRegistry = create()( } // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty(); - + useWorkflowStore.getState().sync.markDirty() + // Trigger sync useWorkflowStore.getState().sync.forceSync() @@ -755,7 +755,7 @@ export const useWorkflowRegistry = create()( } // Get the workspace ID from the source workflow or fall back to active workspace - const workspaceId = sourceWorkflow.workspaceId || (activeWorkspaceId || undefined) + const workspaceId = sourceWorkflow.workspaceId || activeWorkspaceId || undefined // Generate new workflow metadata const newWorkflow: WorkflowMetadata = { @@ -827,12 +827,14 @@ export const useWorkflowRegistry = create()( } // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty(); - + useWorkflowStore.getState().sync.markDirty() + // Trigger sync useWorkflowStore.getState().sync.forceSync() - logger.info(`Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}`) + logger.info( + `Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}` + ) return id }, @@ -862,8 +864,8 @@ export const useWorkflowRegistry = create()( }) // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty(); - + useWorkflowStore.getState().sync.markDirty() + // Sync deletion with database useWorkflowStore.getState().sync.forceSync() @@ -956,8 +958,8 @@ export const useWorkflowRegistry = create()( saveRegistry(updatedWorkflows) // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty(); - + useWorkflowStore.getState().sync.markDirty() + // Use PUT for workflow updates useWorkflowStore.getState().sync.forceSync() diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index 2bbebd065b..376566458b 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -24,7 +24,7 @@ const LOADING_TIMEOUT = 3000 // 3 seconds maximum loading time // Add registry initialization tracking let registryFullyInitialized = false -const REGISTRY_INIT_TIMEOUT = 10000; // 10 seconds maximum for registry initialization +const REGISTRY_INIT_TIMEOUT = 10000 // 10 seconds maximum for registry initialization /** * Checks if the system is currently in the process of loading data from the database @@ -50,7 +50,7 @@ export function isActivelyLoadingFromDB(): boolean { * @returns true if registry is initialized, false otherwise */ export function isRegistryInitialized(): boolean { - return registryFullyInitialized; + return registryFullyInitialized } /** @@ -58,21 +58,21 @@ export function isRegistryInitialized(): boolean { * Should be called only after all workflows have been loaded from DB */ function setRegistryInitialized(): void { - registryFullyInitialized = true; - logger.info('Workflow registry fully initialized'); + registryFullyInitialized = true + logger.info('Workflow registry fully initialized') } /** * Reset registry initialization state when needed (e.g., workspace switch, logout) */ export function resetRegistryInitialization(): void { - registryFullyInitialized = false; - logger.info('Workflow registry initialization reset'); + registryFullyInitialized = false + logger.info('Workflow registry initialization reset') } // Enhanced workflow state tracking -let lastWorkflowState: Record = {}; -let isDirty = false; +let lastWorkflowState: Record = {} +let isDirty = false /** * Checks if workflow state has actually changed since last sync @@ -81,43 +81,46 @@ let isDirty = false; */ function hasWorkflowChanges(currentState: Record): boolean { if (!currentState || Object.keys(currentState).length === 0) { - return false; // Empty state should not trigger sync + return false // Empty state should not trigger sync } - + if (Object.keys(lastWorkflowState).length === 0) { // First time check, mark as changed - lastWorkflowState = JSON.parse(JSON.stringify(currentState)); - return true; + lastWorkflowState = JSON.parse(JSON.stringify(currentState)) + return true } - + // Check if workflow count changed if (Object.keys(currentState).length !== Object.keys(lastWorkflowState).length) { - lastWorkflowState = JSON.parse(JSON.stringify(currentState)); - return true; + lastWorkflowState = JSON.parse(JSON.stringify(currentState)) + return true } - + // Deep comparison of workflow states - let hasChanges = false; + let hasChanges = false for (const [id, workflow] of Object.entries(currentState)) { - if (!lastWorkflowState[id] || JSON.stringify(workflow) !== JSON.stringify(lastWorkflowState[id])) { - hasChanges = true; - break; + if ( + !lastWorkflowState[id] || + JSON.stringify(workflow) !== JSON.stringify(lastWorkflowState[id]) + ) { + hasChanges = true + break } } - + if (hasChanges) { - lastWorkflowState = JSON.parse(JSON.stringify(currentState)); + lastWorkflowState = JSON.parse(JSON.stringify(currentState)) } - - return hasChanges; + + return hasChanges } /** * Mark workflows as dirty (changed) to force a sync */ export function markWorkflowsDirty(): void { - isDirty = true; - logger.info('Workflows marked as dirty, will sync on next opportunity'); + isDirty = true + logger.info('Workflows marked as dirty, will sync on next opportunity') } /** @@ -125,14 +128,14 @@ export function markWorkflowsDirty(): void { * @returns true if workflows are dirty and need syncing */ export function areWorkflowsDirty(): boolean { - return isDirty; + return isDirty } /** * Reset the dirty flag after a successful sync */ export function resetDirtyFlag(): void { - isDirty = false; + isDirty = false } /** @@ -144,8 +147,8 @@ export async function fetchWorkflowsFromDB(): Promise { try { // Reset registry initialization state - resetRegistryInitialization(); - + resetRegistryInitialization() + // Set loading state in registry useWorkflowRegistry.getState().setLoading(true) @@ -210,9 +213,9 @@ export async function fetchWorkflowsFromDB(): Promise { ) // Clear any existing workflows to ensure a clean state useWorkflowRegistry.setState({ workflows: {} }) - + // Mark registry as initialized even with empty data - setRegistryInitialized(); + setRegistryInitialized() return } @@ -321,7 +324,7 @@ export async function fetchWorkflowsFromDB(): Promise { useWorkflowRegistry.setState({ workflows: registryWorkflows }) // Capture initial state for change detection - lastWorkflowState = getAllWorkflowsWithValues(); + lastWorkflowState = getAllWorkflowsWithValues() // 9. Set the first workflow as active if there's no active workflow const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId @@ -337,14 +340,14 @@ export async function fetchWorkflowsFromDB(): Promise { logger.info(`Set first workflow ${firstWorkflowId} as active`) } } - + // Mark registry as fully initialized now that all data is loaded - setRegistryInitialized(); + setRegistryInitialized() } catch (error) { logger.error('Error fetching workflows from DB:', { error }) - + // Mark registry as initialized even on error to allow fallback mechanisms - setRegistryInitialized(); + setRegistryInitialized() } finally { // Reset the flag after a short delay to allow state to settle setTimeout(() => { @@ -365,7 +368,7 @@ export async function fetchWorkflowsFromDB(): Promise { if (workflowCount > 0 && activeWorkflowId && activeDBSyncNeeded()) { // Small delay for state to fully settle before allowing syncs setTimeout(() => { - isDirty = true; // Explicitly mark as dirty for first sync + isDirty = true // Explicitly mark as dirty for first sync workflowSync.sync() }, 500) } @@ -388,7 +391,7 @@ function activeDBSyncNeeded(): boolean { // Add additional checks here if needed for specific workflow changes // For now, we'll simply avoid the automatic sync after load - return isDirty; + return isDirty } // Create the basic sync configuration @@ -399,8 +402,8 @@ const workflowSyncConfig = { // Skip sync if registry is not fully initialized yet if (!isRegistryInitialized()) { - logger.info('Skipping workflow sync while registry is not fully initialized'); - return { skipSync: true }; + logger.info('Skipping workflow sync while registry is not fully initialized') + return { skipSync: true } } // Skip sync if we're currently loading from DB to prevent overwriting DB data @@ -414,12 +417,12 @@ const workflowSyncConfig = { // Only sync if there are actually changes if (!isDirty && !hasWorkflowChanges(allWorkflowsData)) { - logger.info('Skipping workflow sync - no changes detected'); - return { skipSync: true }; + logger.info('Skipping workflow sync - no changes detected') + return { skipSync: true } } - + // Reset dirty flag since we're about to sync - resetDirtyFlag(); + resetDirtyFlag() // Get the active workspace ID const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId @@ -486,12 +489,12 @@ export const workflowSync = { sync: () => { // Skip sync if not initialized if (!isRegistryInitialized()) { - logger.info('Sync requested but registry not fully initialized yet - delaying'); + logger.info('Sync requested but registry not fully initialized yet - delaying') // If we're not initialized, mark dirty and check again later - isDirty = true; - return; + isDirty = true + return } - + // Clear any existing timeout if (syncDebounceTimer) { clearTimeout(syncDebounceTimer) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index e7f74f1dea..d4ece4ef70 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -37,13 +37,13 @@ const initialState = { // Create a consolidated sync control implementation /** * The SyncControl implementation provides a clean, centralized way to handle workflow syncing. - * + * * This pattern offers several advantages: * 1. It encapsulates sync logic through a clear, standardized interface * 2. It allows components to mark workflows as dirty without direct dependencies * 3. It prevents race conditions by ensuring changes are properly tracked before syncing * 4. It centralizes sync decisions to avoid redundant or conflicting operations - * + * * Usage: * - Call markDirty() when workflow state changes but sync can be deferred * - Call forceSync() when an immediate sync to the server is needed @@ -61,7 +61,7 @@ const createSyncControl = (): SyncControl => ({ forceSync: () => { markWorkflowsDirty() // Always mark as dirty before forcing a sync workflowSync.sync() - } + }, }) export const useWorkflowStore = create()( diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index b1a1fbffbc..e409bbabc4 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -49,11 +49,11 @@ export interface WorkflowState { // New interface for sync control export interface SyncControl { // Mark the workflow as changed, requiring sync - markDirty: () => void; + markDirty: () => void // Check if the workflow has unsaved changes - isDirty: () => boolean; + isDirty: () => boolean // Immediately trigger a sync - forceSync: () => void; + forceSync: () => void } export interface WorkflowActions { @@ -78,7 +78,7 @@ export interface WorkflowActions { setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void setScheduleStatus: (hasActiveSchedule: boolean) => void setWebhookStatus: (hasActiveWebhook: boolean) => void - + // Add the sync control methods to the WorkflowActions interface sync: SyncControl } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index a4dc45b82a..88c1f552a4 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -8,6 +8,12 @@ import { autoblocksPromptManagerTool } from './autoblocks' import { browserUseRunTaskTool } from './browser_use' import { clayPopulateTool } from './clay' import { confluenceRetrieveTool, confluenceUpdateTool } from './confluence' +import { + discordGetMessagesTool, + discordGetServerTool, + discordGetUserTool, + discordSendMessageTool, +} from './discord' import { elevenLabsTtsTool } from './elevenlabs' import { exaAnswerTool, exaFindSimilarLinksTool, exaGetContentsTool, exaSearchTool } from './exa' import { fileParseTool } from './file' @@ -64,7 +70,6 @@ import { visionTool } from './vision' import { whatsappSendMessageTool } from './whatsapp' import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x' import { youtubeSearchTool } from './youtube' -import { discordGetMessagesTool, discordGetServerTool, discordGetUserTool, discordSendMessageTool } from './discord' // Registry of all available tools export const tools: Record = { @@ -157,4 +162,4 @@ export const tools: Record = { discord_get_messages: discordGetMessagesTool, discord_get_server: discordGetServerTool, discord_get_user: discordGetUserTool, -} +}