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
48 changes: 31 additions & 17 deletions apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, 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'
Expand Down Expand Up @@ -37,7 +37,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:

logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)

// Verify the source folder exists
const sourceFolder = await db
.select()
.from(workflowFolder)
Expand All @@ -48,7 +47,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
throw new Error('Source folder not found')
}

// Check if user has permission to access the source folder
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
Expand All @@ -61,26 +59,51 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:

const targetWorkspaceId = workspaceId || sourceFolder.workspaceId

// Step 1: Duplicate folder structure
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
const newFolderId = crypto.randomUUID()
const now = new Date()
const targetParentId = parentId ?? sourceFolder.parentId

const folderParentCondition = targetParentId
? eq(workflowFolder.parentId, targetParentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = targetParentId
? eq(workflow.folderId, targetParentId)
: isNull(workflow.folderId)

const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), 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)
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0

// Create the new root folder
await tx.insert(workflowFolder).values({
id: newFolderId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
name,
color: color || sourceFolder.color,
parentId: parentId || sourceFolder.parentId,
sortOrder: sourceFolder.sortOrder,
parentId: targetParentId,
sortOrder,
isExpanded: false,
createdAt: now,
updatedAt: now,
})

// Recursively duplicate child folders
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
await duplicateFolderStructure(
tx,
Expand All @@ -96,7 +119,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return { newFolderId, folderMapping }
})

// Step 2: Duplicate workflows
const workflowStats = await duplicateWorkflowsInFolderTree(
sourceFolder.workspaceId,
targetWorkspaceId,
Expand Down Expand Up @@ -173,7 +195,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
}

// Helper to recursively duplicate folder structure
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response parentId diverges from inserted targetParentId

Low Severity

The DB insert uses targetParentId (computed with ?? on line 65), but the JSON response on line 160 still uses the old expression parentId || sourceFolder.parentId (with ||). The response parentId can differ from the value actually stored in the database — for instance, if parentId were an empty string, ?? would keep it while || would fall through to sourceFolder.parentId. The response parentId feeds the client store via mapFolder, so any mismatch means the client briefly shows the folder under the wrong parent until refetch.

Additional Locations (1)

Fix in Cursor Fix in Web

async function duplicateFolderStructure(
tx: any,
sourceFolderId: string,
Expand All @@ -184,7 +205,6 @@ async function duplicateFolderStructure(
timestamp: Date,
folderMapping: Map<string, string>
): Promise<void> {
// Get all child folders
const childFolders = await tx
.select()
.from(workflowFolder)
Expand All @@ -195,7 +215,6 @@ async function duplicateFolderStructure(
)
)

// Create each child folder and recurse
for (const childFolder of childFolders) {
const newChildFolderId = crypto.randomUUID()
folderMapping.set(childFolder.id, newChildFolderId)
Expand All @@ -213,7 +232,6 @@ async function duplicateFolderStructure(
updatedAt: timestamp,
})

// Recurse for this child's children
await duplicateFolderStructure(
tx,
childFolder.id,
Expand All @@ -227,7 +245,6 @@ async function duplicateFolderStructure(
}
}

// Helper to duplicate all workflows in a folder tree
async function duplicateWorkflowsInFolderTree(
sourceWorkspaceId: string,
targetWorkspaceId: string,
Expand All @@ -237,17 +254,14 @@ async function duplicateWorkflowsInFolderTree(
): Promise<{ total: number; succeeded: number; failed: number }> {
const stats = { total: 0, succeeded: 0, failed: 0 }

// Process each folder in the mapping
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
// Get workflows in this folder
const workflowsInFolder = await db
.select()
.from(workflow)
.where(and(eq(workflow.folderId, oldFolderId), eq(workflow.workspaceId, sourceWorkspaceId)))

stats.total += workflowsInFolder.length

// Duplicate each workflow
for (const sourceWorkflow of workflowsInFolder) {
try {
await duplicateWorkflow({
Expand Down
Loading