Skip to content

Commit 5b7cf99

Browse files
authored
feat(folders): add the generic resourceType-driven folder engine (#6037)
- Replaces the workflow-hardcoded folder orchestration with a config-driven engine: one implementation parameterized by `FOLDER_RESOURCES`, with `lib/workflows/orchestration/folder-lifecycle.ts` reduced from 620 lines to thin wrappers so no existing caller moved - Threads `resourceType` through all four folder routes and the React Query keys; contract widened to `workflow | knowledge_base | table` - Folder locking moved behind a `supportsLocking` capability rather than scattered `resourceType === 'workflow'` checks - Folder restore now matches dependents by the cascade timestamp, so a schedule or webhook archived independently before a folder delete stays archived (deliberate behavior change, documented in the PR) - 25 cascade tests including a fixed-statement-count assertion guarding against N+1 regressions
1 parent 1d64b92 commit 5b7cf99

31 files changed

Lines changed: 2241 additions & 759 deletions

File tree

apps/sim/app/api/folders/[id]/duplicate/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ export const POST = withRouteHandler(
112112
tx,
113113
targetWorkspaceId,
114114
targetParentId,
115-
name
115+
name,
116+
'workflow'
116117
)
117118

118119
await tx.insert(folderTable).values({

apps/sim/app/api/folders/[id]/restore/route.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { restoreFolderContract } from '@/lib/api/contracts'
55
import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { restoreFolder } from '@/lib/folders/lifecycle'
9+
import { folderMutationStatus } from '@/lib/folders/status'
810
import { captureServerEvent } from '@/lib/posthog/server'
9-
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
1011
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1112

1213
const logger = createLogger('RestoreFolderAPI')
@@ -23,29 +24,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
2324
const parsed = await parseRequest(restoreFolderContract, request, context)
2425
if (!parsed.success) return parsed.response
2526
const { id: folderId } = parsed.data.params
26-
const { workspaceId } = parsed.data.body
27+
const { workspaceId, resourceType } = parsed.data.body
2728

2829
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
2930
if (permission !== 'admin' && permission !== 'write') {
3031
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
3132
}
3233

33-
const result = await performRestoreFolder({
34+
const result = await restoreFolder({
35+
resourceType,
3436
folderId,
3537
workspaceId,
3638
userId: session.user.id,
3739
})
3840

3941
if (!result.success) {
40-
return NextResponse.json({ error: result.error }, { status: 400 })
42+
return NextResponse.json(
43+
{ error: result.error },
44+
{ status: folderMutationStatus(result.errorCode) }
45+
)
4146
}
4247

43-
logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
48+
logger.info(`Restored folder ${folderId}`, {
49+
resourceType,
50+
restoredItems: result.restoredItems,
51+
})
4452

4553
captureServerEvent(
4654
session.user.id,
4755
'folder_restored',
48-
{ folder_id: folderId, workspace_id: workspaceId },
56+
{ folder_id: folderId, workspace_id: workspaceId, resource_type: resourceType },
4957
{ groups: { workspace: workspaceId } }
5058
)
5159

apps/sim/app/api/folders/[id]/route.test.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,14 @@ import {
88
authMockFns,
99
createMockRequest,
1010
dbChainMockFns,
11+
foldersLifecycleMock,
12+
foldersLifecycleMockFns,
1113
type MockUser,
1214
permissionsMock,
1315
permissionsMockFns,
1416
queueTableRows,
1517
resetDbChainMock,
1618
schemaMock,
17-
workflowsOrchestrationMock,
18-
workflowsOrchestrationMockFns,
19-
workflowsUtilsMock,
20-
workflowsUtilsMockFns,
2119
} from '@sim/testing'
2220
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
2321

@@ -36,8 +34,11 @@ const { mockLogger } = vi.hoisted(() => {
3634
}
3735
})
3836

39-
const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder
40-
const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder
37+
const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder
38+
const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder
39+
40+
/** Parent ids the mocked engine treats as closing a cycle for the folder under test. */
41+
const cyclicParentIds = new Set<string>()
4142

4243
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
4344

@@ -48,8 +49,7 @@ vi.mock('@sim/logger', () => ({
4849
getRequestContext: () => undefined,
4950
}))
5051
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
51-
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
52-
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
52+
vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock)
5353

5454
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
5555

@@ -101,25 +101,19 @@ describe('Individual Folder API Route', () => {
101101
resetDbChainMock()
102102

103103
mockGetUserEntityPermissions.mockResolvedValue('admin')
104-
mockPerformDeleteFolder.mockResolvedValue({
104+
mockDeleteFolder.mockResolvedValue({
105105
success: true,
106106
deletedItems: { folders: 1, workflows: 0 },
107107
})
108-
mockPerformUpdateFolder.mockImplementation(async (params) => {
108+
mockUpdateFolder.mockImplementation(async (params) => {
109109
if (params.parentId && params.parentId === params.folderId) {
110110
return {
111111
success: false,
112112
error: 'Folder cannot be its own parent',
113113
errorCode: 'validation',
114114
}
115115
}
116-
if (
117-
params.parentId &&
118-
(await workflowsUtilsMockFns.mockCheckForCircularReference(
119-
params.folderId,
120-
params.parentId
121-
))
122-
) {
116+
if (params.parentId && cyclicParentIds.has(params.parentId)) {
123117
return {
124118
success: false,
125119
error: 'Cannot create circular folder reference',
@@ -140,7 +134,7 @@ describe('Individual Folder API Route', () => {
140134
},
141135
}
142136
})
143-
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false)
137+
cyclicParentIds.clear()
144138
})
145139

146140
describe('PUT /api/folders/[id]', () => {
@@ -250,6 +244,26 @@ describe('Individual Folder API Route', () => {
250244
expect(data).toHaveProperty('folder')
251245
})
252246

247+
it('rejects a locked write on a resource type that has no lock semantics', async () => {
248+
mockAuthenticatedUser()
249+
queueFolderLookup()
250+
251+
const req = createMockRequest(
252+
'PUT',
253+
{ locked: true },
254+
{},
255+
'http://localhost:3000/api/folders/folder-1?resourceType=knowledge_base'
256+
)
257+
const params = Promise.resolve({ id: 'folder-1' })
258+
259+
const response = await PUT(req, { params })
260+
261+
expect(response.status).toBe(400)
262+
const data = await response.json()
263+
expect(data.error).toBe('Folder locking is only supported for workflow folders')
264+
expect(mockUpdateFolder).not.toHaveBeenCalled()
265+
})
266+
253267
it('should return 400 when trying to set folder as its own parent', async () => {
254268
mockAuthenticatedUser()
255269

@@ -368,7 +382,7 @@ describe('Individual Folder API Route', () => {
368382
workspaceId: 'workspace-123',
369383
})
370384

371-
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true)
385+
cyclicParentIds.add('folder-1')
372386

373387
const req = createMockRequest('PUT', {
374388
name: 'Updated Folder 3',
@@ -382,9 +396,8 @@ describe('Individual Folder API Route', () => {
382396

383397
const data = await response.json()
384398
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
385-
expect(workflowsUtilsMockFns.mockCheckForCircularReference).toHaveBeenCalledWith(
386-
'folder-3',
387-
'folder-1'
399+
expect(mockUpdateFolder).toHaveBeenCalledWith(
400+
expect.objectContaining({ folderId: 'folder-3', parentId: 'folder-1' })
388401
)
389402
})
390403
})
@@ -405,14 +418,34 @@ describe('Individual Folder API Route', () => {
405418
const data = await response.json()
406419
expect(data).toHaveProperty('success', true)
407420
expect(data).toHaveProperty('deletedItems')
408-
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
421+
expect(mockDeleteFolder).toHaveBeenCalledWith({
422+
resourceType: 'workflow',
409423
folderId: 'folder-1',
410424
workspaceId: 'workspace-123',
411425
userId: TEST_USER.id,
412426
folderName: 'Test Folder',
413427
})
414428
})
415429

430+
it('surfaces a delete-locked resource as 423, not a generic 500', async () => {
431+
mockAuthenticatedUser()
432+
queueFolderLookup()
433+
mockDeleteFolder.mockResolvedValueOnce({
434+
success: false,
435+
error: 'Cannot delete folder: table Ledger is delete-locked',
436+
errorCode: 'locked',
437+
})
438+
439+
const req = createMockRequest('DELETE')
440+
const params = Promise.resolve({ id: 'folder-1' })
441+
442+
const response = await DELETE(req, { params })
443+
444+
expect(response.status).toBe(423)
445+
const data = await response.json()
446+
expect(data.error).toBe('Cannot delete folder: table Ledger is delete-locked')
447+
})
448+
416449
it('should return 401 for unauthenticated delete requests', async () => {
417450
mockUnauthenticated()
418451

@@ -458,7 +491,7 @@ describe('Individual Folder API Route', () => {
458491

459492
const data = await response.json()
460493
expect(data).toHaveProperty('success', true)
461-
expect(mockPerformDeleteFolder).toHaveBeenCalled()
494+
expect(mockDeleteFolder).toHaveBeenCalled()
462495
})
463496

464497
it('should allow folder deletion for admin permissions', async () => {
@@ -476,7 +509,7 @@ describe('Individual Folder API Route', () => {
476509

477510
const data = await response.json()
478511
expect(data).toHaveProperty('success', true)
479-
expect(mockPerformDeleteFolder).toHaveBeenCalled()
512+
expect(mockDeleteFolder).toHaveBeenCalled()
480513
})
481514

482515
it('should handle database errors during deletion', async () => {

0 commit comments

Comments
 (0)