Skip to content

Commit fed33ce

Browse files
committed
feat(folders): generalize folders and add pinning across workflows, files, knowledge bases, and tables
- Replace workflowFolder/workspaceFileFolder with one generic `folder` table (resourceType discriminator) and a new polymorphic `pinnedItem` table - Migration backfills existing folders, repoints FKs, adds folderId to knowledge_base/user_table_definitions; DB trigger + app-level validation enforce same-workspace/same-resourceType parenting - Generic /api/folders and new /api/pinned-items routes, contracts, and React Query hooks shared by all four resource types - Real folder tree + breadcrumb navigation and pin buttons wired into Knowledge Bases and Tables pages (previously flat lists), consolidating duplicated create/delete/restore cascade and breadcrumb logic into shared helpers used by files/knowledge/tables/workflow sidebar
1 parent cc7a6a8 commit fed33ce

107 files changed

Lines changed: 21441 additions & 1444 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { workflow, workflowFolder } from '@sim/db/schema'
3+
import { folder as folderTable, workflow } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { FolderLockedError } from '@sim/platform-authz/workflow'
66
import { generateId } from '@sim/utils/id'
@@ -39,8 +39,14 @@ export const POST = withRouteHandler(
3939

4040
const sourceFolder = await db
4141
.select()
42-
.from(workflowFolder)
43-
.where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt)))
42+
.from(folderTable)
43+
.where(
44+
and(
45+
eq(folderTable.id, sourceFolderId),
46+
eq(folderTable.resourceType, 'workflow'),
47+
isNull(folderTable.deletedAt)
48+
)
49+
)
4450
.then((rows) => rows[0])
4551

4652
if (!sourceFolder) {
@@ -69,17 +75,23 @@ export const POST = withRouteHandler(
6975
await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId)
7076

7177
const folderParentCondition = targetParentId
72-
? eq(workflowFolder.parentId, targetParentId)
73-
: isNull(workflowFolder.parentId)
78+
? eq(folderTable.parentId, targetParentId)
79+
: isNull(folderTable.parentId)
7480
const workflowParentCondition = targetParentId
7581
? eq(workflow.folderId, targetParentId)
7682
: isNull(workflow.folderId)
7783

7884
const [[folderResult], [workflowResult]] = await Promise.all([
7985
tx
80-
.select({ minSortOrder: min(workflowFolder.sortOrder) })
81-
.from(workflowFolder)
82-
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
86+
.select({ minSortOrder: min(folderTable.sortOrder) })
87+
.from(folderTable)
88+
.where(
89+
and(
90+
eq(folderTable.workspaceId, targetWorkspaceId),
91+
eq(folderTable.resourceType, 'workflow'),
92+
folderParentCondition
93+
)
94+
),
8395
tx
8496
.select({ minSortOrder: min(workflow.sortOrder) })
8597
.from(workflow)
@@ -101,8 +113,9 @@ export const POST = withRouteHandler(
101113
name
102114
)
103115

104-
await tx.insert(workflowFolder).values({
116+
await tx.insert(folderTable).values({
105117
id: newFolderId,
118+
resourceType: 'workflow',
106119
userId: session.user.id,
107120
workspaceId: targetWorkspaceId,
108121
name: deduplicatedName,
@@ -168,8 +181,8 @@ export const POST = withRouteHandler(
168181

169182
const duplicatedFolder = await db
170183
.select()
171-
.from(workflowFolder)
172-
.where(eq(workflowFolder.id, newFolderId))
184+
.from(folderTable)
185+
.where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow')))
173186
.then((rows) => rows[0])
174187

175188
return NextResponse.json({ folder: duplicatedFolder }, { status: 201 })
@@ -216,30 +229,54 @@ export const POST = withRouteHandler(
216229
}
217230
)
218231

232+
/**
233+
* Verifies a prospective duplicate-target parent is usable: the same
234+
* existence/workspace/resourceType/deleted invariant every other folder
235+
* write path checks via `assertFolderParentValid` is applied inline to the
236+
* immediate parent's row (the first loop iteration below), then the walk
237+
* continues up the full ancestor chain applying the workflow-specific
238+
* lock-cascade and self-descendant checks. Workspace/resourceType
239+
* consistency for ancestors beyond the immediate parent is guaranteed by the
240+
* `folder_parent_resource_type_match` DB trigger, so later iterations only
241+
* need to re-fetch `parentId`/`locked`. Checking inline on the one row
242+
* already being fetched for the walk avoids a redundant second SELECT of
243+
* the same immediate-parent row that a separate `assertFolderParentValid`
244+
* call would issue.
245+
*/
219246
async function assertTargetParentFolderMutable(
220247
tx: DbOrTx,
221248
parentId: string | null,
222249
targetWorkspaceId: string,
223250
sourceFolderId: string
224251
): Promise<void> {
225-
let currentFolderId = parentId
252+
if (!parentId) return
253+
254+
let currentFolderId: string | null = parentId
226255
const visited = new Set<string>()
256+
let isFirstIteration = true
227257

228258
while (currentFolderId && !visited.has(currentFolderId)) {
229259
visited.add(currentFolderId)
230260
const [folder] = await tx
231261
.select({
232-
id: workflowFolder.id,
233-
parentId: workflowFolder.parentId,
234-
workspaceId: workflowFolder.workspaceId,
235-
locked: workflowFolder.locked,
236-
archivedAt: workflowFolder.archivedAt,
262+
id: folderTable.id,
263+
parentId: folderTable.parentId,
264+
locked: folderTable.locked,
265+
workspaceId: folderTable.workspaceId,
266+
resourceType: folderTable.resourceType,
267+
deletedAt: folderTable.deletedAt,
237268
})
238-
.from(workflowFolder)
239-
.where(eq(workflowFolder.id, currentFolderId))
269+
.from(folderTable)
270+
.where(eq(folderTable.id, currentFolderId))
240271
.limit(1)
241272

242-
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
273+
if (
274+
!folder ||
275+
(isFirstIteration &&
276+
(folder.workspaceId !== targetWorkspaceId ||
277+
folder.resourceType !== 'workflow' ||
278+
folder.deletedAt))
279+
) {
243280
throw new Error('Target parent folder not found')
244281
}
245282
if (folder.id === sourceFolderId) {
@@ -250,6 +287,7 @@ async function assertTargetParentFolderMutable(
250287
}
251288

252289
currentFolderId = folder.parentId
290+
isFirstIteration = false
253291
}
254292
}
255293

@@ -260,16 +298,17 @@ async function deduplicateFolderName(
260298
requestedName: string
261299
): Promise<string> {
262300
const parentCondition = parentId
263-
? eq(workflowFolder.parentId, parentId)
264-
: isNull(workflowFolder.parentId)
301+
? eq(folderTable.parentId, parentId)
302+
: isNull(folderTable.parentId)
265303
const siblingRows = await tx
266-
.select({ name: workflowFolder.name })
267-
.from(workflowFolder)
304+
.select({ name: folderTable.name })
305+
.from(folderTable)
268306
.where(
269307
and(
270-
eq(workflowFolder.workspaceId, workspaceId),
308+
eq(folderTable.workspaceId, workspaceId),
309+
eq(folderTable.resourceType, 'workflow'),
271310
parentCondition,
272-
isNull(workflowFolder.archivedAt)
311+
isNull(folderTable.deletedAt)
273312
)
274313
)
275314
const siblingNames = new Set(siblingRows.map((row) => row.name))
@@ -296,21 +335,23 @@ async function duplicateFolderStructure(
296335
): Promise<void> {
297336
const childFolders = await tx
298337
.select()
299-
.from(workflowFolder)
338+
.from(folderTable)
300339
.where(
301340
and(
302-
eq(workflowFolder.parentId, sourceFolderId),
303-
eq(workflowFolder.workspaceId, sourceWorkspaceId),
304-
isNull(workflowFolder.archivedAt)
341+
eq(folderTable.parentId, sourceFolderId),
342+
eq(folderTable.workspaceId, sourceWorkspaceId),
343+
eq(folderTable.resourceType, 'workflow'),
344+
isNull(folderTable.deletedAt)
305345
)
306346
)
307347

308348
for (const childFolder of childFolders) {
309349
const newChildFolderId = generateId()
310350
folderMapping.set(childFolder.id, newChildFolderId)
311351

312-
await tx.insert(workflowFolder).values({
352+
await tx.insert(folderTable).values({
313353
id: newChildFolderId,
354+
resourceType: 'workflow',
314355
userId,
315356
workspaceId: targetWorkspaceId,
316357
name: childFolder.name,

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import { db } from '@sim/db'
2+
import { folder as folderTable } from '@sim/db/schema'
13
import { createLogger } from '@sim/logger'
24
import { getErrorMessage } from '@sim/utils/errors'
5+
import { and, eq } from 'drizzle-orm'
36
import { type NextRequest, NextResponse } from 'next/server'
47
import { restoreFolderContract } from '@/lib/api/contracts'
58
import { parseRequest } from '@/lib/api/server'
69
import { getSession } from '@/lib/auth'
710
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { performRestoreFolder } from '@/lib/folders/orchestration'
812
import { captureServerEvent } from '@/lib/posthog/server'
9-
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
1013
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1114

1215
const logger = createLogger('RestoreFolderAPI')
@@ -30,7 +33,18 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
3033
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
3134
}
3235

36+
const [existingFolder] = await db
37+
.select({ resourceType: folderTable.resourceType })
38+
.from(folderTable)
39+
.where(and(eq(folderTable.id, folderId), eq(folderTable.workspaceId, workspaceId)))
40+
.limit(1)
41+
42+
if (!existingFolder) {
43+
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
44+
}
45+
3346
const result = await performRestoreFolder({
47+
resourceType: existingFolder.resourceType,
3448
folderId,
3549
workspaceId,
3650
userId: session.user.id,

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
type MockUser,
1111
permissionsMock,
1212
permissionsMockFns,
13-
workflowsOrchestrationMock,
1413
workflowsOrchestrationMockFns,
1514
workflowsUtilsMock,
1615
workflowsUtilsMockFns,
@@ -50,7 +49,10 @@ vi.mock('@sim/db', () => ({
5049
return mockDbRef.current
5150
},
5251
}))
53-
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
52+
vi.mock('@/lib/folders/orchestration', () => ({
53+
performDeleteFolder: workflowsOrchestrationMockFns.mockPerformDeleteFolder,
54+
performUpdateFolder: workflowsOrchestrationMockFns.mockPerformUpdateFolder,
55+
}))
5456
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
5557

5658
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
@@ -70,6 +72,7 @@ const TEST_USER: MockUser = {
7072

7173
const mockFolder = {
7274
id: 'folder-1',
75+
resourceType: 'workflow',
7376
name: 'Test Folder',
7477
userId: TEST_USER.id,
7578
workspaceId: 'workspace-123',
@@ -386,6 +389,7 @@ describe('Individual Folder API Route', () => {
386389
mockDbRef.current = createFolderDbMock({
387390
folderLookupResult: {
388391
id: 'folder-3',
392+
resourceType: 'workflow',
389393
parentId: null,
390394
name: 'Folder 3',
391395
workspaceId: 'workspace-123',
@@ -432,6 +436,7 @@ describe('Individual Folder API Route', () => {
432436
expect(data).toHaveProperty('success', true)
433437
expect(data).toHaveProperty('deletedItems')
434438
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
439+
resourceType: 'workflow',
435440
folderId: 'folder-1',
436441
workspaceId: 'workspace-123',
437442
userId: TEST_USER.id,

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { db } from '@sim/db'
2-
import { workflowFolder } from '@sim/db/schema'
2+
import { folder as folderTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
4+
import { FolderLockedError } from '@sim/platform-authz/workflow'
55
import { eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { updateFolderContract } from '@/lib/api/contracts'
88
import { parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { performDeleteFolder, performUpdateFolder } from '@/lib/folders/orchestration'
12+
import { FOLDER_RESOURCE_POLICIES } from '@/lib/folders/policy'
1113
import { captureServerEvent } from '@/lib/posthog/server'
12-
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
1314
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1415

1516
const logger = createLogger('FoldersIDAPI')
@@ -43,8 +44,8 @@ export const PUT = withRouteHandler(
4344
// Verify the folder exists
4445
const existingFolder = await db
4546
.select()
46-
.from(workflowFolder)
47-
.where(eq(workflowFolder.id, id))
47+
.from(folderTable)
48+
.where(eq(folderTable.id, id))
4849
.then((rows) => rows[0])
4950

5051
if (!existingFolder) {
@@ -65,6 +66,14 @@ export const PUT = withRouteHandler(
6566
)
6667
}
6768

69+
const policy = FOLDER_RESOURCE_POLICIES[existingFolder.resourceType]
70+
71+
if (locked !== undefined && !policy.supportsLocking) {
72+
return NextResponse.json(
73+
{ error: `Folders of type "${existingFolder.resourceType}" do not support locking` },
74+
{ status: 400 }
75+
)
76+
}
6877
if (locked !== undefined && workspacePermission !== 'admin') {
6978
return NextResponse.json(
7079
{ error: 'Admin access required to lock folders' },
@@ -74,13 +83,14 @@ export const PUT = withRouteHandler(
7483

7584
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
7685
if (hasNonLockUpdate) {
77-
await assertFolderMutable(id)
86+
await policy.assertMutable(id)
7887
}
7988
if (parentId !== undefined) {
80-
await assertFolderMutable(parentId)
89+
await policy.assertMutable(parentId)
8190
}
8291

8392
const result = await performUpdateFolder({
93+
resourceType: existingFolder.resourceType,
8494
folderId: id,
8595
workspaceId: existingFolder.workspaceId,
8696
userId: session.user.id,
@@ -126,8 +136,8 @@ export const DELETE = withRouteHandler(
126136
// Verify the folder exists
127137
const existingFolder = await db
128138
.select()
129-
.from(workflowFolder)
130-
.where(eq(workflowFolder.id, id))
139+
.from(folderTable)
140+
.where(eq(folderTable.id, id))
131141
.then((rows) => rows[0])
132142

133143
if (!existingFolder) {
@@ -147,9 +157,10 @@ export const DELETE = withRouteHandler(
147157
)
148158
}
149159

150-
await assertFolderMutable(id)
160+
await FOLDER_RESOURCE_POLICIES[existingFolder.resourceType].assertMutable(id)
151161

152162
const result = await performDeleteFolder({
163+
resourceType: existingFolder.resourceType,
153164
folderId: id,
154165
workspaceId: existingFolder.workspaceId,
155166
userId: session.user.id,

0 commit comments

Comments
 (0)