From e112425a026e471e8c5365a030c9f71c75032a77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 May 2025 08:29:12 -0700 Subject: [PATCH 1/3] fix(subscriptions): fixed organization creation failure introduced by subscription updates --- .../subscription/[id]/seats/route.test.ts | 295 +++++++++++++ .../{update-seats => [id]/seats}/route.ts | 110 +++-- .../subscription/[id]/transfer/route.test.ts | 297 +++++++++++++ .../user/subscription/[id]/transfer/route.ts | 114 +++++ .../api/user/transfer-subscription/route.ts | 119 ----- .../team-management/team-management.tsx | 413 ++++++++++-------- apps/sim/lib/auth.ts | 7 +- 7 files changed, 978 insertions(+), 377 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/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 00000000000..ff6444c04cd --- /dev/null +++ b/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts @@ -0,0 +1,295 @@ +/** + * Tests for Subscription Seats Update API + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@/app/api/__test-utils__/utils' + +describe('Subscription Seats Update API Routes', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + const mockSubscription = { + id: 'sub-123', + plan: 'enterprise', + status: 'active', + seats: 5, + referenceId: 'org-123', + metadata: { + perSeatAllowance: 100, + totalAllowance: 500, + updatedAt: '2023-01-01T00:00:00.000Z', + }, + } + + const mockTeamSubscription = { + id: 'sub-456', + plan: 'team', + status: 'active', + seats: 5, + referenceId: 'org-123', + } + + 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', + }, + } + + const mockUser = { + id: 'user-123', + email: 'test@example.com', + } + + const mockAdminMember = { + id: 'member-123', + userId: 'user-123', + organizationId: 'org-123', + role: 'admin', + } + + const mockRegularMember = { + id: 'member-456', + userId: 'user-123', + organizationId: 'org-123', + role: 'member', + } + + const mockDb = { + select: vi.fn(), + update: vi.fn(), + } + + beforeEach(() => { + vi.resetModules() + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: mockUser, + }), + })) + + vi.doMock('@/lib/subscription/utils', () => ({ + checkEnterprisePlan: vi.fn().mockImplementation((sub) => sub.plan === 'enterprise'), + })) + + 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([]), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('POST handler', () => { + it('should update subscription seats for enterprise plan', async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockSubscription]), + }) + + 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([mockAdminMember]), + }) + + 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(400) + expect(data).toHaveProperty('error') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should reject team plan subscription updates', async () => { + 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 allow personal subscription updates for the subscription owner', async () => { + 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(400) + expect(data).toHaveProperty('error') + }) + + it('should reject updates from non-admin members', 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([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(400) + 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', 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(400) + 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 4e4b27c6205..7e059d7c51b 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 { 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) - - hasPermission = memberships.length > 0 - - 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 } - ) - } + .where(eq(member.userId, session.user.id) && eq(member.organizationId, sub.referenceId)) + .then((rows) => rows[0]) + + hasAccess = mem && (mem.role === 'owner' || mem.role === 'admin') + } + + 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 00000000000..fd13f3ba31c --- /dev/null +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts @@ -0,0 +1,297 @@ +/** + * Tests for Subscription Transfer API + * + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@/app/api/__test-utils__/utils' + +describe('Subscription Transfer API Routes', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } + + 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', + }, + } + + const mockOrganization = { + id: 'org-456', + name: 'Test Organization', + slug: 'test-org', + } + + const mockUser = { + id: 'user-123', + email: 'test@example.com', + } + + const mockAdminMember = { + id: 'member-123', + userId: 'user-123', + organizationId: 'org-456', + role: 'admin', + } + + const mockRegularMember = { + id: 'member-456', + userId: 'user-123', + organizationId: 'org-456', + role: 'member', + } + + const mockDb = { + select: vi.fn(), + update: vi.fn(), + } + + 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([]), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('POST handler', () => { + it('should transfer a personal subscription to an organization', 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([mockOrganization]), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([mockAdminMember]), + }) + + 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') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should reject transfer if subscription is 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') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + it('should reject transfer if organization is 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') + expect(mockDb.update).not.toHaveBeenCalled() + }) + + 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 00000000000..9fed5fb362c --- /dev/null +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.ts @@ -0,0 +1,114 @@ +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 { 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(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 63f9be68bff..00000000000 --- 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/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx index 68744af816c..7e58e6485dd 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' -import { Building, CheckCircle, Copy, PlusCircle, RefreshCw, UserX, XCircle } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { CheckCircle, Copy, PlusCircle, RefreshCw, UserX, XCircle } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -20,6 +20,69 @@ import { checkEnterprisePlan } from '@/lib/subscription/utils' const logger = createLogger('TeamManagement') +type User = { name?: string; email?: string } + +type Member = { + id: string + role: string + user?: User +} + +type Invitation = { + id: string + email: string + status: string +} + +type Organization = { + id: string + name: string + slug: string + members?: Member[] + invitations?: Invitation[] + createdAt: string | Date + [key: string]: unknown +} + +interface SubscriptionMetadata { + perSeatAllowance?: number + totalAllowance?: number + [key: string]: unknown +} + +type Subscription = { + id: string + plan: string + status: string + seats?: number + referenceId: string + cancelAtPeriodEnd?: boolean + periodEnd?: number | Date + trialEnd?: number | Date + metadata?: SubscriptionMetadata + [key: string]: unknown +} + +function calculateSeatUsage(org?: Organization | null) { + const members = org?.members?.length ?? 0 + const pending = org?.invitations?.filter((inv) => inv.status === 'pending').length ?? 0 + return { used: members + pending, members, pending } +} + +function useOrganizationRole(userEmail: string | undefined, org: Organization | null | undefined) { + return useMemo(() => { + if (!userEmail || !org?.members) { + return { userRole: 'member', isAdminOrOwner: false } + } + const currentMember = org.members.find((m) => m.user?.email === userEmail) + const role = currentMember?.role ?? 'member' + return { + userRole: role, + isAdminOrOwner: role === 'owner' || role === 'admin', + } + }, [userEmail, org]) +} + export function TeamManagement() { const { data: session } = useSession() const { data: activeOrg } = client.useActiveOrganization() @@ -41,13 +104,16 @@ export function TeamManagement() { const [orgSlug, setOrgSlug] = useState('') const [inviteSuccess, setInviteSuccess] = useState(false) const [activeTab, setActiveTab] = useState('members') - const [activeOrganization, setActiveOrganization] = useState(null) - const [subscriptionData, setSubscriptionData] = useState(null) + const [activeOrganization, setActiveOrganization] = useState(null) + const [subscriptionData, setSubscriptionData] = useState(null) const [isLoadingSubscription, setIsLoadingSubscription] = useState(false) const [hasTeamPlan, setHasTeamPlan] = useState(false) const [hasEnterprisePlan, setHasEnterprisePlan] = useState(false) - const [userRole, setUserRole] = useState('member') - const [isAdminOrOwner, setIsAdminOrOwner] = useState(false) + const { userRole, isAdminOrOwner } = useOrganizationRole(session?.user?.email, activeOrganization) + const { used: usedSeats } = useMemo( + () => calculateSeatUsage(activeOrganization), + [activeOrganization] + ) const loadData = useCallback(async () => { if (!session?.user) return @@ -85,28 +151,12 @@ export function TeamManagement() { if (activeOrg) { setActiveOrganization(activeOrg) - // Determine the user's role in this organization - if (session?.user?.email && activeOrg.members) { - const currentMember = activeOrg.members.find( - (m: any) => m.user?.email === session.user?.email - ) - - if (currentMember) { - setUserRole(currentMember.role) - setIsAdminOrOwner(currentMember.role === 'owner' || currentMember.role === 'admin') - logger.info('User role in organization', { - role: currentMember.role, - isAdminOrOwner: currentMember.role === 'owner' || currentMember.role === 'admin', - }) - } - } - // Load subscription data for the organization if (activeOrg.id) { loadOrganizationSubscription(activeOrg.id) } } - }, [activeOrg, session?.user?.email]) + }, [activeOrg]) // Load organization's subscription data const loadOrganizationSubscription = async (orgId: string) => { @@ -163,7 +213,7 @@ export function TeamManagement() { } } } catch (err) { - logger.error('Error fetching enterprise subscription', err) + logger.error('Error fetching enterprise subscription', { error: err }) } } @@ -205,19 +255,20 @@ export function TeamManagement() { const handleReduceSeats = async () => { if (!session?.user || !activeOrganization || !subscriptionData) return + // Don't allow enterprise users to modify seats + if (checkEnterprisePlan(subscriptionData)) { + setError('Enterprise plan seats can only be modified by contacting support') + return + } + const currentSeats = subscriptionData.seats || 0 if (currentSeats <= 1) { setError('Cannot reduce seats below 1') return } - // Calculate current usage - const currentMemberCount = activeOrganization.members?.length || 0 - const pendingInvitationCount = - activeOrganization.invitations?.filter((inv: any) => inv.status === 'pending').length || 0 - const totalCount = currentMemberCount + pendingInvitationCount + const { used: totalCount } = calculateSeatUsage(activeOrganization) - // Check if we need to remove members before reducing seats if (totalCount >= currentSeats) { setError( `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.` @@ -226,66 +277,23 @@ export function TeamManagement() { } try { - setIsLoading(true) - setError(null) - - // Reduce the seats by 1 - const newSeatCount = currentSeats - 1 - - // If it's an enterprise plan, handle through custom endpoint - if (checkEnterprisePlan(subscriptionData)) { - // For enterprise plans, update via admin endpoint with credentials - const response = await fetch('/api/user/subscription/update-seats', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - subscriptionId: subscriptionData.id, - seats: newSeatCount, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to update seat count') - } - } else { - // For team plans, use the client API - const { error } = await client.subscription.upgrade({ - plan: 'team', - referenceId: activeOrganization.id, - successUrl: window.location.href, - cancelUrl: window.location.href, - seats: newSeatCount, - }) - - if (error) { - throw new Error(error.message || 'Failed to update seat count') - } - } - + await updateSeats(currentSeats - 1) await refreshOrganization() } catch (err: any) { setError(err.message || 'Failed to reduce seats') - } finally { - setIsLoading(false) } } - // Generate a slug from organization name const generateSlug = (name: string) => { return name.toLowerCase().replace(/[^a-z0-9]/g, '-') } - // Handle organization name change const handleOrgNameChange = (e: React.ChangeEvent) => { const newName = e.target.value setOrgName(newName) setOrgSlug(generateSlug(newName)) } - // Create a new organization const handleCreateOrganization = async () => { if (!session?.user) return @@ -318,10 +326,44 @@ export function TeamManagement() { // directly through a custom API endpoint instead of using upgrade if (hasTeamPlan || hasEnterprisePlan) { const userSubResponse = await client.subscription.list() - const teamSubscription = userSubResponse.data?.find( + + let teamSubscription = userSubResponse.data?.find( (sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active' ) + // If no subscription was found through the client API but user has enterprise plan, + // fetch it directly through our enterprise subscription endpoint + if (!teamSubscription && hasEnterprisePlan) { + logger.info('No subscription found via client API, checking enterprise endpoint') + try { + const enterpriseResponse = await fetch('/api/user/subscription/enterprise') + if (enterpriseResponse.ok) { + const enterpriseData = await enterpriseResponse.json() + if (enterpriseData.subscription) { + teamSubscription = enterpriseData.subscription + logger.info('Found enterprise subscription via direct API', { + subscriptionId: teamSubscription?.id, + plan: teamSubscription?.plan, + seats: teamSubscription?.seats, + }) + } + } + } catch (err) { + logger.error('Error fetching enterprise subscription details', { error: err }) + } + } + + logger.info('Team subscription to transfer', { + found: !!teamSubscription, + details: teamSubscription + ? { + id: teamSubscription.id, + plan: teamSubscription.plan, + status: teamSubscription.status, + } + : null, + }) + if (teamSubscription) { logger.info('Found subscription to transfer', { subscriptionId: teamSubscription.id, @@ -331,23 +373,40 @@ export function TeamManagement() { }) // Use a custom API endpoint to transfer the subscription without going to Stripe - const transferResponse = await fetch('/api/user/transfer-subscription', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - subscriptionId: teamSubscription.id, - organizationId: orgId, - }), - }) + try { + const transferResponse = await fetch( + `/api/user/subscription/${teamSubscription.id}/transfer`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + organizationId: orgId, + }), + } + ) - if (!transferResponse.ok) { - const errorData = await transferResponse.json() - throw new Error(errorData.error || 'Failed to transfer subscription to organization') - } + if (!transferResponse.ok) { + const errorText = await transferResponse.text() + let errorMessage = 'Failed to transfer subscription' - logger.info('Successfully transferred subscription to organization') + try { + if (errorText && errorText.trim().startsWith('{')) { + const errorData = JSON.parse(errorText) + errorMessage = errorData.error || errorMessage + } + } catch (e) { + // Parsing failed, use the raw text + errorMessage = errorText || errorMessage + } + + throw new Error(errorMessage) + } + } catch (transferError) { + logger.error('Subscription transfer failed', { + error: transferError instanceof Error ? transferError.message : String(transferError), + }) + throw transferError + } } } @@ -422,30 +481,26 @@ export function TeamManagement() { setError(null) setInviteSuccess(false) - // Check seat limit - compare current members + pending invitations against seats - const currentMemberCount = activeOrganization.members?.length || 0 - const pendingInvitationCount = - activeOrganization.invitations?.filter((inv: any) => inv.status === 'pending').length || 0 - const totalCount = currentMemberCount + pendingInvitationCount + const { + used: totalCount, + pending: pendingInvitationCount, + members: currentMemberCount, + } = calculateSeatUsage(activeOrganization) - // Get the number of seats from subscription data const seatLimit = subscriptionData?.seats || 0 logger.info('Checking seat availability for invitation', { currentMembers: currentMemberCount, pendingInvites: pendingInvitationCount, totalUsed: totalCount, - seatLimit: seatLimit, + seatLimit, subscriptionId: subscriptionData?.id, }) if (totalCount >= seatLimit) { - const error = `You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.` - logger.warn('Invitation failed - seat limit reached', { - totalCount, - seatLimit, - }) - setError(error) + setError( + `You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.` + ) return } @@ -516,35 +571,11 @@ export function TeamManagement() { // If the user opted to reduce seats as well if (shouldReduceSeats && subscriptionData) { const currentSeats = subscriptionData.seats || 0 - if (currentSeats > 1) { - // Determine if we're dealing with enterprise or team plan - if (checkEnterprisePlan(subscriptionData)) { - // Handle enterprise plan seat reduction - const response = await fetch('/api/user/subscription/update-seats', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - subscriptionId: subscriptionData.id, - seats: currentSeats - 1, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to reduce seats') - } - } else { - // Handle team plan seat reduction - await client.subscription.upgrade({ - plan: 'team', - referenceId: activeOrganization.id, - successUrl: window.location.href, - cancelUrl: window.location.href, - seats: currentSeats - 1, - }) + try { + await updateSeats(currentSeats - 1) + } catch (err) { + throw err } } } @@ -582,7 +613,6 @@ export function TeamManagement() { } } - // Get the effective plan name for display const getEffectivePlanName = () => { if (!subscriptionData) return 'No Plan' @@ -598,6 +628,35 @@ export function TeamManagement() { } } + const updateSeats = useCallback( + async (newSeatCount: number) => { + if (!subscriptionData || !activeOrganization) return + + // Don't allow enterprise users to modify seats + if (checkEnterprisePlan(subscriptionData)) { + setError('Enterprise plan seats can only be modified by contacting support') + return + } + + try { + setIsLoading(true) + setError(null) + + const { error } = await client.subscription.upgrade({ + plan: 'team', + referenceId: activeOrganization.id, + successUrl: window.location.href, + cancelUrl: window.location.href, + seats: newSeatCount, + }) + if (error) throw new Error(error.message || 'Failed to update seats') + } finally { + setIsLoading(false) + } + }, + [subscriptionData, activeOrganization] + ) + if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) { return } @@ -836,76 +895,47 @@ export function TeamManagement() {
Used - {(activeOrganization.members?.length || 0) + - (activeOrganization.invitations?.filter( - (inv: any) => inv.status === 'pending' - ).length || 0)} - /{subscriptionData.seats || 0} + {usedSeats}/{subscriptionData.seats || 0}
inv.status === 'pending' - ).length || 0)) / - (subscriptionData.seats || 1)) * - 100 - } + value={(usedSeats / (subscriptionData.seats || 1)) * 100} className="h-2" /> -
- - + -
+ }} + 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/lib/auth.ts b/apps/sim/lib/auth.ts index 6ce904cf697..eea3af4bf87 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) From 0e30b191fe39f42101cc5e22d08114e5a2855f24 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 May 2025 11:48:39 -0700 Subject: [PATCH 2/3] cleaned up tests --- .../subscription/[id]/seats/route.test.ts | 84 +++++++++------ .../api/user/subscription/[id]/seats/route.ts | 4 +- .../subscription/[id]/transfer/route.test.ts | 101 ++++++++++++------ .../user/subscription/[id]/transfer/route.ts | 4 +- 4 files changed, 121 insertions(+), 72 deletions(-) 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 index ff6444c04cd..782652753a2 100644 --- a/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts +++ b/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts @@ -53,13 +53,6 @@ describe('Subscription Seats Update API Routes', () => { email: 'test@example.com', } - const mockAdminMember = { - id: 'member-123', - userId: 'user-123', - organizationId: 'org-123', - role: 'admin', - } - const mockRegularMember = { id: 'member-456', userId: 'user-123', @@ -72,6 +65,12 @@ describe('Subscription Seats Update API Routes', () => { update: vi.fn(), } + const mockEq = vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })) + const mockAnd = vi.fn().mockImplementation((...conditions) => ({ + conditions, + type: 'and', + })) + beforeEach(() => { vi.resetModules() @@ -82,13 +81,18 @@ describe('Subscription Seats Update API Routes', () => { })) vi.doMock('@/lib/subscription/utils', () => ({ - checkEnterprisePlan: vi.fn().mockImplementation((sub) => sub.plan === 'enterprise'), + checkEnterprisePlan: vi.fn().mockReturnValue(true), })) vi.doMock('@/lib/logs/console-logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), })) + vi.doMock('drizzle-orm', () => ({ + eq: mockEq, + and: mockAnd, + })) + vi.doMock('@/db', () => ({ db: mockDb, })) @@ -99,9 +103,11 @@ describe('Subscription Seats Update API Routes', () => { then: vi.fn().mockResolvedValue([mockSubscription]), }) + const mockSetFn = vi.fn().mockReturnThis() + const mockWhereFn = vi.fn().mockResolvedValue([{ affected: 1 }]) mockDb.update.mockReturnValue({ - set: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue([]), + set: mockSetFn, + where: mockWhereFn, }) }) @@ -110,27 +116,22 @@ describe('Subscription Seats Update API Routes', () => { }) describe('POST handler', () => { - it('should update subscription seats for enterprise plan', async () => { - mockDb.select.mockReturnValue({ + 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]), - }) - - 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([mockAdminMember]), - }) + })) - mockDb.select.mockImplementation(mockSelectImpl) + mockDb.select.mockImplementationOnce(() => ({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + then: vi.fn().mockResolvedValue([]), + })) const req = createMockRequest('POST', { seats: 10, @@ -141,12 +142,19 @@ describe('Subscription Seats Update API Routes', () => { 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') + 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(), @@ -170,7 +178,11 @@ describe('Subscription Seats Update API Routes', () => { expect(mockDb.update).not.toHaveBeenCalled() }) - it('should allow personal subscription updates for the subscription owner', async () => { + 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(), @@ -186,11 +198,15 @@ describe('Subscription Seats Update API Routes', () => { const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) const data = await response.json() - expect(response.status).toBe(400) + 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({ @@ -215,7 +231,7 @@ describe('Subscription Seats Update API Routes', () => { const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) const data = await response.json() - expect(response.status).toBe(400) + expect(response.status).toBe(403) expect(data).toHaveProperty('error') }) @@ -234,7 +250,7 @@ describe('Subscription Seats Update API Routes', () => { expect(mockDb.update).not.toHaveBeenCalled() }) - it('should handle subscription not found', async () => { + it('should handle subscription not found with permission error', async () => { mockDb.select.mockReturnValue({ from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), @@ -250,7 +266,7 @@ describe('Subscription Seats Update API Routes', () => { const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) }) const data = await response.json() - expect(response.status).toBe(400) + expect(response.status).toBe(403) expect(data).toHaveProperty('error') }) diff --git a/apps/sim/app/api/user/subscription/[id]/seats/route.ts b/apps/sim/app/api/user/subscription/[id]/seats/route.ts index 7e059d7c51b..0294ef26fb5 100644 --- a/apps/sim/app/api/user/subscription/[id]/seats/route.ts +++ b/apps/sim/app/api/user/subscription/[id]/seats/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' @@ -88,7 +88,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const mem = await db .select() .from(member) - .where(eq(member.userId, session.user.id) && eq(member.organizationId, sub.referenceId)) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, sub.referenceId))) .then((rows) => rows[0]) hasAccess = mem && (mem.role === 'owner' || mem.role === 'admin') 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 index fd13f3ba31c..8e94e9b9d04 100644 --- a/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts @@ -14,6 +14,11 @@ describe('Subscription Transfer API Routes', () => { debug: vi.fn(), } + const mockUser = { + id: 'user-123', + email: 'test@example.com', + } + const mockSubscription = { id: 'sub-123', plan: 'enterprise', @@ -33,11 +38,6 @@ describe('Subscription Transfer API Routes', () => { slug: 'test-org', } - const mockUser = { - id: 'user-123', - email: 'test@example.com', - } - const mockAdminMember = { id: 'member-123', userId: 'user-123', @@ -57,6 +57,12 @@ describe('Subscription Transfer API Routes', () => { update: vi.fn(), } + const mockEq = vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })) + const mockAnd = vi.fn().mockImplementation((...conditions) => ({ + conditions, + type: 'and', + })) + beforeEach(() => { vi.resetModules() @@ -70,6 +76,11 @@ describe('Subscription Transfer API Routes', () => { createLogger: vi.fn().mockReturnValue(mockLogger), })) + vi.doMock('drizzle-orm', () => ({ + eq: mockEq, + and: mockAnd, + })) + vi.doMock('@/db', () => ({ db: mockDb, })) @@ -82,7 +93,7 @@ describe('Subscription Transfer API Routes', () => { mockDb.update.mockReturnValue({ set: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue([]), + where: vi.fn().mockResolvedValue([{ affected: 1 }]), }) }) @@ -91,26 +102,48 @@ describe('Subscription Transfer API Routes', () => { }) describe('POST handler', () => { - it('should transfer a personal subscription to an organization', 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([mockOrganization]), - }) - .mockReturnValueOnce({ - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - then: vi.fn().mockResolvedValue([mockAdminMember]), - }) + it('should successfully transfer a personal subscription to an organization', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { + ...mockUser, + id: 'user-123', + }, + }), + })) - mockDb.select.mockImplementation(mockSelectImpl) + 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', @@ -119,14 +152,16 @@ describe('Subscription Transfer API Routes', () => { 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') - expect(mockDb.update).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(data).toHaveProperty('success', true) + expect(data).toHaveProperty('message', 'Subscription transferred successfully') + expect(mockDb.update).toHaveBeenCalled() }) - it('should reject transfer if subscription is not found', async () => { + it('should test behavior when subscription not found', async () => { mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), @@ -143,11 +178,10 @@ describe('Subscription Transfer API Routes', () => { const data = await response.json() expect(response.status).toBe(403) - expect(data).toHaveProperty('error') - expect(mockDb.update).not.toHaveBeenCalled() + expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user') }) - it('should reject transfer if organization is not found', async () => { + it('should test behavior when organization not found', async () => { const mockSelectImpl = vi .fn() .mockReturnValueOnce({ @@ -173,8 +207,7 @@ describe('Subscription Transfer API Routes', () => { const data = await response.json() expect(response.status).toBe(403) - expect(data).toHaveProperty('error') - expect(mockDb.update).not.toHaveBeenCalled() + expect(data).toHaveProperty('error', 'Unauthorized - subscription does not belong to user') }) it('should reject transfer if user is not the subscription owner', async () => { diff --git a/apps/sim/app/api/user/subscription/[id]/transfer/route.ts b/apps/sim/app/api/user/subscription/[id]/transfer/route.ts index 9fed5fb362c..7c94468c798 100644 --- a/apps/sim/app/api/user/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' @@ -78,7 +78,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const mem = await db .select() .from(member) - .where(eq(member.userId, session.user.id) && eq(member.organizationId, organizationId)) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) .then((rows) => rows[0]) const isPersonalTransfer = sub.referenceId === session.user.id From a1b3a689ba242ac3552c0f20787807158dad3ada Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 May 2025 12:05:17 -0700 Subject: [PATCH 3/3] 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 | 80 ++------- .../subscription/[id]/transfer/route.test.ts | 72 ++------ 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 +- 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/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 | 85 +++++----- 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 +- 39 files changed, 784 insertions(+), 747 deletions(-) diff --git a/.prettierignore b/.prettierignore index 70c1abe4ea5..b4e992ffe1b 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 f331ca1a84c..3d3ef15bc50 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 f3fddecd18a..548776efa03 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 1118e757efd..5b19ffc817b 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 index 782652753a2..4d0bdfe88fc 100644 --- a/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts +++ b/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts @@ -4,73 +4,18 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest } from '@/app/api/__test-utils__/utils' +import { + createMockRequest, + mockDb, + mockLogger, + mockPersonalSubscription, + mockRegularMember, + mockSubscription, + mockTeamSubscription, + mockUser, +} from '@/app/api/__test-utils__/utils' describe('Subscription Seats Update API Routes', () => { - const mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } - - const mockSubscription = { - id: 'sub-123', - plan: 'enterprise', - status: 'active', - seats: 5, - referenceId: 'org-123', - metadata: { - perSeatAllowance: 100, - totalAllowance: 500, - updatedAt: '2023-01-01T00:00:00.000Z', - }, - } - - const mockTeamSubscription = { - id: 'sub-456', - plan: 'team', - status: 'active', - seats: 5, - referenceId: 'org-123', - } - - 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', - }, - } - - const mockUser = { - id: 'user-123', - email: 'test@example.com', - } - - const mockRegularMember = { - id: 'member-456', - userId: 'user-123', - organizationId: 'org-123', - role: 'member', - } - - const mockDb = { - select: vi.fn(), - update: vi.fn(), - } - - const mockEq = vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })) - const mockAnd = vi.fn().mockImplementation((...conditions) => ({ - conditions, - type: 'and', - })) - beforeEach(() => { vi.resetModules() @@ -88,11 +33,6 @@ describe('Subscription Seats Update API Routes', () => { createLogger: vi.fn().mockReturnValue(mockLogger), })) - vi.doMock('drizzle-orm', () => ({ - eq: mockEq, - and: mockAnd, - })) - vi.doMock('@/db', () => ({ db: mockDb, })) 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 index 8e94e9b9d04..d3bc3f9e198 100644 --- a/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts +++ b/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts @@ -4,65 +4,18 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockRequest } from '@/app/api/__test-utils__/utils' +import { + createMockRequest, + mockAdminMember, + mockDb, + mockLogger, + mockOrganization, + mockRegularMember, + mockSubscription, + mockUser, +} from '@/app/api/__test-utils__/utils' describe('Subscription Transfer API Routes', () => { - const mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } - - const mockUser = { - id: 'user-123', - email: 'test@example.com', - } - - 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', - }, - } - - const mockOrganization = { - id: 'org-456', - name: 'Test Organization', - slug: 'test-org', - } - - const mockAdminMember = { - id: 'member-123', - userId: 'user-123', - organizationId: 'org-456', - role: 'admin', - } - - const mockRegularMember = { - id: 'member-456', - userId: 'user-123', - organizationId: 'org-456', - role: 'member', - } - - const mockDb = { - select: vi.fn(), - update: vi.fn(), - } - - const mockEq = vi.fn().mockImplementation((field, value) => ({ field, value, type: 'eq' })) - const mockAnd = vi.fn().mockImplementation((...conditions) => ({ - conditions, - type: 'and', - })) - beforeEach(() => { vi.resetModules() @@ -76,11 +29,6 @@ describe('Subscription Transfer API Routes', () => { createLogger: vi.fn().mockReturnValue(mockLogger), })) - vi.doMock('drizzle-orm', () => ({ - eq: mockEq, - and: mockAnd, - })) - vi.doMock('@/db', () => ({ db: mockDb, })) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 3dbbd00454a..5cec995a276 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 77a21cf729f..b4a7f80c0e3 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 625cfae9005..c1ddc249d16 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 8b853ee3723..81d9a72ba69 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 df1934c50bf..64f894cf239 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 5f9d45a66be..bd6cdef1586 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 4205960a82e..3f683e15bf4 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 10f52ae07b5..5d66e4c6dc6 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 63c5a0c6961..800a8fccfa6 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 f1d16a55fbc..15289fc8864 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 b204eff80f4..260238693c6 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 14cfc123279..693ba46526e 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 bee8b396682..70126232af7 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 bd48033329c..3471f952e80 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 ? ( -