diff --git a/.gitignore b/.gitignore index ef2e7025b0c..c0532fd4492 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ i18n.cache .claude/launch.json .claude/worktrees/ .claude/scheduled_tasks.lock +.deepsec/ diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index 369c99fedd5..f64ee69b34c 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -10,6 +10,7 @@ import { Modal, ModalClose, ModalContent, + ModalDescription, ModalTitle, ModalTrigger, } from '@/components/emcn' @@ -134,6 +135,9 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal {view === 'login' ? 'Log in' : 'Create account'} + + {view === 'login' ? 'Sign in to your account' : 'Create a new account'} +
diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx index f07e78dd4b2..8225d58cf68 100644 --- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx +++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx @@ -8,6 +8,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, ModalTrigger, @@ -152,6 +153,9 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP } > + + Fill out this form to request a demo and talk to the sales team +
diff --git a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx index ab631d34957..e5fa0d5ca9f 100644 --- a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx +++ b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx @@ -8,6 +8,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Textarea, @@ -83,6 +84,9 @@ export function RequestIntegrationModal() { {status === 'success' ? ( + + Integration request submitted successfully +
+ + Submit a request for a new integration by entering the integration name and your + email +
diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 0d3939f8d25..f846a88d3fb 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,22 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' +import { credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' -import { deleteCredential } from '@/lib/credentials/deletion' -import { - deleteWorkspaceEnvCredentials, - syncPersonalEnvCredentialsForUser, -} from '@/lib/credentials/environment' -import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') @@ -93,93 +85,33 @@ export const PUT = withRouteHandler( const { id } = parsed.data.params const body = parsed.data.body - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) - } - - const updates: Record = {} - - if (body.description !== undefined) { - updates.description = body.description ?? null - } - - if ( - body.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') - ) { - updates.displayName = body.displayName - } - - if (body.serviceAccountJson !== undefined && access.credential.type === 'service_account') { - let parsedJson: Record - try { - parsedJson = JSON.parse(body.serviceAccountJson) - } catch { - return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) - } - if ( - parsedJson.type !== 'service_account' || - typeof parsedJson.client_email !== 'string' || - typeof parsedJson.private_key !== 'string' || - typeof parsedJson.project_id !== 'string' - ) { - return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) - } - const { encrypted } = await encryptSecret(body.serviceAccountJson) - updates.encryptedServiceAccountKey = encrypted - } - - if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { - return NextResponse.json( - { - error: 'No updatable fields provided.', - }, - { status: 400 } - ) - } - return NextResponse.json( - { - error: - 'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.', - }, - { status: 400 } - ) - } - - updates.updatedAt = new Date() - await db.update(credential).set(updates).where(eq(credential.id, id)) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, + const result = await performUpdateCredential({ + credentialId: id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), - }, + displayName: body.displayName, + description: body.description, + serviceAccountJson: body.serviceAccountJson, request, }) + if (!result.success) { + const status = + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'forbidden' + ? 403 + : result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'validation' + ? 400 + : 500 + return NextResponse.json({ error: result.error }, { status }) + } const row = await getCredentialResponse(id, session.user.id) return NextResponse.json({ credential: row }, { status: 200 }) } catch (error) { - if (error instanceof Error && error.message.includes('unique')) { - return NextResponse.json( - { error: 'A service account credential with this name already exists in the workspace' }, - { status: 409 } - ) - } logger.error('Failed to update credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -196,163 +128,24 @@ export const DELETE = withRouteHandler( const { id } = await params try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) - } - - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 }) - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) { - delete current[access.credential.envKey] - } - - await db - .insert(environment) - .values({ - id: ownerUserId, - userId: ownerUserId, - variables: current, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? - {}) as Record - if (access.credential.envKey in current) { - delete current[access.credential.envKey] - } - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } - - await deleteCredential({ + const result = await performDeleteCredential({ credentialId: id, - actorId: session.user.id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - reason: 'user_delete', request, }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? id, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) + if (!result.success) { + const status = + result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'forbidden' + ? 403 + : result.errorCode === 'validation' + ? 400 + : 500 + return NextResponse.json({ error: result.error }, { status }) + } return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 477ada12fce..a60e552dc06 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -34,6 +34,7 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => { }) const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder +const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -54,16 +55,6 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' -/** Type for captured folder values in tests */ -interface CapturedFolderValues { - name?: string - color?: string - parentId?: string | null - isExpanded?: boolean - sortOrder?: number - updatedAt?: Date -} - interface FolderDbMockOptions { folderLookupResult?: any updateResult?: any[] @@ -160,6 +151,41 @@ describe('Individual Folder API Route', () => { success: true, deletedItems: { folders: 1, workflows: 0 }, }) + mockPerformUpdateFolder.mockImplementation(async (params) => { + if (params.parentId && params.parentId === params.folderId) { + return { + success: false, + error: 'Folder cannot be its own parent', + errorCode: 'validation', + } + } + if ( + params.parentId && + (await workflowsUtilsMockFns.mockCheckForCircularReference( + params.folderId, + params.parentId + )) + ) { + return { + success: false, + error: 'Cannot create circular folder reference', + errorCode: 'validation', + } + } + return { + success: true, + folder: { + ...mockFolder, + id: params.folderId, + name: params.name !== undefined ? params.name.trim() : 'Updated Folder', + color: params.color ?? mockFolder.color, + parentId: params.parentId ?? mockFolder.parentId, + isExpanded: params.isExpanded, + sortOrder: params.sortOrder ?? mockFolder.sortOrder, + updatedAt: new Date(), + }, + } + }) workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false) }) @@ -180,7 +206,7 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('folder') expect(data.folder).toMatchObject({ - name: 'Updated Folder', + name: 'Updated Folder Name', }) }) @@ -285,44 +311,15 @@ describe('Individual Folder API Route', () => { it('should trim folder name when updating', async () => { mockAuthenticatedUser() - let capturedUpdates: CapturedFolderValues | null = null - - const mockSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - then: vi.fn().mockImplementation((callback) => { - return Promise.resolve(callback([mockFolder])) - }), - })), - })), - })) - - const mockUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation((updates) => { - capturedUpdates = updates - return { - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockReturnValue([{ ...mockFolder, name: 'Folder With Spaces' }]), - })), - } - }), - })) - - mockDbRef.current = { - select: mockSelect, - update: mockUpdate, - delete: vi.fn(), - } - const req = createMockRequest('PUT', { name: ' Folder With Spaces ', }) const params = Promise.resolve({ id: 'folder-1' }) - await PUT(req, { params }) + const response = await PUT(req, { params }) + const data = await response.json() - expect(capturedUpdates).not.toBeNull() - expect(capturedUpdates!.name).toBe('Folder With Spaces') + expect(data.folder.name).toBe('Folder With Spaces') }) it('should handle database errors gracefully', async () => { diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index be2b71e1028..bc622793bc4 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -9,8 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteFolder } from '@/lib/workflows/orchestration' -import { checkForCircularReference } from '@/lib/workflows/utils' +import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') @@ -81,39 +80,27 @@ export const PUT = withRouteHandler( await assertFolderMutable(parentId) } - // Prevent setting a folder as its own parent or creating circular references - if (parentId && parentId === id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) - } + const result = await performUpdateFolder({ + folderId: id, + workspaceId: existingFolder.workspaceId, + userId: session.user.id, + name, + color, + isExpanded, + locked, + parentId, + sortOrder, + }) - // Check for circular references if parentId is provided - if (parentId) { - const wouldCreateCycle = await checkForCircularReference(id, parentId) - if (wouldCreateCycle) { - return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } - ) - } + if (!result.success || !result.folder) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) } - const updates: Record = { updatedAt: new Date() } - if (name !== undefined) updates.name = name.trim() - if (color !== undefined) updates.color = color - if (isExpanded !== undefined) updates.isExpanded = isExpanded - if (locked !== undefined) updates.locked = locked - if (parentId !== undefined) updates.parentId = parentId || null - if (sortOrder !== undefined) updates.sortOrder = sortOrder - - const [updatedFolder] = await db - .update(workflowFolder) - .set(updates) - .where(eq(workflowFolder.id, id)) - .returning() - - logger.info('Updated folder:', { id, updates }) + logger.info('Updated folder:', { id, updates: parsed.data.body }) - return NextResponse.json({ folder: updatedFolder }) + return NextResponse.json({ folder: result.folder }) } catch (error) { if (error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 73cd890eb84..a2e145bf1e3 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -150,7 +150,11 @@ describe('Folders API Route', () => { mockSelect.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) + const defaultWhereResult = [] as Array> & { + orderBy: typeof mockOrderBy + } + defaultWhereResult.orderBy = mockOrderBy + mockWhere.mockReturnValue(defaultWhereResult) mockOrderBy.mockReturnValue(mockFolders) mockInsert.mockReturnValue({ values: mockValues }) @@ -328,6 +332,14 @@ describe('Folders API Route', () => { }, }) ) + mockWhere + .mockReturnValueOnce([{ minSortOrder: 5 }]) + .mockReturnValueOnce([{ minSortOrder: 2 }]) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) + mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }]) const req = createMockRequest('POST', { name: 'New Test Folder', @@ -355,6 +367,7 @@ describe('Folders API Route', () => { insertResult: [{ ...mockFolders[1] }], }) ) + mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }]) const req = createMockRequest('POST', { name: 'Subfolder', @@ -478,8 +491,8 @@ describe('Folders API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockTransaction.mockImplementationOnce(() => { - throw new Error('Database transaction failed') + mockInsert.mockImplementationOnce(() => { + throw new Error('Database insert failed') }) const req = createMockRequest('POST', { @@ -493,7 +506,7 @@ describe('Folders API Route', () => { const data = await response.json() expect(data).toHaveProperty('error', 'Internal server error') - expect(mockLogger.error).toHaveBeenCalledWith('Error creating folder:', { + expect(mockLogger.error).toHaveBeenCalledWith('Failed to create workflow folder', { error: expect.any(Error), }) }) @@ -512,6 +525,10 @@ describe('Folders API Route', () => { }, }) ) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) const req = createMockRequest('POST', { name: ' Test Folder With Spaces ', @@ -538,6 +555,10 @@ describe('Folders API Route', () => { }, }) ) + mockValues.mockImplementationOnce((values: CapturedFolderValues) => { + capturedValues = values + return { returning: mockReturning } + }) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 3585a6b2834..404ebe0873c 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,19 +1,25 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm' +import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') +function folderMutationStatus(errorCode: string | undefined): number { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + return 500 +} + // GET - Fetch folders for a workspace export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -87,59 +93,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const id = clientId || generateId() - - const newFolder = await db.transaction(async (tx) => { - let sortOrder: number - if (providedSortOrder !== undefined) { - sortOrder = providedSortOrder - } else { - const folderParentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) - const workflowParentCondition = parentId - ? eq(workflow.folderId, parentId) - : isNull(workflow.folderId) - - const [[folderResult], [workflowResult]] = await Promise.all([ - tx - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), - tx - .select({ minSortOrder: min(workflow.sortOrder) }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)), - ]) - - const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) - - sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 - } - - const [folder] = await tx - .insert(workflowFolder) - .values({ - id, - name: name.trim(), - userId: session.user.id, - workspaceId, - parentId: parentId || null, - color: color || '#6B7280', - sortOrder, - }) - .returning() - - return folder + const result = await performCreateFolder({ + id: clientId, + userId: session.user.id, + workspaceId, + name, + parentId, + color, + sortOrder: providedSortOrder, }) - logger.info('Created new folder:', { id, name, workspaceId, parentId }) + if (!result.success || !result.folder) { + return NextResponse.json( + { error: result.error }, + { status: folderMutationStatus(result.errorCode) } + ) + } + + const newFolder = result.folder + + logger.info('Created new folder:', { id: newFolder.id, name, workspaceId, parentId }) captureServerEvent( session.user.id, @@ -148,26 +121,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { groups: { workspace: workspaceId } } ) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: id, - resourceName: name.trim(), - description: `Created folder "${name.trim()}"`, - metadata: { - name: name.trim(), - workspaceId, - parentId: parentId || undefined, - color: color || '#6B7280', - sortOrder: newFolder.sortOrder, - }, - request, - }) - return NextResponse.json({ folder: newFolder }) } catch (error) { logger.error('Error creating folder:', { error }) diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 297dee54767..92929d23f11 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -1,15 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' +import { + getRestorableKnowledgeBase, + performRestoreKnowledgeBase, +} from '@/lib/knowledge/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreKnowledgeBaseAPI') @@ -27,16 +26,7 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, id)) - .limit(1) + const kb = await getRestorableKnowledgeBase(id) if (!kb) { return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) @@ -51,32 +41,21 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - await restoreKnowledgeBase(id, requestId) + const result = await performRestoreKnowledgeBase({ + knowledgeBaseId: id, + userId: auth.userId, + requestId, + }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } logger.info(`[${requestId}] Restored knowledge base ${id}`) - recordAudit({ - workspaceId: kb.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { - knowledgeBaseName: kb.name, - }, - request, - }) - return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index b2b3b35f5b9..6795f6383e1 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -1,22 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - McpDnsResolutionError, - McpDomainNotAllowedError, - McpSsrfError, - validateMcpDomain, - validateMcpServerSsrf, -} from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('McpServerAPI') @@ -55,100 +48,33 @@ export const PATCH = withRouteHandler( // Remove workspaceId from body to prevent it from being updated const { workspaceId: _, ...updateData } = body - if (updateData.url) { - try { - validateMcpDomain(updateData.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - try { - await validateMcpServerSsrf(updateData.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - } - - // Get the current server to check if URL is changing - const [currentServer] = await db - .select({ url: mcpServers.url }) - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - const [updatedServer] = await db - .update(mcpServers) - .set({ - ...updateData, - updatedAt: new Date(), - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning() - - if (!updatedServer) { + const result = await performUpdateMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + serverId, + name: updateData.name, + description: updateData.description, + transport: updateData.transport, + url: updateData.url, + headers: updateData.headers, + timeout: updateData.timeout, + retries: updateData.retries, + enabled: updateData.enabled, + request, + }) + if (!result.success || !result.server) { return createMcpErrorResponse( new Error('Server not found or access denied'), - 'Server not found', - 404 + result.error || 'Server not found', + mcpOrchestrationStatus(result.errorCode) ) } - - const shouldClearCache = - (body.url !== undefined && currentServer?.url !== body.url) || - body.enabled !== undefined || - body.headers !== undefined || - body.timeout !== undefined || - body.retries !== undefined - - if (shouldClearCache) { - await mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`) - } + const updatedServer = result.server logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name || serverId, - description: `Updated MCP server "${updatedServer.name || serverId}"`, - metadata: { - serverName: updatedServer.name, - transport: updatedServer.transport, - url: updatedServer.url, - updatedFields: Object.keys(updateData).filter( - (k) => k !== 'workspaceId' && k !== 'updatedAt' - ), - }, - request, - }) - return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) diff --git a/apps/sim/app/api/mcp/servers/route.test.ts b/apps/sim/app/api/mcp/servers/route.test.ts new file mode 100644 index 00000000000..e831c802e0a --- /dev/null +++ b/apps/sim/app/api/mcp/servers/route.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({ + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(), + }, +})) + +vi.mock('@/lib/mcp/middleware', () => ({ + getParsedBody: () => undefined, + withMcpAuth: + () => + ( + handler: ( + request: NextRequest, + context: { + userId: string + userName: string + userEmail: string + workspaceId: string + requestId: string + } + ) => Promise + ) => + (request: NextRequest) => + handler(request, { + userId: 'user-1', + userName: 'Test User', + userEmail: 'test@example.com', + workspaceId: 'workspace-1', + requestId: 'request-1', + }), +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: vi.fn(), + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +import { DELETE } from '@/app/api/mcp/servers/route' + +function createDeleteRequest(serverId = 'server-1') { + return new Request( + `http://localhost:3000/api/mcp/servers?workspaceId=workspace-1&serverId=${serverId}`, + { method: 'DELETE' } + ) as NextRequest +} + +describe('MCP servers DELETE route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 404 when orchestration reports a missing server', async () => { + mockPerformDeleteMcpServer.mockResolvedValueOnce({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + + const response = await DELETE(createDeleteRequest()) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toEqual({ success: false, error: 'Server not found' }) + }) + + it('returns 500 when orchestration reports an internal delete failure', async () => { + mockPerformDeleteMcpServer.mockResolvedValueOnce({ + success: false, + error: 'Failed to delete MCP server', + errorCode: 'internal', + }) + + const response = await DELETE(createDeleteRequest()) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ success: false, error: 'Failed to delete MCP server' }) + }) +}) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index d2666431506..f0f2744b053 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -1,29 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp' import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - McpDnsResolutionError, - McpDomainNotAllowedError, - McpSsrfError, - validateMcpDomain, - validateMcpServerSsrf, -} from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' +import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse, - generateMcpServerId, + mcpOrchestrationStatus, } from '@/lib/mcp/utils' -import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('McpServersAPI') @@ -82,142 +72,50 @@ export const POST = withRouteHandler( workspaceId, }) - try { - validateMcpDomain(body.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - try { - await validateMcpServerSsrf(body.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } - - const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : generateId() - - const [existingServer] = await db - .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) - - if (existingServer) { - logger.info( - `[${requestId}] Server with ID ${serverId} already exists, updating instead of creating` - ) - - await db - .update(mcpServers) - .set({ - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - updatedAt: new Date(), - deletedAt: null, - }) - .where(eq(mcpServers.id, serverId)) - - await mcpService.clearCache(workspaceId) - - logger.info( - `[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})` - ) - - return createMcpSuccessResponse({ serverId, updated: true }, 200) - } - - await db - .insert(mcpServers) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - await mcpService.clearCache(workspaceId) - - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})` - ) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.mcpServerAdded({ - serverId, - serverName: body.name, - transport: body.transport, - workspaceId, - }) - } catch (_e) { - // Silently fail - } - const sourceParam = body.source as string | undefined const source = sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - captureServerEvent( - userId, - 'mcp_server_connected', - { workspace_id: workspaceId, server_name: body.name, transport: body.transport, source }, - { - groups: { workspace: workspaceId }, - setOnce: { first_mcp_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ + if (!body.url) { + return createMcpErrorResponse( + new Error('url is required'), + 'Missing required parameter', + 400 + ) + } + const result = await performCreateMcpServer({ workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name, - description: `Added MCP server "${body.name}"`, - metadata: { - serverName: body.name, - transport: body.transport, - url: body.url, - timeout: body.timeout || 30000, - retries: body.retries || 3, - source: source, - }, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + source, request, }) + if (!result.success || !result.serverId) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to register MCP server'), + result.error || 'Failed to register MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } - return createMcpSuccessResponse({ serverId }, 201) + logger.info( + `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})` + ) + + return createMcpSuccessResponse( + result.updated + ? { serverId: result.serverId, updated: true } + : { serverId: result.serverId }, + result.updated ? 200 : 201 + ) } catch (error) { logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) @@ -256,48 +154,24 @@ export const DELETE = withRouteHandler( `[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}` ) - const [deletedServer] = await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning() - - if (!deletedServer) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } - - await mcpService.clearCache(workspaceId) - - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - - captureServerEvent( - userId, - 'mcp_server_disconnected', - { workspace_id: workspaceId, server_name: deletedServer.name, source }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ + const result = await performDeleteMcpServer({ workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId!, - resourceName: deletedServer.name, - description: `Removed MCP server "${deletedServer.name}"`, - metadata: { - serverName: deletedServer.name, - transport: deletedServer.transport, - url: deletedServer.url, - source, - }, + serverId, + source, request, }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to delete MCP server'), + result.error || 'Failed to delete MCP server', + mcpOrchestrationStatus(result.errorCode) + ) + } + + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 8836d712a74..95dc9f8adba 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -11,7 +10,10 @@ import { } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { + performDeleteWorkflowMcpServer, + performUpdateWorkflowMcpServer, +} from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('WorkflowMcpServerAPI') @@ -99,62 +101,30 @@ export const PATCH = withRouteHandler( logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) - const [existingServer] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) + const result = await performUpdateWorkflowMcpServer({ + serverId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + }) + if (!result.success || !result.server) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return createMcpErrorResponse( + new Error(result.error || 'Failed to update workflow MCP server'), + result.error || 'Failed to update workflow MCP server', + status ) - .limit(1) - - if (!existingServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) } - const updateData: Record = { - updatedAt: new Date(), - } - - if (body.name !== undefined) { - updateData.name = body.name.trim() - } - if (body.description !== undefined) { - updateData.description = body.description?.trim() || null - } - if (body.isPublic !== undefined) { - updateData.isPublic = body.isPublic - } - - const [updatedServer] = await db - .update(workflowMcpServer) - .set(updateData) - .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) - .returning() + const updatedServer = result.server logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name, - description: `Updated workflow MCP server "${updatedServer.name}"`, - metadata: { - serverName: updatedServer.name, - isPublic: updatedServer.isPublic, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating workflow MCP server:`, error) @@ -179,34 +149,23 @@ export const DELETE = withRouteHandler( logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) - const [deletedServer] = await db - .delete(workflowMcpServer) - .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) - ) - .returning() - - if (!deletedServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ + const result = await performDeleteWorkflowMcpServer({ + serverId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: deletedServer.name, - description: `Unpublished workflow MCP server "${deletedServer.name}"`, - metadata: { serverName: deletedServer.name }, - request, }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Server not found'), + result.error || 'Server not found', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + const deletedServer = result.server + + logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 14eda122b3e..95e54946ded 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -11,9 +10,8 @@ import { } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' const logger = createLogger('WorkflowMcpToolAPI') @@ -99,80 +97,31 @@ export const PATCH = withRouteHandler( logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) - ) + const result = await performUpdateWorkflowMcpTool({ + serverId, + toolId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterSchema: body.parameterSchema, + }) + if (!result.success || !result.tool) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return createMcpErrorResponse( + new Error(result.error || 'Failed to update tool'), + result.error || 'Failed to update tool', + status ) - .limit(1) - - if (!existingTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - const updateData: Record = { - updatedAt: new Date(), } - if (body.toolName !== undefined) { - updateData.toolName = sanitizeToolName(body.toolName) - } - if (body.toolDescription !== undefined) { - updateData.toolDescription = body.toolDescription?.trim() || null - } - if (body.parameterSchema !== undefined) { - updateData.parameterSchema = body.parameterSchema - } - - const [updatedTool] = await db - .update(workflowMcpTool) - .set(updateData) - .where(eq(workflowMcpTool.id, toolId)) - .returning() + const updatedTool = result.tool logger.info(`[${requestId}] Successfully updated tool ${toolId}`) - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Updated tool "${updatedTool.toolName}" in MCP server`, - metadata: { - toolId, - toolName: updatedTool.toolName, - workflowId: updatedTool.workflowId, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - return createMcpSuccessResponse({ tool: updatedTool }) } catch (error) { logger.error(`[${requestId}] Error updating tool:`, error) @@ -197,47 +146,24 @@ export const DELETE = withRouteHandler( logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [deletedTool] = await db - .delete(workflowMcpTool) - .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) - .returning() - - if (!deletedTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ + const result = await performDeleteWorkflowMcpTool({ + serverId, + toolId, workspaceId, - actorId: userId, + userId, actorName: userName, actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Removed tool "${deletedTool.toolName}" from MCP server`, - metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId }, - request, }) + if (!result.success || !result.tool) { + return createMcpErrorResponse( + new Error(result.error || 'Tool not found'), + result.error || 'Tool not found', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + const deletedTool = result.tool + + logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) } catch (error) { diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index cc000883893..85e37371d57 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -1,9 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { @@ -12,11 +10,8 @@ import { } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { performCreateWorkflowMcpTool } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpToolsAPI') @@ -110,134 +105,33 @@ export const POST = withRouteHandler( workflowId: body.workflowId, }) - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [workflowRecord] = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt))) - .limit(1) - - if (!workflowRecord) { - return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) - } - - if (workflowRecord.workspaceId !== workspaceId) { - return createMcpErrorResponse( - new Error('Workflow does not belong to this workspace'), - 'Access denied', - 403 - ) - } - - if (!workflowRecord.isDeployed) { - return createMcpErrorResponse( - new Error('Workflow must be deployed before adding as a tool'), - 'Workflow not deployed', - 400 - ) - } - - const hasStartBlock = await hasValidStartBlock(body.workflowId) - if (!hasStartBlock) { - return createMcpErrorResponse( - new Error('Workflow must have a Start block to be used as an MCP tool'), - 'No start block found', - 400 - ) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, body.workflowId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) - - if (existingTool) { + const result = await performCreateWorkflowMcpTool({ + serverId, + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + workflowId: body.workflowId, + toolName: body.toolName, + toolDescription: body.toolDescription, + parameterSchema: body.parameterSchema, + }) + if (!result.success || !result.tool) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 400 return createMcpErrorResponse( - new Error('This workflow is already added as a tool to this server'), - 'Tool already exists', - 409 + new Error(result.error || 'Failed to add tool'), + result.error || 'Failed to add tool', + result.errorCode === 'internal' ? 500 : status ) } - const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name) - const toolDescription = - body.toolDescription?.trim() || - workflowRecord.description || - `Execute ${workflowRecord.name} workflow` - - const parameterSchema = - body.parameterSchema && Object.keys(body.parameterSchema).length > 0 - ? body.parameterSchema - : await generateParameterSchemaForWorkflow(body.workflowId) - - const toolId = generateId() - const [tool] = await db - .insert(workflowMcpTool) - .values({ - id: toolId, - serverId, - workflowId: body.workflowId, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() + const tool = result.tool logger.info( - `[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}` + `[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}` ) - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Added tool "${toolName}" to MCP server`, - metadata: { - toolId, - toolName, - toolDescription, - workflowId: body.workflowId, - workflowName: workflowRecord.name, - }, - request, - }) - return createMcpSuccessResponse({ tool }, 201) } catch (error) { logger.error(`[${requestId}] Error adding tool:`, error) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 49efe49f2a3..a0bb67c6b28 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -1,19 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createWorkflowMcpServerBodySchema } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpPubSub } from '@/lib/mcp/pubsub' +import { performCreateWorkflowMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpServersAPI') @@ -112,111 +107,31 @@ export const POST = withRouteHandler( workflowIds: body.workflowIds, }) - const serverId = generateId() - - const [server] = await db - .insert(workflowMcpServer) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name.trim(), - description: body.description?.trim() || null, - isPublic: body.isPublic ?? false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - const workflowIds: string[] = body.workflowIds || [] - const addedTools: Array<{ workflowId: string; toolName: string }> = [] - - if (workflowIds.length > 0) { - const workflows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) - - for (const workflowRecord of workflows) { - if (workflowRecord.workspaceId !== workspaceId) { - logger.warn( - `[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace` - ) - continue - } - - if (!workflowRecord.isDeployed) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`) - continue - } - - const hasStartBlock = await hasValidStartBlock(workflowRecord.id) - if (!hasStartBlock) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`) - continue - } - - const toolName = sanitizeToolName(workflowRecord.name) - const toolDescription = - workflowRecord.description || `Execute ${workflowRecord.name} workflow` - - const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) - - const toolId = generateId() - await db.insert(workflowMcpTool).values({ - id: toolId, - serverId, - workflowId: workflowRecord.id, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - - addedTools.push({ workflowId: workflowRecord.id, toolName }) - } - - logger.info( - `[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`, - addedTools.map((t) => t.toolName) + const result = await performCreateWorkflowMcpServer({ + workspaceId, + userId, + actorName: userName, + actorEmail: userEmail, + name: body.name, + description: body.description, + isPublic: body.isPublic, + workflowIds: body.workflowIds, + }) + if (!result.success || !result.server) { + return createMcpErrorResponse( + new Error(result.error || 'Failed to create workflow MCP server'), + result.error || 'Failed to create workflow MCP server', + result.errorCode === 'validation' ? 400 : 500 ) - - if (addedTools.length > 0) { - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - } } + const { server } = result + const addedTools = result.addedTools || [] + logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})` ) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name.trim(), - description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, - metadata: { - serverName: body.name.trim(), - isPublic: body.isPublic ?? false, - toolCount: addedTools.length, - toolNames: addedTools.map((t) => t.toolName), - workflowIds: addedTools.map((t) => t.workflowId), - }, - request, - }) - return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { logger.error(`[${requestId}] Error creating workflow MCP server:`, error) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index bc80d9cec56..129ec4c1582 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -15,6 +15,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteJob, performUpdateJob } from '@/lib/workflows/schedules/orchestration' import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -168,58 +169,32 @@ export const PUT = withRouteHandler( ) } - const updates = validatedBody - const setFields: Record = { updatedAt: new Date() } - - if (updates.title !== undefined) setFields.jobTitle = updates.title.trim() - if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim() - if (updates.timezone !== undefined) setFields.timezone = updates.timezone - if (updates.lifecycle !== undefined) { - setFields.lifecycle = updates.lifecycle - if (updates.lifecycle === 'persistent') { - setFields.maxRuns = null - } + if (!workspaceId) { + return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) } - if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns - - if (updates.cronExpression !== undefined) { - const tz = updates.timezone ?? schedule.timezone ?? 'UTC' - const cronResult = validateCronExpression(updates.cronExpression, tz) - if (!cronResult.isValid) { - return NextResponse.json( - { error: cronResult.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - setFields.cronExpression = updates.cronExpression - if (schedule.status === 'active' && cronResult.nextRun) { - setFields.nextRunAt = cronResult.nextRun - } - } - - await db - .update(workflowSchedule) - .set(setFields) - .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - - logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) - recordAudit({ + const updateResult = await performUpdateJob({ + jobId: scheduleId, workspaceId, - actorId: session.user.id, + userId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - resourceName: schedule.jobTitle ?? undefined, - description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`, - metadata: { - operation: 'update', - updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'), - }, + title: validatedBody.title, + prompt: validatedBody.prompt, + timezone: validatedBody.timezone, + lifecycle: validatedBody.lifecycle, + maxRuns: validatedBody.maxRuns, + cronExpression: validatedBody.cronExpression, request, }) + if (!updateResult.success) { + return NextResponse.json( + { error: updateResult.error || 'Failed to update schedule' }, + { status: updateResult.errorCode === 'validation' ? 400 : 500 } + ) + } + + logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) return NextResponse.json({ message: 'Schedule updated successfully' }) } @@ -298,6 +273,27 @@ export const DELETE = withRouteHandler( if (result instanceof NextResponse) return result const { schedule, workspaceId } = result + if (schedule.sourceType === 'job') { + if (!workspaceId) { + return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) + } + const deleteResult = await performDeleteJob({ + jobId: scheduleId, + workspaceId, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + if (!deleteResult.success) { + return NextResponse.json( + { error: deleteResult.error || 'Failed to delete schedule' }, + { status: deleteResult.errorCode === 'not_found' ? 404 : 500 } + ) + } + return NextResponse.json({ message: 'Schedule deleted successfully' }) + } + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 574166ee4c3..37bdc00ab24 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -1,8 +1,6 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -11,8 +9,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { validateCronExpression } from '@/lib/workflows/schedules/utils' +import { performCreateJob } from '@/lib/workflows/schedules/orchestration' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('ScheduledAPI') @@ -228,80 +225,43 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const validation = validateCronExpression(cronExpression, timezone) - if (!validation.isValid) { - return NextResponse.json( - { error: validation.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - - let nextRunAt = validation.nextRun! - if (startDate) { - const start = new Date(startDate) - if (start > new Date()) { - nextRunAt = start - } - } - - const now = new Date() - const id = generateId() - - await db.insert(workflowSchedule).values({ - id, + const result = await performCreateJob({ + workspaceId, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + title, + prompt, cronExpression, - triggerType: 'schedule', - sourceType: 'job', - status: 'active', timezone, - nextRunAt, - createdAt: now, - updatedAt: now, - failedCount: 0, - jobTitle: title.trim(), - prompt: prompt.trim(), lifecycle, - maxRuns: maxRuns ?? null, - runCount: 0, - sourceWorkspaceId: workspaceId, - sourceUserId: session.user.id, + maxRuns, + startDate, + request: req, }) + if (!result.success || !result.schedule) { + return NextResponse.json( + { error: result.error || 'Failed to create schedule' }, + { status: result.errorCode === 'validation' ? 400 : 500 } + ) + } - logger.info(`[${requestId}] Created job schedule ${id}`, { + logger.info(`[${requestId}] Created job schedule ${result.schedule.id}`, { title, cronExpression, timezone, lifecycle, }) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SCHEDULE_CREATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: id, - resourceName: title.trim(), - description: `Created job schedule "${title.trim()}"`, - metadata: { - cronExpression, - timezone, - lifecycle, - maxRuns: maxRuns ?? null, - }, - request: req, - }) - - captureServerEvent( - session.user.id, - 'scheduled_task_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json( - { schedule: { id, status: 'active', cronExpression, nextRunAt } }, + { + schedule: { + id: result.schedule.id, + status: result.schedule.status, + cronExpression: result.schedule.cronExpression, + nextRunAt: result.schedule.nextRunAt, + }, + }, { status: 201 } ) } catch (error) { diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index c6955984fed..e7dd16ff85b 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -1,11 +1,11 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableIdParamsSchema } from '@/lib/api/contracts/tables' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getTableById, restoreTable, TableConflictError } from '@/lib/table' +import { getTableById } from '@/lib/table' +import { performRestoreTable } from '@/lib/table/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreTableAPI') @@ -31,33 +31,20 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await restoreTable(tableId, requestId) + const result = await performRestoreTable({ tableId, userId: auth.userId, requestId }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } logger.info(`[${requestId}] Restored table ${tableId}`) - recordAudit({ - workspaceId: table.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - metadata: { - tableName: table.name, - workspaceId: table.workspaceId, - }, - request, + return NextResponse.json({ + success: true, + data: { table: result.table }, }) - - return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring table ${tableId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 70900716520..89a48b80896 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -182,33 +182,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) + const responseTables = tables.map((t) => { + const schemaData = t.schema as TableSchema + return { + id: t.id, + name: t.name, + description: t.description, + schema: { + columns: schemaData.columns.map(normalizeColumn), + }, + rowCount: t.rowCount, + maxRows: t.maxRows, + workspaceId: t.workspaceId, + createdBy: t.createdBy, + createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), + updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), + archivedAt: + t.archivedAt instanceof Date + ? t.archivedAt.toISOString() + : t.archivedAt + ? String(t.archivedAt) + : null, + } + }) + return NextResponse.json({ success: true, data: { - tables: tables.map((t) => { - const schemaData = t.schema as TableSchema - return { - id: t.id, - name: t.name, - description: t.description, - schema: { - columns: schemaData.columns.map(normalizeColumn), - }, - rowCount: t.rowCount, - maxRows: t.maxRows, - createdBy: t.createdBy, - createdAt: - t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt), - updatedAt: - t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt), - archivedAt: - t.archivedAt instanceof Date - ? t.archivedAt.toISOString() - : t.archivedAt - ? String(t.archivedAt) - : null, - } - }), + tables: responseTables, totalCount: tables.length, }, }) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 65c129787b1..cfb53f06430 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -3,17 +3,20 @@ import { type NextRequest, NextResponse } from 'next/server' import { fileManageContract } from '@/lib/api/contracts/tools/file' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer, getWorkspaceFile, - getWorkspaceFileByName, + resolveWorkspaceFileReference, updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -143,11 +146,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const selectedFileId = fileId || (fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput) - ? typeof fileInput.id === 'string' - ? fileInput.id - : typeof fileInput.fileId === 'string' - ? fileInput.fileId - : '' + ? (() => { + const obj = fileInput as Record + return typeof obj.id === 'string' + ? obj.id + : typeof obj.fileId === 'string' + ? obj.fileId + : '' + })() : '') if (!selectedFileId) { @@ -222,14 +228,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'write': { const { fileName, content, contentType } = body - const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) + const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments: folderSegments, + }) + const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName)) const fileBuffer = Buffer.from(content ?? '', 'utf-8') const result = await uploadWorkspaceFile( workspaceId, userId, fileBuffer, - fileName, - mimeType + leafName, + mimeType, + { folderId } ) logger.info('File created', { @@ -249,10 +262,50 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } + case 'move': { + const { fileId, targetFolder } = body + const pathSegments = targetFolder.trim() + ? targetFolder + .trim() + .split('/') + .map((s) => s.trim()) + .filter(Boolean) + : [] + const targetFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId, + pathSegments, + }) + const moveResult = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + targetFolderId, + }) + if (!moveResult.success) { + return NextResponse.json( + { success: false, error: moveResult.error }, + { + status: + moveResult.errorCode === 'conflict' + ? 409 + : moveResult.errorCode === 'not_found' + ? 404 + : 400, + } + ) + } + logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' }) + return NextResponse.json({ + success: true, + data: { fileId, targetFolder: targetFolder || '(root)' }, + }) + } + case 'append': { const { fileName, content } = body - const existing = await getWorkspaceFileByName(workspaceId, fileName) + const existing = await resolveWorkspaceFileReference(workspaceId, fileName) if (!existing) { return NextResponse.json( { success: false, error: `File not found: "${fileName}"` }, diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index a2e4c029d1d..5482a4d378f 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -1,15 +1,11 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files' import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - deleteWorkspaceFile, - fetchWorkspaceFileBuffer, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, createRateLimitResponse, @@ -97,24 +93,19 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil return NextResponse.json({ error: 'File not found' }, { status: 404 }) } - await deleteWorkspaceFile(workspaceId, fileId) + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + }) + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 500 }) + } logger.info( `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` ) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Archived file "${fileRecord.name}" via API`, - metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, - request, - }) - return NextResponse.json({ success: true, data: { diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index 8c6b7a928ea..7d20bf3e3eb 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' @@ -8,7 +7,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { restoreWorkflow } from '@/lib/workflows/lifecycle' +import { performRestoreWorkflow } from '@/lib/workflows/orchestration' import { getWorkflowById } from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -50,31 +49,20 @@ export const POST = withRouteHandler( } await assertFolderMutable(workflowData.folderId) - const result = await restoreWorkflow(workflowId, { requestId }) + const result = await performRestoreWorkflow({ + workflowId, + userId: auth.userId, + requestId, + }) - if (!result.restored) { - return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 }) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) } logger.info(`[${requestId}] Restored workflow ${workflowId}`) - recordAudit({ - workspaceId: workflowData.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_RESTORED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name, - description: `Restored workflow "${workflowData.name}"`, - metadata: { - workflowName: workflowData.name, - workspaceId: workflowData.workspaceId || undefined, - }, - request, - }) - captureServerEvent( auth.userId, 'workflow_restored', diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 68a0ae3e57d..d752a3e6dc5 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -27,6 +27,7 @@ const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById const mockAuthorizeWorkflowByWorkspacePermission = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow +const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({ mockDbUpdate: vi.fn(), @@ -85,6 +86,22 @@ describe('Workflow By ID API Route', () => { }) mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) + mockPerformUpdateWorkflow.mockImplementation(async (params) => ({ + success: true, + workflow: { + id: params.workflowId, + name: params.name ?? params.currentName, + description: params.description ?? null, + color: params.color ?? null, + workspaceId: params.workspaceId, + folderId: params.folderId ?? params.currentFolderId ?? null, + sortOrder: params.sortOrder ?? null, + locked: params.locked ?? null, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }, + })) mockDbTransaction.mockImplementation(async (callback) => callback({ execute: vi.fn().mockResolvedValue(undefined), @@ -595,8 +612,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "Duplicate Name" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -628,8 +648,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "Duplicate Name" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -748,9 +771,11 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'write', }) - - // Duplicate exists in target folder - mockDuplicateCheck([{ id: 'workflow-other' }]) + mockPerformUpdateWorkflow.mockResolvedValueOnce({ + success: false, + error: 'A workflow named "My Workflow" already exists in this folder', + errorCode: 'conflict', + }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 20c9c896ba5..2f3f0ebe3ce 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -8,7 +8,7 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/workflow-authz' -import { and, eq, isNull, ne, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -16,7 +16,7 @@ import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/aut import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkflow } from '@/lib/workflows/orchestration' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { getWorkflowById } from '@/lib/workflows/utils' @@ -338,78 +338,33 @@ export const PUT = withRouteHandler( await assertFolderMutable(updates.folderId) } - const updateData: Record = { updatedAt: new Date() } - if (updates.name !== undefined) updateData.name = updates.name - if (updates.description !== undefined) updateData.description = updates.description - if (updates.color !== undefined) updateData.color = updates.color - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder - if (updates.locked !== undefined) updateData.locked = updates.locked - - if (updates.name !== undefined || updates.folderId !== undefined) { - const targetName = updates.name ?? workflowData.name - const targetFolderId = - updates.folderId !== undefined ? updates.folderId : workflowData.folderId - - if (!workflowData.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - - const conditions = [ - eq(workflow.workspaceId, workflowData.workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, targetName), - ne(workflow.id, workflowId), - ] - - if (targetFolderId) { - conditions.push(eq(workflow.folderId, targetFolderId)) - } else { - conditions.push(isNull(workflow.folderId)) - } + if (!workflowData.workspaceId) { + logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } - const [duplicate] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...conditions)) - .limit(1) + const result = await performUpdateWorkflow({ + workflowId, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + ...updates, + requestId, + }) - if (duplicate) { - logger.warn( - `[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}` - ) - return NextResponse.json( - { error: `A workflow named "${targetName}" already exists in this folder` }, - { status: 409 } - ) - } + if (!result.success || !result.workflow) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - color: workflow.color, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - locked: workflow.locked, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - archivedAt: workflow.archivedAt, - }) - const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates: updateData, + updates, }) - return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) + return NextResponse.json({ workflow: result.workflow }, { status: 200 }) } catch (error: any) { if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index ed10d8dc497..ebb1e11a1d6 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -87,9 +87,9 @@ describe('Workflows API Route - POST ordering', () => { it('uses top insertion against mixed siblings (folders + workflows)', async () => { const minResultsQueue: Array> = [ + [], [{ minOrder: 5 }], [{ minOrder: 2 }], - [], ] mockDbSelect.mockImplementation(() => ({ diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 17024877312..4a80994510e 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,9 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, workflow, workflowFolder } from '@sim/db/schema' +import { permissions, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -11,9 +9,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -183,86 +179,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - const workflowId = clientId || generateId() - const now = new Date() - - logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`) - - let sortOrder: number - if (providedSortOrder !== undefined) { - sortOrder = providedSortOrder - } else { - const workflowParentCondition = folderId - ? eq(workflow.folderId, folderId) - : isNull(workflow.folderId) - const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) - - const [[workflowMinResult], [folderMinResult]] = await Promise.all([ - db - .select({ minOrder: min(workflow.sortOrder) }) - .from(workflow) - .where( - and( - eq(workflow.workspaceId, workspaceId), - workflowParentCondition, - isNull(workflow.archivedAt) - ) - ), - db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), - ]) - - const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< - number | null - >((currentMin, candidate) => { - if (candidate == null) return currentMin - if (currentMin == null) return candidate - return Math.min(currentMin, candidate) - }, null) + const result = await performCreateWorkflow({ + id: clientId, + name: requestedName, + description, + color, + workspaceId, + folderId, + sortOrder: providedSortOrder, + deduplicate, + userId, + requestId, + }) - sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 + if (!result.success || !result.workflow) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } - let name = requestedName - - if (deduplicate) { - name = await deduplicateWorkflowName(requestedName, workspaceId, folderId) - } else { - const duplicateConditions = [ - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, requestedName), - ] - - if (folderId) { - duplicateConditions.push(eq(workflow.folderId, folderId)) - } else { - duplicateConditions.push(isNull(workflow.folderId)) - } - - const [duplicateWorkflow] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...duplicateConditions)) - .limit(1) - - if (duplicateWorkflow) { - return NextResponse.json( - { error: `A workflow named "${requestedName}" already exists in this folder` }, - { status: 409 } - ) - } - } + const createdWorkflow = result.workflow import('@/lib/core/telemetry') .then(({ PlatformEvents }) => { PlatformEvents.workflowCreated({ - workflowId, - name, + workflowId: createdWorkflow.id, + name: createdWorkflow.name, workspaceId: workspaceId || undefined, folderId: folderId || undefined, }) @@ -271,74 +212,36 @@ export const POST = withRouteHandler(async (req: NextRequest) => { // Silently fail }) - const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts() - - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId, - workspaceId, - folderId: folderId || null, - sortOrder, - name, - description, - color, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) - - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) - }) - - logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`) + logger.info( + `[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks` + ) captureServerEvent( userId, 'workflow_created', - { workflow_id: workflowId, workspace_id: workspaceId ?? '', name }, + { + workflow_id: createdWorkflow.id, + workspace_id: workspaceId ?? '', + name: createdWorkflow.name, + }, { groups: workspaceId ? { workspace: workspaceId } : undefined, setOnce: { first_workflow_created_at: new Date().toISOString() }, } ) - recordAudit({ - workspaceId, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_CREATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: name, - description: `Created workflow "${name}"`, - metadata: { - name, - description: description || undefined, - color, - workspaceId, - folderId: folderId || undefined, - sortOrder, - }, - request: req, - }) - return NextResponse.json({ - id: workflowId, - name, - description, - color, - workspaceId, - folderId, - sortOrder, - createdAt: now, - updatedAt: now, - startBlockId, - subBlockValues, + id: createdWorkflow.id, + name: createdWorkflow.name, + description: createdWorkflow.description, + color: createdWorkflow.color, + workspaceId: createdWorkflow.workspaceId, + folderId: createdWorkflow.folderId, + sortOrder: createdWorkflow.sortOrder, + createdAt: createdWorkflow.createdAt, + updatedAt: createdWorkflow.updatedAt, + startBlockId: createdWorkflow.startBlockId, + subBlockValues: createdWorkflow.subBlockValues, }) } catch (error) { logger.error(`[${requestId}] Error creating workflow`, error) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index 0d9caeec189..6242eb64a01 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,8 +9,8 @@ import { deleteWorkspaceApiKeysContract, } from '@/lib/api/contracts/api-keys' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -106,60 +105,17 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { name, source } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where( - and( - eq(apiKey.workspaceId, workspaceId), - eq(apiKey.name, name), - eq(apiKey.type, 'workspace') - ) - ) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A workspace API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - workspaceId, - userId: userId, - createdBy: userId, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'workspace', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - try { - PlatformEvents.apiKeyGenerated({ - userId: userId, - keyName: name, - }) - } catch { - // Telemetry should not fail the operation + const result = await performCreateWorkspaceApiKey({ + workspaceId, + userId, + name, + source, + actorName: session.user.name, + actorEmail: session.user.email, + }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) } captureServerEvent( @@ -174,25 +130,8 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - resourceName: name, - description: `Created API key "${name}"`, - metadata: { keyName: name, keyType: 'workspace', source: source ?? 'settings' }, - request, - }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, + key: result.key, }) } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key POST error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index fe6ef946bc1..fe225810380 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' @@ -6,7 +5,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkspaceFileAPI') @@ -38,28 +37,22 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await restoreWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - recordAudit({ + const result = await performRestoreWorkspaceFile({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_RESTORED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileId, - description: `Restored workspace file ${fileId}`, - request, + fileId, + userId: session.user.id, }) + if (!result.success) { + return NextResponse.json( + { error: result.error }, + { status: result.errorCode === 'conflict' ? 409 : 500 } + ) + } + + logger.info(`[${requestId}] Restored workspace file ${fileId}`) return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof FileConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index dea5882bc0d..cba4dd8a72f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -9,11 +8,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' import { - deleteWorkspaceFile, - FileConflictError, - renameWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -51,26 +50,30 @@ export const PATCH = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name) - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`) - - recordAudit({ + const result = await performRenameWorkspaceFile({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Renamed file to "${updatedFile.name}"`, - request, + fileId, + name, + userId: session.user.id, }) + if (!result.success || !result.file) { + return NextResponse.json( + { success: false, error: result.error }, + { status: result.errorCode === 'conflict' ? 409 : 500 } + ) + } + + logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`) + captureServerEvent( + session.user.id, + 'file_renamed', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) return NextResponse.json({ success: true, - file: updatedFile, + file: result.file, }) } catch (error) { logger.error(`[${requestId}] Error renaming workspace file:`, error) @@ -79,7 +82,7 @@ export const PATCH = withRouteHandler( success: false, error: error instanceof Error ? error.message : 'Failed to rename file', }, - { status: error instanceof FileConflictError ? 409 : 500 } + { status: 500 } ) } } @@ -120,22 +123,33 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - recordAudit({ + const result = await performDeleteWorkspaceFileItems({ workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - description: `Archived file "${fileId}"`, - request, + userId: session.user.id, + fileIds: [fileId], }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + logger.info(`[${requestId}] Archived workspace file: ${fileId}`) + + captureServerEvent( + session.user.id, + 'file_deleted', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) return NextResponse.json({ success: true, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts new file mode 100644 index 00000000000..1f47f650bd6 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileBulkArchiveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + fileIds, + folderIds, + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + if (!result.deletedItems) { + return NextResponse.json( + { success: false, error: 'Failed to delete workspace file items' }, + { status: 500 } + ) + } + + captureServerEvent( + session.user.id, + 'file_bulk_deleted', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, deletedItems: result.deletedItems }) + } catch (error) { + logger.error('Failed to bulk archive workspace file items:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts new file mode 100644 index 00000000000..c65b5438158 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import JSZip from 'jszip' +import { type NextRequest, NextResponse } from 'next/server' +import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + buildWorkspaceFileFolderPathMap, + fetchWorkspaceFileBuffer, + listWorkspaceFileFolders, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +const logger = createLogger('WorkspaceFilesDownloadAPI') +const MAX_ZIP_DOWNLOAD_FILES = 100 +const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 + +function safeZipPath(path: string): string { + return path + .split('/') + .map((segment) => { + const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_') + return cleaned === '.' || cleaned === '..' ? '_' : cleaned + }) + .filter(Boolean) + .join('/') +} + +function withZipPathSuffix(path: string, suffix: number): string { + const slashIndex = path.lastIndexOf('/') + const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : '' + const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path + const dotIndex = filename.lastIndexOf('.') + + return dotIndex > 0 + ? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}` + : `${directory}${filename} (${suffix})` +} + +function collectDescendantFolderIds( + selectedFolderIds: string[], + folders: Array<{ id: string; parentId: string | null }> +): Set { + const folderIds = new Set(selectedFolderIds) + let changed = true + while (changed) { + changed = false + for (const folder of folders) { + if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { + folderIds.add(folder.id) + changed = true + } + } + } + return folderIds +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds } = parsed.data.query + + const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const [files, folders] = await Promise.all([ + listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }), + listWorkspaceFileFolders(workspaceId), + ]) + const folderPaths = buildWorkspaceFileFolderPathMap(folders) + const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const requestedFileIds = new Set(fileIds) + const filesToZip = files.filter( + (file) => + requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId)) + ) + + if (filesToZip.length === 0) { + return NextResponse.json({ error: 'No files selected for download' }, { status: 400 }) + } + + if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { + return NextResponse.json( + { + error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`, + }, + { status: 400 } + ) + } + + const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) { + return NextResponse.json( + { + error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, + }, + { status: 400 } + ) + } + + const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file))) + + // Assemble zip synchronously so path deduplication is deterministic. + const zip = new JSZip() + const usedPaths = new Set() + for (let i = 0; i < filesToZip.length; i++) { + const file = filesToZip[i] + const buffer = buffers[i] + const folderPath = file.folderId ? folderPaths.get(file.folderId) : null + const basePath = + safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) || + safeZipPath(file.name) || + file.id + let zipPath = basePath + let suffix = 2 + while (usedPaths.has(zipPath)) { + zipPath = withZipPathSuffix(basePath, suffix) + suffix++ + } + usedPaths.add(zipPath) + zip.file(zipPath, buffer) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) + return new NextResponse(new Uint8Array(zipBuffer), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="workspace-files.zip"', + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + logger.error('Failed to download workspace file selection:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..86df6e83f91 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,66 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + performRestoreWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFolderRestoreAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performRestoreWorkspaceFileFolder({ + workspaceId, + folderId, + userId: session.user.id, + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + const { folder, restoredItems } = result + if (!folder || !restoredItems) { + return NextResponse.json( + { success: false, error: 'Failed to restore workspace file folder' }, + { status: 500 } + ) + } + + logger.info(`Restored workspace file folder: ${folderId}`) + + captureServerEvent( + session.user.id, + 'folder_restored', + { folder_id: folderId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder, restoredItems }) + } catch (error) { + logger.error('Failed to restore workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..78232e8a704 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -0,0 +1,121 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + deleteWorkspaceFileFolderContract, + updateWorkspaceFileFolderContract, +} from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { + performDeleteWorkspaceFileItems, + performUpdateWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFolderAPI') + +async function assertWritePermission(userId: string, workspaceId: string) { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + return permission === 'admin' || permission === 'write' +} + +export const PATCH = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + if (!(await assertWritePermission(session.user.id, workspaceId))) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId, + userId: session.user.id, + ...parsed.data.body, + }) + if (!result.success || !result.folder) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + captureServerEvent( + session.user.id, + 'folder_renamed', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder: result.folder }) + } catch (error) { + logger.error('Failed to update workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, folderId } = parsed.data.params + + if (!(await assertWritePermission(session.user.id, workspaceId))) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + folderIds: [folderId], + }) + if (!result.success) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'validation' + ? 400 + : result.errorCode === 'not_found' + ? 404 + : 500, + } + ) + } + if (!result.deletedItems) { + return NextResponse.json( + { success: false, error: 'Failed to delete workspace file folder' }, + { status: 500 } + ) + } + + captureServerEvent( + session.user.id, + 'folder_deleted', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return NextResponse.json({ success: true, deletedItems: result.deletedItems }) + } catch (error) { + logger.error('Failed to delete workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts new file mode 100644 index 00000000000..02de14dcf1e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + createWorkspaceFileFolderContract, + listWorkspaceFileFoldersContract, +} from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' +import { + performCreateWorkspaceFileFolder, + workspaceFilesOrchestrationStatus, +} from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileFoldersAPI') + +async function getWorkspacePermission(userId: string, workspaceId: string) { + return getUserEntityPermissions(userId, 'workspace', workspaceId) +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { scope } = parsed.data.query + + const permission = await getWorkspacePermission(session.user.id, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const folders = await listWorkspaceFileFolders(workspaceId, { scope }) + return NextResponse.json({ success: true, folders }) + } +) + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { name, parentId } = parsed.data.body + + const permission = await getWorkspacePermission(session.user.id, workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performCreateWorkspaceFileFolder({ + workspaceId, + userId: session.user.id, + name, + parentId, + }) + if (!result.success || !result.folder) { + return NextResponse.json( + { success: false, error: result.error }, + { status: workspaceFilesOrchestrationStatus(result.errorCode) } + ) + } + captureServerEvent( + session.user.id, + 'folder_created', + { workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + return NextResponse.json({ success: true, folder: result.folder }) + } catch (error) { + logger.error('Failed to create workspace file folder:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts new file mode 100644 index 00000000000..81861789eee --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceFileMoveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, folderIds, targetFolderId } = parsed.data.body + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + try { + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId: session.user.id, + fileIds, + folderIds, + targetFolderId, + }) + if (!result.success || !result.movedItems) { + return NextResponse.json( + { success: false, error: result.error }, + { + status: + result.errorCode === 'conflict' + ? 409 + : result.errorCode === 'not_found' + ? 404 + : result.errorCode === 'validation' + ? 400 + : 500, + } + ) + } + if (fileIds.length > 0) { + captureServerEvent( + session.user.id, + 'file_moved', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + } + if (folderIds.length > 0) { + captureServerEvent( + session.user.id, + 'folder_moved', + { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, + { groups: { workspace: workspaceId } } + ) + } + return NextResponse.json({ + success: true, + movedItems: result.movedItems, + }) + } catch (error) { + logger.error('Failed to move workspace file items:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts index 332a9386ca7..1227f93c504 100644 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { checkStorageQuota } from '@/lib/billing/storage' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -31,7 +32,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { params, body } = parsed.data const workspaceId = params.id - const { fileName, contentType, fileSize } = body + const { fileName, contentType, fileSize, folderId } = body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'admin' && permission !== 'write') { @@ -46,6 +47,16 @@ export const POST = withRouteHandler( ) } + let targetFolderId: string | null + try { + targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid target folder' }, + { status: 400 } + ) + } + if (!hasCloudStorage()) { logger.info(`Local storage detected, signaling API fallback for ${fileName}`) return NextResponse.json({ @@ -73,7 +84,7 @@ export const POST = withRouteHandler( userId, customKey: key, expirationSeconds: 3600, - metadata: { workspaceId }, + metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) }, }) const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace` diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts index dfcaa537b5e..0b6d4876ab3 100644 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/register/route.ts @@ -33,7 +33,7 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { params, body } = parsed.data const workspaceId = params.id - const { key, name, contentType } = body + const { key, name, contentType, folderId } = body const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (permission !== 'admin' && permission !== 'write') { @@ -56,6 +56,7 @@ export const POST = withRouteHandler( key, originalName: name, contentType, + folderId, }) if (created) { diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index d89b12118e8..d8d4c2e691c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -123,6 +123,9 @@ export const POST = withRouteHandler( const formData = await request.formData() const rawFile = formData.get('file') + const rawFolderId = formData.get('folderId') + const folderId = + typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null if (!rawFile || !(rawFile instanceof File)) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) @@ -146,7 +149,8 @@ export const POST = withRouteHandler( session.user.id, buffer, fileName, - rawFile.type || 'application/octet-stream' + rawFile.type || 'application/octet-stream', + { folderId } ) logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index d609462d9a1..716bb0e85d8 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -41,6 +41,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, ModalTabs, @@ -859,7 +860,9 @@ export default function PlaygroundPage() { Modal {size.toUpperCase()} -

This is a {size} sized modal.

+ + This is a {size} sized modal. +
@@ -882,6 +885,9 @@ export default function PlaygroundPage() { Advanced + + Modal settings with general and advanced tabs +

General settings content

diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index a81ec62c746..66ba3cfdecf 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -22,6 +22,7 @@ export type { ResourceCell, ResourceColumn, ResourceRow, + RowDragDropConfig, SelectableConfig, } from './resource/resource' export { Resource, ResourceTable } from './resource/resource' diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 298aafbf722..359f152fa0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -10,6 +10,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Textarea, @@ -239,6 +240,9 @@ export const MessageActions = memo(function MessageActions({ Give feedback + + Submit feedback about this response +

diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx index 5d1b1a76179..4f028948794 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx @@ -11,6 +11,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, } from '@/components/emcn' @@ -230,6 +231,9 @@ export function OAuthModal(props: OAuthModalProps) { Connect {providerName} + + Connect your {providerName} account to grant access +

diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 6e3e6868f96..42b15a67e6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -1,5 +1,14 @@ 'use client' -import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type DragEvent, + memo, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import { ArrowDown, ArrowUp, Button, Checkbox, Loader, Plus, Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -30,12 +39,25 @@ export interface ResourceRow { export interface SelectableConfig { selectedIds: Set - onSelectRow: (id: string, checked: boolean) => void + onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void onSelectAll: (checked: boolean) => void isAllSelected: boolean disabled?: boolean } +export interface RowDragDropConfig { + activeDropTargetId?: string | null + draggedRowIds?: Set + isAnyDragActive?: boolean + isRowDraggable?: (rowId: string) => boolean + isRowDropTarget?: (rowId: string) => boolean + onDragStart?: (e: DragEvent, rowId: string) => void + onDragOver?: (e: DragEvent, rowId: string) => void + onDragLeave?: (e: DragEvent, rowId: string) => void + onDrop?: (e: DragEvent, rowId: string) => void + onDragEnd?: (e: DragEvent, rowId: string) => void +} + export interface PaginationConfig { currentPage: number totalPages: number @@ -51,10 +73,12 @@ interface ResourceProps { defaultSort?: string sort?: SortConfig headerActions?: HeaderAction[] + leadingActions?: ReactNode columns: ResourceColumn[] rows: ResourceRow[] selectedRowId?: string | null selectable?: SelectableConfig + rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void @@ -71,8 +95,6 @@ interface ResourceProps { const EMPTY_CELL_PLACEHOLDER = '- - -' const SKELETON_ROW_COUNT = 5 -const stopPropagation = (e: React.MouseEvent) => e.stopPropagation() - /** * Shared page shell for resource list pages (tables, files, knowledge, schedules, logs). * Renders the header, toolbar with search, and a data table from column/row definitions. @@ -86,10 +108,12 @@ export const Resource = memo(function Resource({ defaultSort, sort: sortOverride, headerActions, + leadingActions, columns, rows, selectedRowId, selectable, + rowDragDrop, onRowClick, onRowHover, onRowContextMenu, @@ -113,6 +137,7 @@ export const Resource = memo(function Resource({ breadcrumbs={breadcrumbs} create={create} actions={headerActions} + leadingActions={leadingActions} /> void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void @@ -172,6 +199,7 @@ export const ResourceTable = memo(function ResourceTable({ sort: externalSort, selectedRowId, selectable, + rowDragDrop, onRowClick, onRowHover, onRowContextMenu, @@ -327,6 +355,7 @@ export const ResourceTable = memo(function ResourceTable({ columns={columns} selectedRowId={selectedRowId} selectable={selectable} + rowDragDrop={rowDragDrop} onRowClick={onRowClick} onRowHover={onRowHover} onRowContextMenu={onRowContextMenu} @@ -442,6 +471,7 @@ interface DataRowProps { columns: ResourceColumn[] selectedRowId?: string | null selectable?: SelectableConfig + rowDragDrop?: RowDragDropConfig onRowClick?: (rowId: string) => void onRowHover?: (rowId: string) => void onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void @@ -453,16 +483,35 @@ const DataRow = memo(function DataRow({ columns, selectedRowId, selectable, + rowDragDrop, onRowClick, onRowHover, onRowContextMenu, hasCheckbox, }: DataRowProps) { const isSelected = selectable?.selectedIds.has(row.id) ?? false - - const handleClick = useCallback(() => { - onRowClick?.(row.id) - }, [onRowClick, row.id]) + const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false + const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false + const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id + const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false + const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false + const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0 + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if ( + selectable && + !selectable.disabled && + (e.shiftKey || e.metaKey || e.ctrlKey || !onRowClick || hasActiveSelection) + ) { + e.preventDefault() + selectable.onSelectRow(row.id, !isSelected, e.shiftKey) + return + } + onRowClick?.(row.id) + }, + [hasActiveSelection, isSelected, onRowClick, row.id, selectable] + ) const handleMouseEnter = useCallback(() => { onRowHover?.(row.id) @@ -475,25 +524,65 @@ const DataRow = memo(function DataRow({ [onRowContextMenu, row.id] ) + const shiftKeyRef = useRef(false) + + const handleSelectRowClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + shiftKeyRef.current = e.shiftKey + }, []) + const handleSelectRow = useCallback( (checked: boolean | 'indeterminate') => { - selectable?.onSelectRow(row.id, checked as boolean) + selectable?.onSelectRow(row.id, checked as boolean, shiftKeyRef.current) + shiftKeyRef.current = false }, [selectable, row.id] ) + const handleDragStart = (e: DragEvent) => { + rowDragDrop?.onDragStart?.(e, row.id) + } + + const handleDragOver = (e: DragEvent) => { + rowDragDrop?.onDragOver?.(e, row.id) + } + + const handleDragLeave = (e: DragEvent) => { + rowDragDrop?.onDragLeave?.(e, row.id) + } + + const handleDrop = (e: DragEvent) => { + rowDragDrop?.onDrop?.(e, row.id) + } + + const handleDragEnd = (e: DragEvent) => { + rowDragDrop?.onDragEnd?.(e, row.id) + } + return ( {hasCheckbox && selectable && ( @@ -503,7 +592,7 @@ const DataRow = memo(function DataRow({ onCheckedChange={handleSelectRow} disabled={selectable.disabled} aria-label='Select row' - onClick={stopPropagation} + onClick={handleSelectRowClick} /> )} @@ -553,22 +642,22 @@ interface ResourceColGroupProps { hasCheckbox?: boolean } +const CHECKBOX_WEIGHT = 0.4 + const ResourceColGroup = memo(function ResourceColGroup({ columns, hasCheckbox, }: ResourceColGroupProps) { + const weights = columns.map( + (col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) + ) + const total = (hasCheckbox ? CHECKBOX_WEIGHT : 0) + weights.reduce((s, w) => s + w, 0) + return ( - {hasCheckbox && } + {hasCheckbox && } {columns.map((col, colIdx) => ( - + ))} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..125184647b9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx @@ -0,0 +1,127 @@ +'use client' + +import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' +import { + Button, + Download, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Folder, + Tooltip, + Trash2, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' + +interface FilesActionBarProps { + selectedCount: number + onDownload?: () => void + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + onDelete?: () => void + isLoading?: boolean + className?: string +} + +export function FilesActionBar({ + selectedCount, + onDownload, + onMove, + moveOptions, + onDelete, + isLoading = false, + className, +}: FilesActionBarProps) { + return ( + + + {selectedCount > 0 && ( + +
+ + {selectedCount} selected + +
+ {onDownload && ( + + + + + Download + + )} + {onMove && moveOptions && ( + + + + + + + + Move + + + {moveOptions.length > 0 && ( + onMove(moveOptions[0].value)}> + + {moveOptions[0].label} + + )} + {moveOptions.length > 1 && } + {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} + + + )} + {onDelete && ( + + + + + Delete + + )} +
+
+
+ )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts new file mode 100644 index 00000000000..aa19162a077 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts @@ -0,0 +1 @@ +export { FilesActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx new file mode 100644 index 00000000000..045884086a0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx @@ -0,0 +1,70 @@ +'use client' + +import { memo } from 'react' +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, +} from '@/components/emcn' + +interface DeleteConfirmModalProps { + open: boolean + onOpenChange: (open: boolean) => void + fileName?: string + fileCount: number + folderCount: number + onDelete: () => void + isPending: boolean +} + +export const DeleteConfirmModal = memo(function DeleteConfirmModal({ + open, + onOpenChange, + fileName, + fileCount, + folderCount, + onDelete, + isPending, +}: DeleteConfirmModalProps) { + const totalCount = fileCount + folderCount + const hasFolders = folderCount > 0 + const title = totalCount > 1 ? 'Delete Items' : hasFolders ? 'Delete Folder' : 'Delete File' + const consequence = hasFolders + ? totalCount > 1 + ? 'This will also delete files and folders inside any selected folders.' + : 'This will also delete files and folders inside it.' + : totalCount > 1 + ? 'You can restore them from Recently Deleted in Settings.' + : 'You can restore it from Recently Deleted in Settings.' + + return ( + + + {title} + + + Are you sure you want to delete{' '} + {fileName ? ( + {fileName} + ) : ( + `${totalCount} item${totalCount === 1 ? '' : 's'}` + )} + ? {consequence} + + + + + + + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts new file mode 100644 index 00000000000..23b57d9365a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/index.ts @@ -0,0 +1 @@ +export { DeleteConfirmModal } from './delete-confirm-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx new file mode 100644 index 00000000000..1d545d0b9eb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -0,0 +1,114 @@ +'use client' + +import { memo } from 'react' +import { + Download, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Eye, + Folder, + FolderInput, + Pencil, + Trash2, +} from '@/components/emcn' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' + +interface FileRowContextMenuProps { + isOpen: boolean + position: { x: number; y: number } + onClose: () => void + onOpen: () => void + onDownload?: () => void + onRename: () => void + onDelete: () => void + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + canEdit: boolean + selectedCount: number +} + +export const FileRowContextMenu = memo(function FileRowContextMenu({ + isOpen, + position, + onClose, + onOpen, + onDownload, + onRename, + onDelete, + onMove, + moveOptions, + canEdit, + selectedCount, +}: FileRowContextMenuProps) { + const isMultiSelect = selectedCount > 1 + + return ( + !open && onClose()} modal={false}> + +
+ + e.preventDefault()} + > + {!isMultiSelect && ( + + + Open + + )} + {onDownload && ( + + + {isMultiSelect ? `Download ${selectedCount} items` : 'Download'} + + )} + {canEdit && ( + <> + + {!isMultiSelect && ( + + + Rename + + )} + {onMove && moveOptions && moveOptions.length > 0 && ( + + + + {isMultiSelect ? `Move ${selectedCount} items` : 'Move to'} + + + onMove(moveOptions[0].value)}> + + {moveOptions[0].label} + + {moveOptions.length > 1 && } + {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} + + + )} + + + {isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'} + + + )} + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts new file mode 100644 index 00000000000..d53dca37c19 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/index.ts @@ -0,0 +1 @@ +export { FileRowContextMenu } from './file-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 53072bced76..7af1ea24fdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -631,7 +631,7 @@ const STATIC_MARKDOWN_COMPONENTS = {
), thead: ({ children }: { children?: React.ReactNode }) => ( - {children} + {children} ), tbody: ({ children }: { children?: React.ReactNode }) => {children}, tr: ({ children }: { children?: React.ReactNode }) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx index 031213ead7f..1954a1fcb05 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/files-list-context-menu/files-list-context-menu.tsx @@ -7,15 +7,17 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/emcn' -import { Plus, Upload } from '@/components/emcn/icons' +import { FolderPlus, Plus, Upload } from '@/components/emcn/icons' interface FilesListContextMenuProps { isOpen: boolean position: { x: number; y: number } onClose: () => void onCreateFile?: () => void + onCreateFolder?: () => void onUploadFile?: () => void disableCreate?: boolean + disableCreateFolder?: boolean disableUpload?: boolean } @@ -24,22 +26,18 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({ position, onClose, onCreateFile, + onCreateFolder, onUploadFile, disableCreate = false, + disableCreateFolder = false, disableUpload = false, }: FilesListContextMenuProps) { return ( !open && onClose()} modal={false}>
@@ -56,6 +54,12 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({ New file )} + {onCreateFolder && ( + + + New folder + + )} {onUploadFile && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 7e304d786ba..ec1147a8fee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1,7 +1,8 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { Button, @@ -9,24 +10,22 @@ import { Combobox, type ComboboxOption, Download, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, Eye, + File as FilesIcon, + Folder, + FolderPlus, Loader, Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Pencil, - Trash, + Trash2, toast, Upload, } from '@/components/emcn' -import { File as FilesIcon } from '@/components/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { triggerFileDownload } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -47,10 +46,12 @@ import { SUPPORTED_VIDEO_EXTENSIONS, } from '@/lib/uploads/utils/validation' import type { + BreadcrumbItem, FilterTag, HeaderAction, ResourceColumn, ResourceRow, + RowDragDropConfig, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' @@ -61,6 +62,9 @@ import { ResourceHeader, timeCell, } from '@/app/workspace/[workspaceId]/components' +import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' +import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' +import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FileViewer, @@ -68,9 +72,18 @@ import { isTextEditable, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { + useBulkArchiveWorkspaceFileItems, + useCreateWorkspaceFileFolder, + useMoveWorkspaceFileItems, + useUpdateWorkspaceFileFolder, + useWorkspaceFileFolders, + type WorkspaceFileFolderApi, +} from '@/hooks/queries/workspace-file-folders' import { useDeleteWorkspaceFile, useRenameWorkspaceFile, @@ -82,6 +95,9 @@ import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' +type FileResourceItem = + | { kind: 'file'; id: string; file: WorkspaceFileRecord } + | { kind: 'folder'; id: string; folder: WorkspaceFileFolderApi } const logger = createLogger('Files') @@ -120,6 +136,20 @@ const MIME_TYPE_LABELS: Record = { 'text/markdown': 'Markdown', } +const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] +const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] + +const fileRowId = (id: string) => `file:${id}` +const folderRowId = (id: string) => `folder:${id}` +const parseRowId = (rowId: string): { kind: 'file' | 'folder'; id: string } => { + if (rowId.startsWith('folder:')) return { kind: 'folder', id: rowId.slice('folder:'.length) } + if (rowId.startsWith('file:')) return { kind: 'file', id: rowId.slice('file:'.length) } + return { kind: 'file', id: rowId } +} + +const hasExternalFiles = (dataTransfer: DataTransfer): boolean => + dataTransfer.types.includes('Files') + function formatFileType(mimeType: string | null, filename: string): string { if (mimeType && MIME_TYPE_LABELS[mimeType]) { return MIME_TYPE_LABELS[mimeType] @@ -143,11 +173,13 @@ export function Files() { const router = useRouter() const searchParams = useSearchParams() const isNewFile = searchParams.get('new') === '1' + const currentFolderId = searchParams.get('folderId') const workspaceId = params?.workspaceId as string const fileIdFromRoute = typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true const { config: permissionConfig } = usePermissionConfig() useEffect(() => { @@ -156,11 +188,17 @@ export function Files() { } }, [permissionConfig.hideFilesTab, router, workspaceId]) - const { data: files = [], isLoading, error } = useWorkspaceFiles(workspaceId) + const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) + const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = + useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const uploadFile = useUploadWorkspaceFile() const deleteFile = useDeleteWorkspaceFile() const renameFile = useRenameWorkspaceFile() + const createFolder = useCreateWorkspaceFileFolder() + const updateFolder = useUpdateWorkspaceFileFolder() + const moveItems = useMoveWorkspaceFileItems() + const bulkArchiveItems = useBulkArchiveWorkspaceFileItems() const { isOpen: isContextMenuOpen, @@ -183,6 +221,8 @@ export function Files() { const justCreatedFileIdRef = useRef(null) const filesRef = useRef(files) filesRef.current = files + const foldersRef = useRef(folders) + foldersRef.current = folders const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ @@ -205,6 +245,9 @@ export function Files() { const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') + const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) + const [activeDropTargetId, setActiveDropTargetId] = useState(null) + const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set()) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' if (fileIdFromRoute) { @@ -216,13 +259,25 @@ export function Files() { }) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const contextMenuFileRef = useRef(null) - const [deleteTargetFile, setDeleteTargetFile] = useState<{ id: string; name: string } | null>( - null - ) + const contextMenuItemRef = useRef(null) + const lastSelectedIndexRef = useRef(-1) + const draggedRowIdsRef = useRef([]) + const dragGhostRef = useRef(null) + const [deleteTarget, setDeleteTarget] = useState<{ + fileIds: string[] + folderIds: string[] + name: string + } | null>(null) const listRename = useInlineRename({ - onSave: (fileId, name) => renameFile.mutate({ workspaceId, fileId, name }), + onSave: (rowId, name) => { + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + updateFolder.mutate({ workspaceId, folderId: parsed.id, updates: { name } }) + return + } + renameFile.mutate({ workspaceId, fileId: parsed.id, name }) + }, }) const headerRename = useInlineRename({ @@ -231,6 +286,12 @@ export function Files() { }, }) + const breadcrumbRename = useInlineRename({ + onSave: (folderId, name) => { + updateFolder.mutate({ workspaceId, folderId, updates: { name } }) + }, + }) + const selectedFile = useMemo( () => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null), [fileIdFromRoute, files] @@ -238,10 +299,40 @@ export function Files() { const selectedFileRef = useRef(selectedFile) selectedFileRef.current = selectedFile + const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) + const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null + const currentFolderPath = currentFolder?.path ?? null + + const visibleFolders = useMemo(() => { + const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) + const searched = debouncedSearchTerm + ? siblings.filter((folder) => + folder.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + ) + : siblings + const col = activeSort?.column ?? 'name' + const dir = activeSort?.direction ?? 'asc' + return [...searched].sort((a, b) => { + let cmp = 0 + if (col === 'updated') { + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + } else if (col === 'created') { + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return dir === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchTerm, activeSort]) + const filteredFiles = useMemo(() => { let result = debouncedSearchTerm - ? files.filter((f) => f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - : files + ? files.filter( + (f) => + (f.folderId ?? null) === currentFolderId && + f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) + ) + : files.filter((f) => (f.folderId ?? null) === currentFolderId) if (typeFilter.length > 0) { result = result.filter((f) => { @@ -296,28 +387,48 @@ export function Files() { } return dir === 'asc' ? cmp : -cmp }) - }, [files, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter, activeSort, members]) - - const rowCacheRef = useRef( - new Map() - ) + }, [ + files, + currentFolderId, + debouncedSearchTerm, + typeFilter, + sizeFilter, + uploadedByFilter, + activeSort, + members, + ]) const baseRows: ResourceRow[] = useMemo(() => { - const prevCache = rowCacheRef.current - const nextCache = new Map< - string, - { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members } - >() - - const result = filteredFiles.map((file) => { - const cached = prevCache.get(file.id) - if (cached && cached.file === file && cached.members === members) { - nextCache.set(file.id, cached) - return cached.row - } + const folderRows = visibleFolders.map((folder) => ({ + id: folderRowId(folder.id), + cells: { + name: { + icon: , + label: folder.name, + }, + size: { label: 'Folder' }, + type: { + icon: , + label: 'Folder', + }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, members), + updated: timeCell(folder.updatedAt), + }, + sortValues: { + name: folder.name, + size: -1, + type: 'Folder', + created: new Date(folder.createdAt).getTime(), + updated: new Date(folder.updatedAt).getTime(), + owner: members?.find((m) => m.userId === folder.userId)?.name ?? '', + }, + })) + + const fileRows = filteredFiles.map((file) => { const Icon = getDocumentIcon(file.type || '', file.name) const row: ResourceRow = { - id: file.id, + id: fileRowId(file.id), cells: { name: { icon: , @@ -334,22 +445,28 @@ export function Files() { owner: ownerCell(file.uploadedBy, members), updated: timeCell(file.updatedAt), }, + sortValues: { + name: file.name, + size: file.size, + type: formatFileType(file.type, file.name), + created: new Date(file.uploadedAt).getTime(), + updated: new Date(file.updatedAt).getTime(), + owner: members?.find((m) => m.userId === file.uploadedBy)?.name ?? '', + }, } - nextCache.set(file.id, { row, file, members }) return row }) - rowCacheRef.current = nextCache - return result - }, [filteredFiles, members]) + return [...folderRows, ...fileRows] + }, [visibleFolders, filteredFiles, members]) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows return baseRows.map((row) => { if (row.id !== listRename.editingId) return row - const file = filteredFiles.find((f) => f.id === row.id) - if (!file) return row - const Icon = getDocumentIcon(file.type || '', file.name) + const parsed = parseRowId(row.id) + const file = parsed.kind === 'file' ? filteredFiles.find((f) => f.id === parsed.id) : null + const Icon = file ? getDocumentIcon(file.type || '', file.name) : Folder return { ...row, cells: { @@ -373,78 +490,359 @@ export function Files() { }, } }) - }, [ - baseRows, - listRename.editingId, - listRename.editValue, - listRename.setEditValue, - listRename.submitRename, - listRename.cancelRename, - filteredFiles, - ]) + }, [baseRows, listRename.editingId, listRename.editValue, filteredFiles]) - const uploadFiles = async (filesToUpload: File[]) => { - if (!workspaceId || filesToUpload.length === 0) return + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const oversized: string[] = [] - const sizeFiltered = filesToUpload.filter((f) => { - if (f.size > MAX_WORKSPACE_FILE_SIZE) { - oversized.push(f.name) - return false - } - return true + const prevVisibleRowIdsRef = useRef(visibleRowIds) + useEffect(() => { + if (prevVisibleRowIdsRef.current === visibleRowIds) return + prevVisibleRowIdsRef.current = visibleRowIds + lastSelectedIndexRef.current = -1 + const visible = new Set(visibleRowIds) + setSelectedRowIds((prev) => { + if (prev.size === 0) return prev + const next = new Set(Array.from(prev).filter((id) => visible.has(id))) + return next.size === prev.size ? prev : next }) - if (oversized.length > 0) { - toast.error( - oversized.length === 1 - ? `${oversized[0]} exceeds the 5 GiB upload limit` - : `${oversized.length} files exceed the 5 GiB upload limit` - ) + }, [visibleRowIds]) + + const isAllSelected = + visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) + const selectedFileIds = useMemo( + () => + Array.from(selectedRowIds) + .map(parseRowId) + .filter((item) => item.kind === 'file') + .map((item) => item.id), + [selectedRowIds] + ) + const selectedFolderIds = useMemo( + () => + Array.from(selectedRowIds) + .map(parseRowId) + .filter((item) => item.kind === 'folder') + .map((item) => item.id), + [selectedRowIds] + ) + + const selectableConfig = useMemo( + () => ({ + selectedIds: selectedRowIds, + isAllSelected, + onSelectRow: (rowId: string, checked: boolean, shiftKey?: boolean) => { + const currentIndex = visibleRowIds.indexOf(rowId) + if (shiftKey && lastSelectedIndexRef.current !== -1 && currentIndex !== -1) { + const start = Math.min(lastSelectedIndexRef.current, currentIndex) + const end = Math.max(lastSelectedIndexRef.current, currentIndex) + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) + return next + }) + lastSelectedIndexRef.current = currentIndex + } else { + setSelectedRowIds((prev) => { + const next = new Set(prev) + if (checked) next.add(rowId) + else next.delete(rowId) + return next + }) + if (checked) lastSelectedIndexRef.current = currentIndex + else lastSelectedIndexRef.current = -1 + } + }, + onSelectAll: (checked: boolean) => { + lastSelectedIndexRef.current = -1 + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (const rowId of visibleRowIds) { + if (checked) next.add(rowId) + else next.delete(rowId) + } + return next + }) + }, + disabled: false, + }), + [selectedRowIds, isAllSelected, visibleRowIds] + ) + + const descendantFolderIdsByFolderId = useMemo(() => { + const childrenByParent = new Map() + for (const folder of folders) { + if (!folder.parentId) continue + const children = childrenByParent.get(folder.parentId) ?? [] + children.push(folder.id) + childrenByParent.set(folder.parentId, children) } - const unsupported: string[] = [] - const allowedFiles = sizeFiltered.filter((f) => { - const ext = getFileExtension(f.name) - const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number]) - if (!ok) unsupported.push(f.name) - return ok - }) + const result = new Map>() + const collect = (folderId: string, seen = new Set()): Set => { + const cached = result.get(folderId) + if (cached) return cached + if (seen.has(folderId)) return new Set() + + const nextSeen = new Set(seen) + nextSeen.add(folderId) + const descendants = new Set() + for (const childId of childrenByParent.get(folderId) ?? []) { + if (nextSeen.has(childId)) continue + descendants.add(childId) + for (const nestedId of collect(childId, nextSeen)) { + descendants.add(nestedId) + } + } + result.set(folderId, descendants) + return descendants + } - if (unsupported.length > 0) { - logger.warn('Unsupported file types skipped:', unsupported) + for (const folder of folders) { + collect(folder.id) } + return result + }, [folders]) + + const isInvalidDropTarget = useCallback( + (targetRowId: string, sourceRowIds: string[]) => { + const target = parseRowId(targetRowId) + if (target.kind !== 'folder') return true + + for (const sourceRowId of sourceRowIds) { + const source = parseRowId(sourceRowId) + if (source.kind !== 'folder') continue + if (source.id === target.id) return true + if (descendantFolderIdsByFolderId.get(source.id)?.has(target.id)) return true + } + + // Reject drop if every dragged item is already a direct child of the target + const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { + const source = parseRowId(sourceRowId) + if (source.kind === 'file') { + return filesRef.current.find((f) => f.id === source.id)?.folderId === target.id + } + return (foldersRef.current.find((f) => f.id === source.id)?.parentId ?? null) === target.id + }) + if (allAlreadyInTarget) return true - if (allowedFiles.length === 0) return + return false + }, + [descendantFolderIdsByFolderId] + ) - try { - setUploading(true) - setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) + const uploadFiles = useCallback( + async (filesToUpload: File[], targetFolderId = currentFolderId) => { + if (!workspaceId || filesToUpload.length === 0 || !canEdit) return - for (let i = 0; i < allowedFiles.length; i++) { - try { - await uploadFile.mutateAsync({ + const oversized: string[] = [] + const sizeFiltered = filesToUpload.filter((f) => { + if (f.size > MAX_WORKSPACE_FILE_SIZE) { + oversized.push(f.name) + return false + } + return true + }) + if (oversized.length > 0) { + toast.error( + oversized.length === 1 + ? `${oversized[0]} exceeds the 5 GiB upload limit` + : `${oversized.length} files exceed the 5 GiB upload limit` + ) + } + + const unsupported: string[] = [] + const allowedFiles = sizeFiltered.filter((f) => { + const ext = getFileExtension(f.name) + const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number]) + if (!ok) unsupported.push(f.name) + return ok + }) + + if (unsupported.length > 0) { + logger.warn('Unsupported file types skipped:', unsupported) + } + + if (allowedFiles.length === 0) return + + try { + setUploading(true) + setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) + + for (let i = 0; i < allowedFiles.length; i++) { + try { + await uploadFile.mutateAsync({ + workspaceId, + file: allowedFiles[i], + folderId: targetFolderId, + onProgress: ({ percent }) => { + setUploadProgress((prev) => ({ ...prev, currentPercent: percent })) + }, + }) + setUploadProgress({ + completed: i + 1, + total: allowedFiles.length, + currentPercent: 0, + }) + } catch (err) { + logger.error('Error uploading file:', err) + } + } + } catch (err) { + logger.error('Error uploading file:', err) + } finally { + setUploading(false) + setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) + } + }, + [workspaceId, canEdit, currentFolderId] + ) + + const rowDragDropConfig = useMemo( + () => ({ + activeDropTargetId, + draggedRowIds, + isAnyDragActive: draggedRowIds.size > 0, + isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId, + isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder', + onDragStart: (e: DragEvent, rowId) => { + if (!canEdit || listRename.editingId === rowId) { + e.preventDefault() + return + } + + const sourceRowIds = selectedRowIds.has(rowId) + ? visibleRowIds.filter((visibleRowId) => selectedRowIds.has(visibleRowId)) + : [rowId] + + draggedRowIdsRef.current = sourceRowIds + setDraggedRowIds(new Set(sourceRowIds)) + if (!selectedRowIds.has(rowId)) { + setSelectedRowIds(new Set([rowId])) + } + + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData( + 'application/x-sim-workspace-file-rows', + JSON.stringify(sourceRowIds) + ) + e.dataTransfer.setData('text/plain', sourceRowIds.join(',')) + + const count = sourceRowIds.length + const firstParsed = parseRowId(sourceRowIds[0]) + const firstName = + firstParsed.kind === 'file' + ? filesRef.current.find((f) => f.id === firstParsed.id)?.name + : foldersRef.current.find((f) => f.id === firstParsed.id)?.name + const ghostLabel = + count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item') + const ghost = document.createElement('div') + ghost.style.cssText = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + const text = document.createElement('span') + text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + text.textContent = ghostLabel + ghost.appendChild(text) + document.body.appendChild(ghost) + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + }, + onDragOver: (e: DragEvent, rowId) => { + const sourceRowIds = draggedRowIdsRef.current + const isExternalFileDrag = hasExternalFiles(e.dataTransfer) + if (!isExternalFileDrag && isInvalidDropTarget(rowId, sourceRowIds)) return + + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' + setActiveDropTargetId(rowId) + }, + onDragLeave: (e: DragEvent, rowId) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setActiveDropTargetId((current) => (current === rowId ? null : current)) + }, + onDrop: (e: DragEvent, rowId) => { + e.preventDefault() + e.stopPropagation() + dragCounterRef.current = 0 + setIsDraggingOver(false) + setActiveDropTargetId(null) + const target = parseRowId(rowId) + if (target.kind !== 'folder') return + + const droppedFiles = Array.from(e.dataTransfer.files ?? []) + if (droppedFiles.length > 0) { + void uploadFiles(droppedFiles, target.id) + return + } + + let sourceRowIds = draggedRowIdsRef.current + const rawSource = e.dataTransfer.getData('application/x-sim-workspace-file-rows') + if (rawSource) { + try { + const parsedSource = JSON.parse(rawSource) + if (Array.isArray(parsedSource)) { + sourceRowIds = parsedSource.filter( + (source): source is string => typeof source === 'string' && source.length > 0 + ) + } + } catch { + sourceRowIds = draggedRowIdsRef.current + } + } + + if (isInvalidDropTarget(rowId, sourceRowIds)) return + + const fileIds = sourceRowIds + .map(parseRowId) + .filter((source) => source.kind === 'file') + .map((source) => source.id) + const folderIds = sourceRowIds + .map(parseRowId) + .filter((source) => source.kind === 'folder') + .map((source) => source.id) + + if (fileIds.length === 0 && folderIds.length === 0) return + + void moveItems + .mutateAsync({ workspaceId, - file: allowedFiles[i], - onProgress: ({ percent }) => { - setUploadProgress((prev) => ({ ...prev, currentPercent: percent })) - }, + fileIds, + folderIds, + targetFolderId: target.id, + }) + .then(() => { + setSelectedRowIds(new Set()) }) - setUploadProgress({ - completed: i + 1, - total: allowedFiles.length, - currentPercent: 0, + .catch((error) => { + logger.error('Failed to move items via drag and drop:', error) }) - } catch (err) { - logger.error('Error uploading file:', err) + }, + onDragEnd: () => { + if (dragGhostRef.current) { + dragGhostRef.current.remove() + dragGhostRef.current = null } - } - } catch (err) { - logger.error('Error uploading file:', err) - } finally { - setUploading(false) - setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) - } - } + dragCounterRef.current = 0 + draggedRowIdsRef.current = [] + setDraggedRowIds(new Set()) + setIsDraggingOver(false) + setActiveDropTargetId(null) + }, + }), + [ + activeDropTargetId, + draggedRowIds, + canEdit, + listRename.editingId, + selectedRowIds, + visibleRowIds, + isInvalidDropTarget, + uploadFiles, + workspaceId, + ] + ) const handleFileChange = async (e: React.ChangeEvent) => { const list = e.target.files @@ -454,22 +852,26 @@ export function Files() { } const handleDragEnter = (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() dragCounterRef.current++ - if (e.dataTransfer.types.includes('Files')) setIsDraggingOver(true) + setIsDraggingOver(true) } - const handleDragLeave = () => { + const handleDragLeave = (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return dragCounterRef.current-- if (dragCounterRef.current === 0) setIsDraggingOver(false) } const handleDragOver = (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' } const handleDrop = async (e: React.DragEvent) => { + if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() dragCounterRef.current = 0 setIsDraggingOver(false) @@ -485,50 +887,73 @@ export function Files() { } }, []) - const deleteTargetFileRef = useRef(deleteTargetFile) - deleteTargetFileRef.current = deleteTargetFile + const deleteTargetRef = useRef(deleteTarget) + deleteTargetRef.current = deleteTarget const fileIdFromRouteRef = useRef(fileIdFromRoute) fileIdFromRouteRef.current = fileIdFromRoute const handleDelete = useCallback(async () => { - const target = deleteTargetFileRef.current + const target = deleteTargetRef.current if (!target) return try { - await deleteFile.mutateAsync({ - workspaceId, - fileId: target.id, - }) + if (target.folderIds.length > 0 || target.fileIds.length > 1) { + await bulkArchiveItems.mutateAsync({ + workspaceId, + fileIds: target.fileIds, + folderIds: target.folderIds, + }) + } else if (target.fileIds.length === 1) { + await deleteFile.mutateAsync({ + workspaceId, + fileId: target.fileIds[0], + }) + } else { + setShowDeleteConfirm(false) + setDeleteTarget(null) + return + } setShowDeleteConfirm(false) - setDeleteTargetFile(null) - if (fileIdFromRouteRef.current === target.id) { + setDeleteTarget(null) + setSelectedRowIds(new Set()) + if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') - router.push(`/workspace/${workspaceId}/files`) + router.push( + currentFolderId + ? `/workspace/${workspaceId}/files?folderId=${currentFolderId}` + : `/workspace/${workspaceId}/files` + ) } } catch (err) { logger.error('Failed to delete file:', err) } - }, [workspaceId, router]) + }, [workspaceId, router, currentFolderId]) const isDirtyRef = useRef(isDirty) isDirtyRef.current = isDirty const saveStatusRef = useRef(saveStatus) saveStatusRef.current = saveStatus + const pendingFileNavigationUrlRef = useRef(null) const handleSave = useCallback(async () => { if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return await saveRef.current() }, []) - const handleBackAttempt = useCallback(() => { - if (isDirtyRef.current) { - setShowUnsavedChangesAlert(true) - } else { + const handleNavigateFromFileDetail = useCallback( + (url: string) => { + if (isDirtyRef.current) { + pendingFileNavigationUrlRef.current = url + setShowUnsavedChangesAlert(true) + return + } + setPreviewMode('editor') - router.push(`/workspace/${workspaceId}/files`) - } - }, [router, workspaceId]) + router.push(url) + }, + [router] + ) const handleStartHeaderRename = useCallback(() => { const file = selectedFileRef.current @@ -543,64 +968,115 @@ export function Files() { const handleDeleteSelected = useCallback(() => { const file = selectedFileRef.current if (file) { - setDeleteTargetFile({ id: file.id, name: file.name }) + setDeleteTarget({ fileIds: [file.id], folderIds: [], name: file.name }) setShowDeleteConfirm(true) } }, []) - const fileDetailBreadcrumbs = useMemo( - () => - selectedFile - ? [ - { label: 'Files', onClick: handleBackAttempt }, - { - label: selectedFile.name, - editing: headerRename.editingId - ? { - isEditing: true, - value: headerRename.editValue, - onChange: headerRename.setEditValue, - onSubmit: headerRename.submitRename, - onCancel: headerRename.cancelRename, - } - : undefined, - dropdownItems: [ - { - label: 'Rename', - icon: Pencil, - onClick: handleStartHeaderRename, - }, - { - label: 'Download', - icon: Download, - onClick: handleDownloadSelected, - }, - { - label: 'Delete', - icon: Trash, - onClick: handleDeleteSelected, - }, - ], - }, - ] - : [], - [ - selectedFile, - handleBackAttempt, - headerRename.editingId, - headerRename.editValue, - handleStartHeaderRename, - handleDownloadSelected, - handleDeleteSelected, + const handleBulkDelete = useCallback(() => { + if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return + setDeleteTarget({ + fileIds: selectedFileIds, + folderIds: selectedFolderIds, + name: + selectedFileIds.length + selectedFolderIds.length === 1 + ? (files.find((file) => file.id === selectedFileIds[0])?.name ?? + folders.find((folder) => folder.id === selectedFolderIds[0])?.name ?? + 'selected item') + : `${selectedFileIds.length + selectedFolderIds.length} selected items`, + }) + setShowDeleteConfirm(true) + }, [selectedFileIds, selectedFolderIds, files, folders]) + + const handleBulkDownload = useCallback(() => { + const selectedFiles = files.filter((file) => selectedFileIds.includes(file.id)) + if (selectedFiles.length === 1 && selectedFolderIds.length === 0) { + handleDownload(selectedFiles[0]) + return + } + + const query = new URLSearchParams() + for (const fileId of selectedFileIds) query.append('fileIds', fileId) + for (const folderId of selectedFolderIds) query.append('folderIds', folderId) + + if (query.size === 0) return + window.location.href = `/api/workspaces/${workspaceId}/files/download?${query.toString()}` + }, [selectedFileIds, selectedFolderIds, files, handleDownload, workspaceId]) + + const fileDetailBreadcrumbs = useMemo(() => { + if (!selectedFile) return [] + + const folderBreadcrumbs: BreadcrumbItem[] = [] + const visitedFolderIds = new Set() + let folderId = selectedFile.folderId + + while (folderId && !visitedFolderIds.has(folderId)) { + visitedFolderIds.add(folderId) + const folder = folderById.get(folderId) + if (!folder) break + + folderBreadcrumbs.unshift({ + label: folder.name, + onClick: () => + handleNavigateFromFileDetail(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + }) + folderId = folder.parentId + } + + return [ + { + label: 'Files', + onClick: () => handleNavigateFromFileDetail(`/workspace/${workspaceId}/files`), + }, + ...folderBreadcrumbs, + { + label: selectedFile.name, + editing: headerRename.editingId + ? { + isEditing: true, + value: headerRename.editValue, + onChange: headerRename.setEditValue, + onSubmit: headerRename.submitRename, + onCancel: headerRename.cancelRename, + } + : undefined, + dropdownItems: [ + { label: 'Download', icon: Download, onClick: handleDownloadSelected }, + ...(canEdit + ? [ + { label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename }, + { label: 'Delete', icon: Trash2, onClick: handleDeleteSelected }, + ] + : []), + ], + }, ] - ) + }, [ + selectedFile, + folderById, + handleNavigateFromFileDetail, + workspaceId, + canEdit, + headerRename.editingId, + headerRename.editValue, + handleStartHeaderRename, + handleDownloadSelected, + handleDeleteSelected, + ]) const handleDiscardChanges = () => { setShowUnsavedChangesAlert(false) setIsDirty(false) setSaveStatus('idle') setPreviewMode('editor') - router.push(`/workspace/${workspaceId}/files`) + const folderId = selectedFileRef.current?.folderId + const targetUrl = + pendingFileNavigationUrlRef.current ?? + (folderId + ? `/workspace/${workspaceId}/files?folderId=${folderId}` + : `/workspace/${workspaceId}/files`) + pendingFileNavigationUrlRef.current = null + router.push(targetUrl) } const creatingFileRef = useRef(creatingFile) @@ -611,7 +1087,9 @@ export function Files() { setCreatingFile(true) try { - const existingNames = new Set(filesRef.current.map((f) => f.name)) + const existingNames = new Set( + filesRef.current.filter((f) => (f.folderId ?? null) === currentFolderId).map((f) => f.name) + ) let name = 'untitled.md' let counter = 1 while (existingNames.has(name)) { @@ -622,57 +1100,152 @@ export function Files() { const mimeType = getMimeTypeFromExtension('md') const blob = new Blob([''], { type: mimeType }) const file = new File([blob], name, { type: mimeType }) - const result = await uploadFile.mutateAsync({ workspaceId, file, skipToast: true }) + const result = await uploadFile.mutateAsync({ + workspaceId, + file, + folderId: currentFolderId, + skipToast: true, + }) const fileId = result.file?.id if (fileId) { justCreatedFileIdRef.current = fileId - router.push(`/workspace/${workspaceId}/files/${fileId}?new=1`) + const params = new URLSearchParams({ new: '1' }) + if (currentFolderId) params.set('folderId', currentFolderId) + router.push(`/workspace/${workspaceId}/files/${fileId}?${params.toString()}`) } } catch (err) { logger.error('Failed to create file:', err) } finally { setCreatingFile(false) } - }, [workspaceId, router]) + }, [workspaceId, router, currentFolderId]) + + const handleCreateFolder = useCallback(async () => { + if (!workspaceId) return + const existingNames = new Set( + folders + .filter((folder) => (folder.parentId ?? null) === currentFolderId) + .map((folder) => folder.name) + ) + let name = 'New folder' + let counter = 1 + while (existingNames.has(name)) { + name = `New folder (${counter})` + counter++ + } + + try { + const folder = await createFolder.mutateAsync({ + workspaceId, + name, + parentId: currentFolderId, + }) + listRename.startRename(folderRowId(folder.id), folder.name) + } catch (error) { + logger.error('Failed to create folder:', error) + toast.error(toError(error).message) + } + }, [workspaceId, folders, currentFolderId, listRename.startRename]) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { - const file = filesRef.current.find((f) => f.id === rowId) - if (file) { - contextMenuFileRef.current = file - openContextMenu(e) + const parsed = parseRowId(rowId) + const item = + parsed.kind === 'folder' + ? folders.find((folder) => folder.id === parsed.id) + : filesRef.current.find((file) => file.id === parsed.id) + if (!item) return + contextMenuItemRef.current = + parsed.kind === 'folder' + ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } + : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } + if (!selectedRowIds.has(rowId)) { + lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) + setSelectedRowIds(new Set([rowId])) } + openContextMenu(e) }, - [openContextMenu] + [folders, openContextMenu, selectedRowIds, visibleRowIds] ) const handleContextMenuOpen = useCallback(() => { - const file = contextMenuFileRef.current - if (!file) return - router.push(`/workspace/${workspaceId}/files/${file.id}`) + const item = contextMenuItemRef.current + if (!item) return + if (item.kind === 'folder') { + router.push(`/workspace/${workspaceId}/files?folderId=${item.folder.id}`) + closeContextMenu() + return + } + router.push( + item.file.folderId + ? `/workspace/${workspaceId}/files/${item.file.id}?folderId=${item.file.folderId}` + : `/workspace/${workspaceId}/files/${item.file.id}` + ) closeContextMenu() }, [closeContextMenu, router, workspaceId]) const handleContextMenuDownload = useCallback(() => { - const file = contextMenuFileRef.current - if (!file) return - handleDownload(file) + const item = contextMenuItemRef.current + if (!item) return + const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { + handleBulkDownload() + closeContextMenu() + return + } + if (item.kind === 'folder') { + window.location.href = `/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(item.folder.id)}` + closeContextMenu() + return + } + handleDownload(item.file) closeContextMenu() - }, [handleDownload, closeContextMenu]) + }, [selectedRowIds, handleBulkDownload, closeContextMenu, workspaceId, handleDownload]) const handleContextMenuRename = useCallback(() => { - const file = contextMenuFileRef.current - if (file) listRename.startRename(file.id, file.name) + const item = contextMenuItemRef.current + if (item?.kind === 'file') listRename.startRename(fileRowId(item.file.id), item.file.name) + if (item?.kind === 'folder') + listRename.startRename(folderRowId(item.folder.id), item.folder.name) closeContextMenu() }, [listRename.startRename, closeContextMenu]) const handleContextMenuDelete = useCallback(() => { - const file = contextMenuFileRef.current - if (!file) return - setDeleteTargetFile({ id: file.id, name: file.name }) + const item = contextMenuItemRef.current + if (!item) return + const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { + handleBulkDelete() + closeContextMenu() + return + } + setDeleteTarget( + item.kind === 'file' + ? { fileIds: [item.file.id], folderIds: [], name: item.file.name } + : { fileIds: [], folderIds: [item.folder.id], name: item.folder.name } + ) setShowDeleteConfirm(true) closeContextMenu() - }, [closeContextMenu]) + }, [selectedRowIds, handleBulkDelete, closeContextMenu]) + + const handleContextMenuMove = useCallback( + async (optionValue: string) => { + const targetFolderId = optionValue === '__root__' ? null : optionValue + try { + await moveItems.mutateAsync({ + workspaceId, + fileIds: selectedFileIds, + folderIds: selectedFolderIds, + targetFolderId, + }) + setSelectedRowIds(new Set()) + closeContextMenu() + } catch (error) { + logger.error('Failed to move items:', error) + } + }, + [workspaceId, selectedFileIds, selectedFolderIds, closeContextMenu] + ) const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { @@ -689,12 +1262,14 @@ export function Files() { ) const handleListUploadFile = useCallback(() => { + if (!canEdit || uploading) return fileInputRef.current?.click() closeListContextMenu() - }, [closeListContextMenu]) + }, [canEdit, uploading, closeListContextMenu]) const prevFileIdRef = useRef(fileIdFromRoute) - if (fileIdFromRoute !== prevFileIdRef.current) { + useEffect(() => { + if (fileIdFromRoute === prevFileIdRef.current) return prevFileIdRef.current = fileIdFromRoute const isJustCreated = isNewFile || (fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute) @@ -709,16 +1284,18 @@ export function Files() { : null return file && isPreviewable(file) ? 'preview' : 'editor' })() - if (nextMode !== previewMode) { - setPreviewMode(nextMode) - } - } + setPreviewMode((current) => (nextMode === current ? current : nextMode)) + }, [fileIdFromRoute, isNewFile]) useEffect(() => { if (isNewFile && fileIdFromRoute) { - router.replace(`/workspace/${workspaceId}/files/${fileIdFromRoute}`) + router.replace( + currentFolderId + ? `/workspace/${workspaceId}/files/${fileIdFromRoute}?folderId=${currentFolderId}` + : `/workspace/${workspaceId}/files/${fileIdFromRoute}` + ) } - }, [isNewFile, fileIdFromRoute, router, workspaceId]) + }, [isNewFile, fileIdFromRoute, router, workspaceId, currentFolderId]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -740,6 +1317,49 @@ export function Files() { } }, [handleSave]) + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + const visibleRowIdsRef = useRef(visibleRowIds) + visibleRowIdsRef.current = visibleRowIds + const listRenameActiveRef = useRef(listRename.editingId) + listRenameActiveRef.current = listRename.editingId + const handleBulkDeleteRef = useRef(handleBulkDelete) + handleBulkDeleteRef.current = handleBulkDelete + + useEffect(() => { + const handleListKeyDown = (e: KeyboardEvent) => { + if (fileIdFromRouteRef.current) return + const active = document.activeElement + if ( + active && + (active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + (active as HTMLElement).isContentEditable) + ) + return + if (listRenameActiveRef.current) return + + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedRowIdsRef.current.size > 0) { + e.preventDefault() + handleBulkDeleteRef.current() + return + } + + if (e.key === 'Escape' && selectedRowIdsRef.current.size > 0) { + e.preventDefault() + setSelectedRowIds(new Set()) + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { + e.preventDefault() + setSelectedRowIds(new Set(visibleRowIdsRef.current)) + } + } + window.addEventListener('keydown', handleListKeyDown) + return () => window.removeEventListener('keydown', handleListKeyDown) + }, []) + const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -807,14 +1427,19 @@ export function Files() { icon: Download, onClick: handleDownloadSelected, }, - { - label: 'Delete', - icon: Trash, - onClick: handleDeleteSelected, - }, + ...(canEdit + ? [ + { + label: 'Delete', + icon: Trash2, + onClick: handleDeleteSelected, + }, + ] + : []), ] }, [ selectedFile, + canEdit, saveStatus, previewMode, isDirty, @@ -831,19 +1456,27 @@ export function Files() { headerRenameRef.current = headerRename const handleRowClick = useCallback( - (id: string) => { - if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) { - router.push(`/workspace/${workspaceId}/files/${id}`) + (rowId: string) => { + if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { + const parsed = parseRowId(rowId) + if (parsed.kind === 'folder') { + router.push(`/workspace/${workspaceId}/files?folderId=${parsed.id}`) + return + } + router.push( + currentFolderId + ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}` + : `/workspace/${workspaceId}/files/${parsed.id}` + ) } }, - [router, workspaceId] + [router, workspaceId, currentFolderId] ) const handleUploadClick = useCallback(() => { + if (!canEdit || uploading) return fileInputRef.current?.click() - }, []) - - const canEdit = userPermissions.canEdit === true + }, [canEdit, uploading]) const searchConfig: SearchConfig = { value: inputValue, @@ -873,16 +1506,82 @@ export function Files() { label: uploadButtonLabel, icon: Upload, onClick: handleUploadClick, + disabled: uploading || !canEdit, + }, + { + label: 'New folder', + icon: FolderPlus, + onClick: handleCreateFolder, + disabled: createFolder.isPending || !canEdit, }, ], - [uploadButtonLabel, handleUploadClick] + [uploadButtonLabel, handleUploadClick, handleCreateFolder, createFolder.isPending, canEdit] ) - const handleNavigateToFiles = () => { + const handleNavigateToFiles = useCallback(() => { router.push(`/workspace/${workspaceId}/files`) - } + }, [router, workspaceId]) + + const loadingBreadcrumbs = useMemo( + () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }], + [handleNavigateToFiles] + ) + + const breadcrumbRenameRef = useRef(breadcrumbRename) + breadcrumbRenameRef.current = breadcrumbRename + + const listBreadcrumbs = useMemo(() => { + const breadcrumbs: BreadcrumbItem[] = [{ label: 'Files', onClick: handleNavigateToFiles }] + if (!currentFolderPath) return breadcrumbs - const loadingBreadcrumbs = [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }] + const segments = currentFolderPath.split('/') + let parentId: string | null = null + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const folder = folders.find( + (item) => item.name === segment && (item.parentId ?? null) === parentId + ) + if (!folder) continue + const isCurrentFolder = folder.id === currentFolderId + breadcrumbs.push({ + label: folder.name, + onClick: isCurrentFolder + ? undefined + : () => router.push(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + editing: + isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id + ? { + isEditing: true, + value: breadcrumbRenameRef.current.editValue, + onChange: breadcrumbRenameRef.current.setEditValue, + onSubmit: breadcrumbRenameRef.current.submitRename, + onCancel: breadcrumbRenameRef.current.cancelRename, + } + : undefined, + dropdownItems: + isCurrentFolder && canEdit + ? [ + { + label: 'Rename', + onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name), + }, + ] + : undefined, + }) + parentId = folder.id + } + return breadcrumbs + }, [ + currentFolderPath, + currentFolderId, + folders, + handleNavigateToFiles, + router, + workspaceId, + canEdit, + breadcrumbRename.editingId, + breadcrumbRename.editValue, + ]) const memberOptions: ComboboxOption[] = useMemo( () => @@ -905,6 +1604,22 @@ export function Files() { [members] ) + const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { + const buildSubtree = (parentId: string | null): MoveOptionNode[] => + folders + .filter((f) => { + if ((f.parentId ?? null) !== parentId) return false + if (selectedFolderIds.includes(f.id)) return false + return selectedFolderIds.every( + (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) + ) + }) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) + + return [{ value: '__root__', label: 'Files', children: [] }, ...buildSubtree(null)] + }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) + const sortConfig: SortConfig = useMemo( () => ({ options: [ @@ -925,6 +1640,14 @@ export function Files() { const hasActiveFilters = typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0 + const emptyMessage = debouncedSearchTerm + ? `No files match "${debouncedSearchTerm}"` + : hasActiveFilters + ? 'No files match the active filters' + : currentFolderId + ? 'This folder is empty' + : 'No files yet' + const filterContent = useMemo(() => { const typeDisplayLabel = typeFilter.length === 0 @@ -1026,13 +1749,12 @@ export function Files() { {hasActiveFilters && ( @@ -1113,9 +1835,9 @@ export function Files() { Unsaved Changes -

+ You have unsaved changes. Are you sure you want to discard them? -

+
) } - -interface FileRowContextMenuProps { - isOpen: boolean - position: { x: number; y: number } - onClose: () => void - onOpen: () => void - onDownload: () => void - onRename: () => void - onDelete: () => void - canEdit: boolean -} - -const FileRowContextMenu = memo(function FileRowContextMenu({ - isOpen, - position, - onClose, - onOpen, - onDownload, - onRename, - onDelete, - canEdit, -}: FileRowContextMenuProps) { - return ( - !open && onClose()} modal={false}> - -
- - e.preventDefault()} - > - - - Open - - - - Download - - {canEdit && ( - <> - - - - Rename - - - - Delete - - - )} - - - ) -}) - -interface DeleteConfirmModalProps { - open: boolean - onOpenChange: (open: boolean) => void - fileName?: string - onDelete: () => void - isPending: boolean -} - -const DeleteConfirmModal = memo(function DeleteConfirmModal({ - open, - onOpenChange, - fileName, - onDelete, - isPending, -}: DeleteConfirmModalProps) { - return ( - - - Delete File - -

- Are you sure you want to delete{' '} - {fileName}? You can - restore it from Recently Deleted in Settings. -

-
- - - - -
-
- ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx new file mode 100644 index 00000000000..3909673616e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/move-options.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@/components/emcn' +import { Folder } from '@/components/emcn/icons' + +export interface MoveOptionNode { + value: string + label: string + children: MoveOptionNode[] +} + +export function renderMoveOption( + option: MoveOptionNode, + onMove: (value: string) => void +): ReactNode { + if (option.children.length === 0) { + return ( + onMove(option.value)}> + + {option.label} + + ) + } + return ( + + + + {option.label} + + + onMove(option.value)}>Move here + + {option.children.map((child) => renderMoveOption(child, onMove))} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx index efcae7e3394..0ba36377e7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx @@ -69,6 +69,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + filefolder: { + label: 'File folder', + renderIcon: ({ className }) => , + }, past_chat: { label: 'Past chat', renderIcon: ({ className }) => , diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index f4ccdd57854..f5ebb862fe6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -31,6 +31,7 @@ import { useLogsList } from '@/hooks/queries/logs' import { useTablesList } from '@/hooks/queries/tables' import { useTasks } from '@/hooks/queries/tasks' import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' export interface AddResourceDropdownProps { @@ -73,6 +74,7 @@ export function useAvailableResources( const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) const { data: folders = [] } = useFolders(workspaceId) + const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId) const { data: tasks = [] } = useTasks(workspaceId) const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS) const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData]) @@ -119,9 +121,19 @@ export function useAvailableResources( items: files.map((f) => ({ id: f.id, name: f.name, + folderId: f.folderId ?? null, isOpen: existingKeys.has(`file:${f.id}`), })), }, + { + type: 'filefolder' as const, + items: fileFolders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId ?? null, + isOpen: existingKeys.has(`filefolder:${f.id}`), + })), + }, { type: 'knowledgebase' as const, items: (knowledgeBases ?? []).map((kb) => ({ @@ -162,6 +174,7 @@ export function useAvailableResources( }, [ workflows, folders, + fileFolders, tables, files, knowledgeBases, @@ -271,6 +284,91 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI ) } +export type FileFolderTreeNode = + | { kind: 'file'; id: string; name: string; isOpen?: boolean } + | { kind: 'folder'; id: string; name: string; isOpen?: boolean; children: FileFolderTreeNode[] } + +export function buildFileFolderTree( + fileItems: AvailableItem[], + folderItems: AvailableItem[] +): FileFolderTreeNode[] { + const byFolder = new Map() + for (const f of fileItems) { + const key = (f.folderId as string | null | undefined) ?? null + const bucket = byFolder.get(key) ?? [] + bucket.push(f) + byFolder.set(key, bucket) + } + + const buildLevel = (parentId: string | null): FileFolderTreeNode[] => { + const childFolders = folderItems.filter( + (f) => ((f.parentId as string | null | undefined) ?? null) === parentId + ) + const childFiles = byFolder.get(parentId) ?? [] + const nodes: FileFolderTreeNode[] = [] + for (const folder of childFolders) { + const children = buildLevel(folder.id) + nodes.push({ + kind: 'folder', + id: folder.id, + name: folder.name, + isOpen: folder.isOpen, + children, + }) + } + for (const file of childFiles) { + nodes.push({ kind: 'file', id: file.id, name: file.name, isOpen: file.isOpen }) + } + return nodes + } + + return buildLevel(null) +} + +interface FileFolderTreeItemsProps { + nodes: FileFolderTreeNode[] + onSelect: (resource: MothershipResource, isOpen?: boolean) => void +} + +export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) { + return ( + <> + {nodes.map((node) => + node.kind === 'file' ? ( + onSelect({ type: 'file', id: node.id, title: node.name }, node.isOpen)} + > + {getResourceConfig('file').renderDropdownItem({ + item: { id: node.id, name: node.name }, + })} + + ) : ( + + + + {node.name} + + + + onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen) + } + > + + {node.name} + + {node.children.length > 0 && ( + + )} + + + ) + )} + + ) +} + export function AddResourceDropdown({ workspaceId, existingKeys, @@ -282,7 +380,6 @@ export function AddResourceDropdown({ const [search, setSearch] = useState('') const [activeIndex, setActiveIndex] = useState(0) const available = useAvailableResources(workspaceId, existingKeys, excludeTypes) - const handleOpenChange = (next: boolean) => { setOpen(next) if (!next) { @@ -308,6 +405,12 @@ export function AddResourceDropdown({ return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) }, [available]) + const fileFolderTree = useMemo(() => { + const fileGroup = available.find((g) => g.type === 'file') + const fileFolderGroup = available.find((g) => g.type === 'filefolder') + return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) + }, [available]) + const filtered = useMemo(() => { const q = search.toLowerCase().trim() if (!q) return null @@ -407,8 +510,28 @@ export function AddResourceDropdown({ )} + {fileFolderTree.length > 0 && ( + + + {(() => { + const Icon = getResourceConfig('file').icon + return + })()} + Files + + + + + + )} {available.map(({ type, items }) => { - if (type === 'workflow' || type === 'folder') return null + if ( + type === 'workflow' || + type === 'folder' || + type === 'file' || + type === 'filefolder' + ) + return null if (items.length === 0) return null const config = getResourceConfig(type) const Icon = config.icon diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts index a89dbf0db7e..8a266363937 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/index.ts @@ -1,11 +1,14 @@ export type { AddResourceDropdownProps, AvailableItem, + FileFolderTreeNode, WorkflowTreeNode, } from './add-resource-dropdown' export { AddResourceDropdown, + buildFileFolderTree, buildWorkflowFolderTree, + FileFolderTreeItems, useAvailableResources, WorkflowFolderTreeItems, } from './add-resource-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index c80ca1c7b08..73d312c42ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -27,6 +27,7 @@ import { taskKeys } from '@/hooks/queries/tasks' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { useWorkflows } from '@/hooks/queries/workflows' +import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' interface DropdownItemRenderProps { @@ -180,6 +181,15 @@ export const RESOURCE_REGISTRY: Record , }, + filefolder: { + type: 'filefolder', + label: 'File Folders', + icon: FolderIcon, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, task: { type: 'task', label: 'Tasks', @@ -232,6 +242,9 @@ const RESOURCE_INVALIDATORS: Record< folder: (qc) => { qc.invalidateQueries({ queryKey: folderKeys.lists() }) }, + filefolder: (qc, wId) => { + qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) }) + }, task: (qc, wId) => { qc.invalidateQueries({ queryKey: taskKeys.list(wId) }) }, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 4507b0f3e41..7f6a6c76544 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -90,6 +90,7 @@ const RESOURCE_TO_CONTEXT: Record< table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }), file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }), folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }), + filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }), task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }), log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }), generic: (r) => ({ kind: 'docs', label: r.title }), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 45875b9b05c..dcdaf517ebe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -14,7 +14,9 @@ import { import { Plus } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { + buildFileFolderTree, buildWorkflowFolderTree, + FileFolderTreeItems, type useAvailableResources, WorkflowFolderTreeItems, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' @@ -75,6 +77,12 @@ export const PlusMenuDropdown = React.memo( return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? []) }, [availableResources]) + const fileFolderTree = useMemo(() => { + const fileGroup = availableResources.find((g) => g.type === 'file') + const fileFolderGroup = availableResources.find((g) => g.type === 'filefolder') + return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? []) + }, [availableResources]) + const filteredItems = useMemo(() => { const rawQuery = isMention ? (mentionQuery ?? '') : search const q = rawQuery.toLowerCase().trim() @@ -293,8 +301,28 @@ export const PlusMenuDropdown = React.memo( )} + {fileFolderTree.length > 0 && ( + + + {(() => { + const Icon = getResourceConfig('file').icon + return + })()} + Files + + + + + + )} {availableResources - .filter(({ type }) => type !== 'workflow' && type !== 'folder') + .filter( + ({ type }) => + type !== 'workflow' && + type !== 'folder' && + type !== 'file' && + type !== 'filefolder' + ) .map(({ type, items }) => { if (items.length === 0) return null const config = getResourceConfig(type) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx index aa30ce5761f..bd10d154ee5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/delete-chunk-modal/delete-chunk-modal.tsx @@ -1,6 +1,14 @@ 'use client' -import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn' +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, +} from '@/components/emcn' import type { ChunkData } from '@/lib/knowledge/types' import { useDeleteChunk } from '@/hooks/queries/kb/knowledge' @@ -34,9 +42,9 @@ export function DeleteChunkModal({ Delete Chunk -

+ Are you sure you want to delete this chunk? This action cannot be undone. -

+
+ + View and edit tags assigned to this document +
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 22b22551a29..e7e38119d63 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -11,6 +11,7 @@ import { Modal, ModalBody, ModalContent, + ModalDescription, ModalFooter, ModalHeader, Trash, @@ -72,9 +73,9 @@ function UnsavedChangesModal({ Unsaved Changes -

+ You have unsaved changes. Are you sure you want to discard them? -

+