Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`)

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/folders/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})

Expand Down
35 changes: 27 additions & 8 deletions apps/sim/app/api/folders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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 })
}
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/workflows/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}`)

Expand All @@ -45,6 +47,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workspaceId,
folderId,
requestId,
newWorkflowId: newId,
})

try {
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/workflows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -109,6 +110,7 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json()
const {
id: clientId,
name,
description,
color,
Expand Down Expand Up @@ -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}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function FolderItem({
folderId: folder.id,
name,
color,
id: crypto.randomUUID(),
})

if (result.id) {
Expand All @@ -164,6 +165,7 @@ export function FolderItem({
workspaceId,
name: 'New Folder',
parentId: folder.id,
id: crypto.randomUUID(),
})
if (result.id) {
expandFolder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
workspaceId,
name,
color,
id: crypto.randomUUID(),
})

if (result.id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe
name: duplicateName,
parentId: folder.parentId,
color: folder.color,
newId: crypto.randomUUID(),
})

if (result?.id) {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 36 additions & 6 deletions apps/sim/hooks/queries/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ interface CreateFolderVariables {
parentId?: string
color?: string
sortOrder?: number
id?: string
}

interface UpdateFolderVariables {
Expand All @@ -90,6 +91,7 @@ interface DuplicateFolderVariables {
name: string
parentId?: string | null
color?: string
newId?: string
}

/**
Expand All @@ -102,13 +104,14 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
variables: TVariables,
tempId: string,
previousFolders: Record<string, WorkflowFolder>
) => WorkflowFolder
) => WorkflowFolder,
customGenerateTempId?: (variables: TVariables) => string
) {
return createOptimisticMutationHandlers<WorkflowFolder, TVariables, WorkflowFolder>(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)
Expand All @@ -121,12 +124,36 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
replaceOptimisticEntry: (tempId, data) => {
useFolderStore.setState((state) => {
const { [tempId]: _, ...remainingFolders } = state.folders
return {

const update: Record<string, unknown> = {
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) => {
Expand Down Expand Up @@ -163,7 +190,8 @@ export function useCreateFolder() {
createdAt: new Date(),
updatedAt: new Date(),
}
}
},
(variables) => variables.id ?? crypto.randomUUID()
)

return useMutation({
Expand Down Expand Up @@ -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 {
Expand All @@ -261,7 +288,8 @@ export function useDuplicateFolderMutation() {
createdAt: new Date(),
updatedAt: new Date(),
}
}
},
(variables) => variables.newId ?? crypto.randomUUID()
)

return useMutation({
Expand All @@ -271,6 +299,7 @@ export function useDuplicateFolderMutation() {
name,
parentId,
color,
newId,
}: DuplicateFolderVariables): Promise<WorkflowFolder> => {
const response = await fetch(`/api/folders/${id}/duplicate`, {
method: 'POST',
Expand All @@ -280,6 +309,7 @@ export function useDuplicateFolderMutation() {
name,
parentId: parentId ?? null,
color,
newId,
}),
})

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/hooks/queries/utils/optimistic-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface OptimisticMutationConfig<TData, TVariables, TItem, TContext> {
name: string
getQueryKey: (variables: TVariables) => readonly unknown[]
getSnapshot: () => Record<string, TItem>
generateTempId: () => string
generateTempId: (variables: TVariables) => string
createOptimisticItem: (variables: TVariables, tempId: string) => TItem
applyOptimisticUpdate: (tempId: string, item: TItem) => void
replaceOptimisticEntry: (tempId: string, data: TData) => void
Expand Down Expand Up @@ -41,7 +41,7 @@ export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
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}`)
Expand Down
Loading