Skip to content

Commit 7be60f6

Browse files
committed
feat(duplicate): added folder duplication, add duplicate to sidebar context menu (#1902)
1 parent 054865a commit 7be60f6

File tree

11 files changed

+964
-292
lines changed

11 files changed

+964
-292
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { db } from '@sim/db'
2+
import { workflow, workflowFolder } from '@sim/db/schema'
3+
import { and, eq } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { z } from 'zod'
6+
import { getSession } from '@/lib/auth'
7+
import { createLogger } from '@/lib/logs/console/logger'
8+
import { getUserEntityPermissions } from '@/lib/permissions/utils'
9+
import { generateRequestId } from '@/lib/utils'
10+
import { duplicateWorkflow } from '@/lib/workflows/duplicate'
11+
12+
const logger = createLogger('FolderDuplicateAPI')
13+
14+
const DuplicateRequestSchema = z.object({
15+
name: z.string().min(1, 'Name is required'),
16+
workspaceId: z.string().optional(),
17+
parentId: z.string().nullable().optional(),
18+
color: z.string().optional(),
19+
})
20+
21+
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
22+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
23+
const { id: sourceFolderId } = await params
24+
const requestId = generateRequestId()
25+
const startTime = Date.now()
26+
27+
const session = await getSession()
28+
if (!session?.user?.id) {
29+
logger.warn(`[${requestId}] Unauthorized folder duplication attempt for ${sourceFolderId}`)
30+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
31+
}
32+
33+
try {
34+
const body = await req.json()
35+
const { name, workspaceId, parentId, color } = DuplicateRequestSchema.parse(body)
36+
37+
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
38+
39+
// Verify the source folder exists
40+
const sourceFolder = await db
41+
.select()
42+
.from(workflowFolder)
43+
.where(eq(workflowFolder.id, sourceFolderId))
44+
.then((rows) => rows[0])
45+
46+
if (!sourceFolder) {
47+
throw new Error('Source folder not found')
48+
}
49+
50+
// Check if user has permission to access the source folder
51+
const userPermission = await getUserEntityPermissions(
52+
session.user.id,
53+
'workspace',
54+
sourceFolder.workspaceId
55+
)
56+
57+
if (!userPermission || userPermission === 'read') {
58+
throw new Error('Source folder not found or access denied')
59+
}
60+
61+
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
62+
63+
// Step 1: Duplicate folder structure
64+
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
65+
const newFolderId = crypto.randomUUID()
66+
const now = new Date()
67+
68+
// Create the new root folder
69+
await tx.insert(workflowFolder).values({
70+
id: newFolderId,
71+
userId: session.user.id,
72+
workspaceId: targetWorkspaceId,
73+
name,
74+
color: color || sourceFolder.color,
75+
parentId: parentId || sourceFolder.parentId,
76+
sortOrder: sourceFolder.sortOrder,
77+
isExpanded: false,
78+
createdAt: now,
79+
updatedAt: now,
80+
})
81+
82+
// Recursively duplicate child folders
83+
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
84+
await duplicateFolderStructure(
85+
tx,
86+
sourceFolderId,
87+
newFolderId,
88+
sourceFolder.workspaceId,
89+
targetWorkspaceId,
90+
session.user.id,
91+
now,
92+
folderMapping
93+
)
94+
95+
return { newFolderId, folderMapping }
96+
})
97+
98+
// Step 2: Duplicate workflows
99+
const workflowStats = await duplicateWorkflowsInFolderTree(
100+
sourceFolder.workspaceId,
101+
targetWorkspaceId,
102+
folderMapping,
103+
session.user.id,
104+
requestId
105+
)
106+
107+
const elapsed = Date.now() - startTime
108+
logger.info(
109+
`[${requestId}] Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`,
110+
{
111+
foldersCount: folderMapping.size,
112+
workflowsCount: workflowStats.total,
113+
workflowsSucceeded: workflowStats.succeeded,
114+
workflowsFailed: workflowStats.failed,
115+
}
116+
)
117+
118+
return NextResponse.json(
119+
{
120+
id: newFolderId,
121+
name,
122+
color: color || sourceFolder.color,
123+
workspaceId: targetWorkspaceId,
124+
parentId: parentId || sourceFolder.parentId,
125+
foldersCount: folderMapping.size,
126+
workflowsCount: workflowStats.succeeded,
127+
},
128+
{ status: 201 }
129+
)
130+
} catch (error) {
131+
if (error instanceof Error) {
132+
if (error.message === 'Source folder not found') {
133+
logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`)
134+
return NextResponse.json({ error: 'Source folder not found' }, { status: 404 })
135+
}
136+
137+
if (error.message === 'Source folder not found or access denied') {
138+
logger.warn(
139+
`[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}`
140+
)
141+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
142+
}
143+
}
144+
145+
if (error instanceof z.ZodError) {
146+
logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors })
147+
return NextResponse.json(
148+
{ error: 'Invalid request data', details: error.errors },
149+
{ status: 400 }
150+
)
151+
}
152+
153+
const elapsed = Date.now() - startTime
154+
logger.error(
155+
`[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`,
156+
error
157+
)
158+
return NextResponse.json({ error: 'Failed to duplicate folder' }, { status: 500 })
159+
}
160+
}
161+
162+
// Helper to recursively duplicate folder structure
163+
async function duplicateFolderStructure(
164+
tx: any,
165+
sourceFolderId: string,
166+
newParentFolderId: string,
167+
sourceWorkspaceId: string,
168+
targetWorkspaceId: string,
169+
userId: string,
170+
timestamp: Date,
171+
folderMapping: Map<string, string>
172+
): Promise<void> {
173+
// Get all child folders
174+
const childFolders = await tx
175+
.select()
176+
.from(workflowFolder)
177+
.where(
178+
and(
179+
eq(workflowFolder.parentId, sourceFolderId),
180+
eq(workflowFolder.workspaceId, sourceWorkspaceId)
181+
)
182+
)
183+
184+
// Create each child folder and recurse
185+
for (const childFolder of childFolders) {
186+
const newChildFolderId = crypto.randomUUID()
187+
folderMapping.set(childFolder.id, newChildFolderId)
188+
189+
await tx.insert(workflowFolder).values({
190+
id: newChildFolderId,
191+
userId,
192+
workspaceId: targetWorkspaceId,
193+
name: childFolder.name,
194+
color: childFolder.color,
195+
parentId: newParentFolderId,
196+
sortOrder: childFolder.sortOrder,
197+
isExpanded: false,
198+
createdAt: timestamp,
199+
updatedAt: timestamp,
200+
})
201+
202+
// Recurse for this child's children
203+
await duplicateFolderStructure(
204+
tx,
205+
childFolder.id,
206+
newChildFolderId,
207+
sourceWorkspaceId,
208+
targetWorkspaceId,
209+
userId,
210+
timestamp,
211+
folderMapping
212+
)
213+
}
214+
}
215+
216+
// Helper to duplicate all workflows in a folder tree
217+
async function duplicateWorkflowsInFolderTree(
218+
sourceWorkspaceId: string,
219+
targetWorkspaceId: string,
220+
folderMapping: Map<string, string>,
221+
userId: string,
222+
requestId: string
223+
): Promise<{ total: number; succeeded: number; failed: number }> {
224+
const stats = { total: 0, succeeded: 0, failed: 0 }
225+
226+
// Process each folder in the mapping
227+
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
228+
// Get workflows in this folder
229+
const workflowsInFolder = await db
230+
.select()
231+
.from(workflow)
232+
.where(and(eq(workflow.folderId, oldFolderId), eq(workflow.workspaceId, sourceWorkspaceId)))
233+
234+
stats.total += workflowsInFolder.length
235+
236+
// Duplicate each workflow
237+
for (const sourceWorkflow of workflowsInFolder) {
238+
try {
239+
await duplicateWorkflow({
240+
sourceWorkflowId: sourceWorkflow.id,
241+
userId,
242+
name: sourceWorkflow.name,
243+
description: sourceWorkflow.description || undefined,
244+
color: sourceWorkflow.color,
245+
workspaceId: targetWorkspaceId,
246+
folderId: newFolderId,
247+
requestId,
248+
})
249+
250+
stats.succeeded++
251+
} catch (error) {
252+
stats.failed++
253+
logger.error(`[${requestId}] Error duplicating workflow ${sourceWorkflow.id}:`, error)
254+
}
255+
}
256+
}
257+
258+
return stats
259+
}

0 commit comments

Comments
 (0)