diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 54eafdf36ff..e59cc943d15 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -17,6 +17,7 @@ const DuplicateRequestSchema = z.object({ workspaceId: z.string().optional(), parentId: z.string().nullable().optional(), color: z.string().optional(), + newId: z.string().uuid().optional(), }) // POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows @@ -33,7 +34,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const body = await req.json() - const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body) + const { + name, + workspaceId, + parentId, + color, + newId: clientNewId, + } = DuplicateRequestSchema.parse(body) logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) @@ -60,7 +67,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const targetWorkspaceId = workspaceId || sourceFolder.workspaceId const { newFolderId, folderMapping } = await db.transaction(async (tx) => { - const newFolderId = crypto.randomUUID() + const newFolderId = clientNewId || crypto.randomUUID() const now = new Date() const targetParentId = parentId ?? sourceFolder.parentId diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 5fa3a709018..b31b527a745 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -455,7 +455,7 @@ describe('Folders API Route', () => { expect(response.status).toBe(400) const data = await response.json() - expect(data).toHaveProperty('error', 'Name and workspace ID are required') + expect(data).toHaveProperty('error', 'Invalid request data') } }) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 835231d31f2..2ae6d1673ab 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -3,12 +3,22 @@ import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, asc, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersAPI') +const CreateFolderSchema = z.object({ + id: z.string().uuid().optional(), + name: z.string().min(1, 'Name is required'), + workspaceId: z.string().min(1, 'Workspace ID is required'), + parentId: z.string().optional(), + color: z.string().optional(), + sortOrder: z.number().int().optional(), +}) + // GET - Fetch folders for a workspace export async function GET(request: NextRequest) { try { @@ -59,13 +69,15 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body - - if (!name || !workspaceId) { - return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 }) - } + const { + id: clientId, + name, + workspaceId, + parentId, + color, + sortOrder: providedSortOrder, + } = CreateFolderSchema.parse(body) - // Check if user has workspace permissions (at least 'write' access to create folders) const workspacePermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -79,8 +91,7 @@ export async function POST(request: NextRequest) { ) } - // Generate a new ID - const id = crypto.randomUUID() + const id = clientId || crypto.randomUUID() const newFolder = await db.transaction(async (tx) => { let sortOrder: number @@ -150,6 +161,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ folder: newFolder }) } catch (error) { + if (error instanceof z.ZodError) { + logger.warn('Invalid folder creation data', { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + logger.error('Error creating folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index ad37410c9d5..cc00c0c0b7c 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -15,6 +15,7 @@ const DuplicateRequestSchema = z.object({ color: z.string().optional(), workspaceId: z.string().optional(), folderId: z.string().nullable().optional(), + newId: z.string().uuid().optional(), }) // POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows @@ -32,7 +33,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const body = await req.json() - const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body) + const { name, description, color, workspaceId, folderId, newId } = + DuplicateRequestSchema.parse(body) logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) @@ -45,6 +47,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: workspaceId, folderId, requestId, + newWorkflowId: newId, }) try { diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 611d808cf61..fd18939b75f 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -13,6 +13,7 @@ import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowAPI') const CreateWorkflowSchema = z.object({ + id: z.string().uuid().optional(), name: z.string().min(1, 'Name is required'), description: z.string().optional().default(''), color: z.string().optional().default('#3972F6'), @@ -109,6 +110,7 @@ export async function POST(req: NextRequest) { try { const body = await req.json() const { + id: clientId, name, description, color, @@ -140,7 +142,7 @@ export async function POST(req: NextRequest) { ) } - const workflowId = crypto.randomUUID() + const workflowId = clientId || crypto.randomUUID() const now = new Date() logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 1c170ce296f..0d8b856db73 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -144,6 +144,7 @@ export function FolderItem({ folderId: folder.id, name, color, + id: crypto.randomUUID(), }) if (result.id) { @@ -164,6 +165,7 @@ export function FolderItem({ workspaceId, name: 'New Folder', parentId: folder.id, + id: crypto.randomUUID(), }) if (result.id) { expandFolder() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts index 1de81cf6913..7efbe64decc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts @@ -27,7 +27,11 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { try { const folderName = await generateFolderName(workspaceId) - const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId }) + const folder = await createFolderMutation.mutateAsync({ + name: folderName, + workspaceId, + id: crypto.randomUUID(), + }) logger.info(`Created folder: ${folderName}`) return folder.id } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts index 3402ff96bfe..e304f7d5975 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts @@ -42,6 +42,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp workspaceId, name, color, + id: crypto.randomUUID(), }) if (result.id) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts index a7d93045b83..43b7399e4db 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts @@ -77,6 +77,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup name: duplicateName, parentId: folder.parentId, color: folder.color, + newId: crypto.randomUUID(), }) const newFolderId = result?.id if (newFolderId) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts index 7bdf915e983..48a48146e07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts @@ -88,6 +88,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe name: duplicateName, parentId: folder.parentId, color: folder.color, + newId: crypto.randomUUID(), }) if (result?.id) { @@ -109,6 +110,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe description: workflow.description, color: getNextWorkflowColor(), folderId: workflow.folderId, + newId: crypto.randomUUID(), }) duplicatedWorkflowIds.push(result.id) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts index eefb28ab00a..e1a14b49bfc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts @@ -77,6 +77,7 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor description: sourceWorkflow.description, color: getNextWorkflowColor(), folderId: sourceWorkflow.folderId, + newId: crypto.randomUUID(), }) duplicatedIds.push(result.id) diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index b369795ac60..303e41babcf 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -71,6 +71,7 @@ interface CreateFolderVariables { parentId?: string color?: string sortOrder?: number + id?: string } interface UpdateFolderVariables { @@ -90,6 +91,7 @@ interface DuplicateFolderVariables { name: string parentId?: string | null color?: string + newId?: string } /** @@ -102,13 +104,14 @@ function createFolderMutationHandlers - ) => WorkflowFolder + ) => WorkflowFolder, + customGenerateTempId?: (variables: TVariables) => string ) { return createOptimisticMutationHandlers(queryClient, { name, getQueryKey: (variables) => folderKeys.list(variables.workspaceId), getSnapshot: () => ({ ...useFolderStore.getState().folders }), - generateTempId: () => generateTempId('temp-folder'), + generateTempId: customGenerateTempId ?? (() => generateTempId('temp-folder')), createOptimisticItem: (variables, tempId) => { const previousFolders = useFolderStore.getState().folders return createOptimisticFolder(variables, tempId, previousFolders) @@ -121,12 +124,36 @@ function createFolderMutationHandlers { useFolderStore.setState((state) => { const { [tempId]: _, ...remainingFolders } = state.folders - return { + + const update: Record = { folders: { ...remainingFolders, [data.id]: data, }, } + + if (tempId !== data.id) { + const expandedFolders = new Set(state.expandedFolders) + const selectedFolders = new Set(state.selectedFolders) + + if (expandedFolders.has(tempId)) { + expandedFolders.delete(tempId) + expandedFolders.add(data.id) + } + if (selectedFolders.has(tempId)) { + selectedFolders.delete(tempId) + selectedFolders.add(data.id) + } + + update.expandedFolders = expandedFolders + update.selectedFolders = selectedFolders + + if (state.lastSelectedFolderId === tempId) { + update.lastSelectedFolderId = data.id + } + } + + return update }) }, rollback: (snapshot) => { @@ -163,7 +190,8 @@ export function useCreateFolder() { createdAt: new Date(), updatedAt: new Date(), } - } + }, + (variables) => variables.id ?? crypto.randomUUID() ) return useMutation({ @@ -241,7 +269,6 @@ export function useDuplicateFolderMutation() { (variables, tempId, previousFolders) => { const currentWorkflows = useWorkflowRegistry.getState().workflows - // Get source folder info if available const sourceFolder = previousFolders[variables.id] const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null return { @@ -261,7 +288,8 @@ export function useDuplicateFolderMutation() { createdAt: new Date(), updatedAt: new Date(), } - } + }, + (variables) => variables.newId ?? crypto.randomUUID() ) return useMutation({ @@ -271,6 +299,7 @@ export function useDuplicateFolderMutation() { name, parentId, color, + newId, }: DuplicateFolderVariables): Promise => { const response = await fetch(`/api/folders/${id}/duplicate`, { method: 'POST', @@ -280,6 +309,7 @@ export function useDuplicateFolderMutation() { name, parentId: parentId ?? null, color, + newId, }), }) diff --git a/apps/sim/hooks/queries/utils/optimistic-mutation.ts b/apps/sim/hooks/queries/utils/optimistic-mutation.ts index 8ca7ff829b1..47482e7ffa9 100644 --- a/apps/sim/hooks/queries/utils/optimistic-mutation.ts +++ b/apps/sim/hooks/queries/utils/optimistic-mutation.ts @@ -7,7 +7,7 @@ export interface OptimisticMutationConfig { name: string getQueryKey: (variables: TVariables) => readonly unknown[] getSnapshot: () => Record - generateTempId: () => string + generateTempId: (variables: TVariables) => string createOptimisticItem: (variables: TVariables, tempId: string) => TItem applyOptimisticUpdate: (tempId: string, item: TItem) => void replaceOptimisticEntry: (tempId: string, data: TData) => void @@ -41,7 +41,7 @@ export function createOptimisticMutationHandlers( const queryKey = getQueryKey(variables) await queryClient.cancelQueries({ queryKey }) const previousState = getSnapshot() - const tempId = generateTempId() + const tempId = generateTempId(variables) const optimisticItem = createOptimisticItem(variables, tempId) applyOptimisticUpdate(tempId, optimisticItem) logger.info(`[${name}] Added optimistic entry: ${tempId}`) diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 9664cf86423..0141af8994d 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -128,6 +128,7 @@ interface CreateWorkflowVariables { color?: string folderId?: string | null sortOrder?: number + id?: string } interface CreateWorkflowResult { @@ -147,6 +148,7 @@ interface DuplicateWorkflowVariables { description?: string color: string folderId?: string | null + newId?: string } interface DuplicateWorkflowResult { @@ -168,7 +170,8 @@ interface DuplicateWorkflowResult { function createWorkflowMutationHandlers( queryClient: ReturnType, name: string, - createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata + createOptimisticWorkflow: (variables: TVariables, tempId: string) => WorkflowMetadata, + customGenerateTempId?: (variables: TVariables) => string ) { return createOptimisticMutationHandlers< CreateWorkflowResult | DuplicateWorkflowResult, @@ -178,7 +181,7 @@ function createWorkflowMutationHandlers workflowKeys.list(variables.workspaceId), getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }), - generateTempId: () => generateTempId('temp-workflow'), + generateTempId: customGenerateTempId ?? (() => generateTempId('temp-workflow')), createOptimisticItem: createOptimisticWorkflow, applyOptimisticUpdate: (tempId, item) => { useWorkflowRegistry.setState((state) => ({ @@ -206,6 +209,17 @@ function createWorkflowMutationHandlers { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } }, rollback: (snapshot) => { useWorkflowRegistry.setState({ workflows: snapshot }) @@ -245,12 +259,13 @@ export function useCreateWorkflow() { folderId: variables.folderId || null, sortOrder, } - } + }, + (variables) => variables.id ?? crypto.randomUUID() ) return useMutation({ mutationFn: async (variables: CreateWorkflowVariables): Promise => { - const { workspaceId, name, description, color, folderId, sortOrder } = variables + const { workspaceId, name, description, color, folderId, sortOrder, id } = variables logger.info(`Creating new workflow in workspace: ${workspaceId}`) @@ -258,6 +273,7 @@ export function useCreateWorkflow() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + id, name: name || generateCreativeWorkflowName(), description: description || 'New workflow', color: color || getNextWorkflowColor(), @@ -346,12 +362,13 @@ export function useDuplicateWorkflowMutation() { targetFolderId ), } - } + }, + (variables) => variables.newId ?? crypto.randomUUID() ) return useMutation({ mutationFn: async (variables: DuplicateWorkflowVariables): Promise => { - const { workspaceId, sourceId, name, description, color, folderId } = variables + const { workspaceId, sourceId, name, description, color, folderId, newId } = variables logger.info(`Duplicating workflow ${sourceId} in workspace: ${workspaceId}`) @@ -364,6 +381,7 @@ export function useDuplicateWorkflowMutation() { color, workspaceId, folderId: folderId ?? null, + newId, }), }) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index cee5f467a35..10d49b65d64 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -24,6 +24,7 @@ interface DuplicateWorkflowOptions { workspaceId?: string folderId?: string | null requestId?: string + newWorkflowId?: string } interface DuplicateWorkflowResult { @@ -93,10 +94,10 @@ export async function duplicateWorkflow( workspaceId, folderId, requestId = 'unknown', + newWorkflowId: clientNewWorkflowId, } = options - // Generate new workflow ID - const newWorkflowId = crypto.randomUUID() + const newWorkflowId = clientNewWorkflowId || crypto.randomUUID() const now = new Date() // Duplicate workflow and all related data in a transaction