Skip to content

Commit b49ed2f

Browse files
authored
feat(export): support maintenance of nested folder structure on import/export, added folder export admin route (#2795)
* feat(export): support maintenance of nested folder structure on import/export * consolidated utils, added admin routes * remove default tags from A2A
1 parent 837405e commit b49ed2f

File tree

13 files changed

+709
-302
lines changed

13 files changed

+709
-302
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/**
2+
* GET /api/v1/admin/folders/[id]/export
3+
*
4+
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
5+
*
6+
* Query Parameters:
7+
* - format: 'zip' (default) or 'json'
8+
*
9+
* Response:
10+
* - ZIP file download (Content-Type: application/zip)
11+
* - JSON: FolderExportFullPayload
12+
*/
13+
14+
import { db } from '@sim/db'
15+
import { workflow, workflowFolder } from '@sim/db/schema'
16+
import { createLogger } from '@sim/logger'
17+
import { eq } from 'drizzle-orm'
18+
import { NextResponse } from 'next/server'
19+
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
20+
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
21+
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
22+
import {
23+
internalErrorResponse,
24+
notFoundResponse,
25+
singleResponse,
26+
} from '@/app/api/v1/admin/responses'
27+
import {
28+
type FolderExportPayload,
29+
parseWorkflowVariables,
30+
type WorkflowExportState,
31+
} from '@/app/api/v1/admin/types'
32+
33+
const logger = createLogger('AdminFolderExportAPI')
34+
35+
interface RouteParams {
36+
id: string
37+
}
38+
39+
interface CollectedWorkflow {
40+
id: string
41+
folderId: string | null
42+
}
43+
44+
/**
45+
* Recursively collects all workflows within a folder and its subfolders.
46+
*/
47+
function collectWorkflowsInFolder(
48+
folderId: string,
49+
allWorkflows: Array<{ id: string; folderId: string | null }>,
50+
allFolders: Array<{ id: string; parentId: string | null }>
51+
): CollectedWorkflow[] {
52+
const collected: CollectedWorkflow[] = []
53+
54+
for (const wf of allWorkflows) {
55+
if (wf.folderId === folderId) {
56+
collected.push({ id: wf.id, folderId: wf.folderId })
57+
}
58+
}
59+
60+
for (const folder of allFolders) {
61+
if (folder.parentId === folderId) {
62+
const childWorkflows = collectWorkflowsInFolder(folder.id, allWorkflows, allFolders)
63+
collected.push(...childWorkflows)
64+
}
65+
}
66+
67+
return collected
68+
}
69+
70+
/**
71+
* Collects all subfolders recursively under a root folder.
72+
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
73+
*/
74+
function collectSubfolders(
75+
rootFolderId: string,
76+
allFolders: Array<{ id: string; name: string; parentId: string | null }>
77+
): FolderExportPayload[] {
78+
const subfolders: FolderExportPayload[] = []
79+
80+
function collect(parentId: string) {
81+
for (const folder of allFolders) {
82+
if (folder.parentId === parentId) {
83+
subfolders.push({
84+
id: folder.id,
85+
name: folder.name,
86+
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
87+
})
88+
collect(folder.id)
89+
}
90+
}
91+
}
92+
93+
collect(rootFolderId)
94+
return subfolders
95+
}
96+
97+
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
98+
const { id: folderId } = await context.params
99+
const url = new URL(request.url)
100+
const format = url.searchParams.get('format') || 'zip'
101+
102+
try {
103+
const [folderData] = await db
104+
.select({
105+
id: workflowFolder.id,
106+
name: workflowFolder.name,
107+
workspaceId: workflowFolder.workspaceId,
108+
})
109+
.from(workflowFolder)
110+
.where(eq(workflowFolder.id, folderId))
111+
.limit(1)
112+
113+
if (!folderData) {
114+
return notFoundResponse('Folder')
115+
}
116+
117+
const allWorkflows = await db
118+
.select({ id: workflow.id, folderId: workflow.folderId })
119+
.from(workflow)
120+
.where(eq(workflow.workspaceId, folderData.workspaceId))
121+
122+
const allFolders = await db
123+
.select({
124+
id: workflowFolder.id,
125+
name: workflowFolder.name,
126+
parentId: workflowFolder.parentId,
127+
})
128+
.from(workflowFolder)
129+
.where(eq(workflowFolder.workspaceId, folderData.workspaceId))
130+
131+
const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders)
132+
const subfolders = collectSubfolders(folderId, allFolders)
133+
134+
const workflowExports: Array<{
135+
workflow: {
136+
id: string
137+
name: string
138+
description: string | null
139+
color: string | null
140+
folderId: string | null
141+
}
142+
state: WorkflowExportState
143+
}> = []
144+
145+
for (const collectedWf of workflowsInFolder) {
146+
try {
147+
const [wfData] = await db
148+
.select()
149+
.from(workflow)
150+
.where(eq(workflow.id, collectedWf.id))
151+
.limit(1)
152+
153+
if (!wfData) {
154+
logger.warn(`Skipping workflow ${collectedWf.id} - not found`)
155+
continue
156+
}
157+
158+
const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id)
159+
160+
if (!normalizedData) {
161+
logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`)
162+
continue
163+
}
164+
165+
const variables = parseWorkflowVariables(wfData.variables)
166+
167+
const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId
168+
169+
const state: WorkflowExportState = {
170+
blocks: normalizedData.blocks,
171+
edges: normalizedData.edges,
172+
loops: normalizedData.loops,
173+
parallels: normalizedData.parallels,
174+
metadata: {
175+
name: wfData.name,
176+
description: wfData.description ?? undefined,
177+
color: wfData.color,
178+
exportedAt: new Date().toISOString(),
179+
},
180+
variables,
181+
}
182+
183+
workflowExports.push({
184+
workflow: {
185+
id: wfData.id,
186+
name: wfData.name,
187+
description: wfData.description,
188+
color: wfData.color,
189+
folderId: remappedFolderId,
190+
},
191+
state,
192+
})
193+
} catch (error) {
194+
logger.error(`Failed to load workflow ${collectedWf.id}:`, { error })
195+
}
196+
}
197+
198+
logger.info(
199+
`Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders`
200+
)
201+
202+
if (format === 'json') {
203+
const exportPayload = {
204+
version: '1.0',
205+
exportedAt: new Date().toISOString(),
206+
folder: {
207+
id: folderData.id,
208+
name: folderData.name,
209+
},
210+
workflows: workflowExports,
211+
folders: subfolders,
212+
}
213+
214+
return singleResponse(exportPayload)
215+
}
216+
217+
const zipWorkflows = workflowExports.map((wf) => ({
218+
workflow: {
219+
id: wf.workflow.id,
220+
name: wf.workflow.name,
221+
description: wf.workflow.description ?? undefined,
222+
color: wf.workflow.color ?? undefined,
223+
folderId: wf.workflow.folderId,
224+
},
225+
state: wf.state,
226+
variables: wf.state.variables,
227+
}))
228+
229+
const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders)
230+
const arrayBuffer = await zipBlob.arrayBuffer()
231+
232+
const sanitizedName = sanitizePathSegment(folderData.name)
233+
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
234+
235+
return new NextResponse(arrayBuffer, {
236+
status: 200,
237+
headers: {
238+
'Content-Type': 'application/zip',
239+
'Content-Disposition': `attachment; filename="${filename}"`,
240+
'Content-Length': arrayBuffer.byteLength.toString(),
241+
},
242+
})
243+
} catch (error) {
244+
logger.error('Admin API: Failed to export folder', { error, folderId })
245+
return internalErrorResponse('Failed to export folder')
246+
}
247+
})

apps/sim/app/api/v1/admin/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@
3434
* GET /api/v1/admin/workflows/:id - Get workflow details
3535
* DELETE /api/v1/admin/workflows/:id - Delete workflow
3636
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
37+
* POST /api/v1/admin/workflows/export - Export multiple workflows (ZIP/JSON)
3738
* POST /api/v1/admin/workflows/import - Import single workflow
3839
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
3940
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
4041
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
4142
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
4243
*
44+
* Folders:
45+
* GET /api/v1/admin/folders/:id/export - Export folder with contents (ZIP/JSON)
46+
*
4347
* Organizations:
4448
* GET /api/v1/admin/organizations - List all organizations
4549
* POST /api/v1/admin/organizations - Create organization (requires ownerId)

apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* GET /api/v1/admin/workflows/[id]/export
33
*
4-
* Export a single workflow as JSON.
4+
* Export a single workflow as JSON (raw, unsanitized for admin backup/restore).
55
*
66
* Response: AdminSingleResponse<WorkflowExportPayload>
77
*/

0 commit comments

Comments
 (0)