Skip to content

Commit 31da1eb

Browse files
committed
feat(folders): cut file folders over, and give knowledge bases and tables folders
Builds on the generic resourceType-driven folder engine to finish the migration and extend it to two more resource trees. File-folder cutover - Repoints all 30 `workspace_file_folders` query sites onto `folder` scoped to `resourceType = 'file'`, id-keyed lookups included — a caller can hand a workflow folder's id to a file-folder endpoint, and only the predicate stops it - Adds the missing restore-conflict handling: a file folder whose name was taken while it was archived now deduplicates against the RESOLVED parent (restore re-roots when the original parent is still archived) instead of being permanently unrestorable - The forking mapper's `resourceType` predicate lives in the `leftJoin` condition, not the WHERE, where it would silently make the outer join inner - `servedFolderResourceTypeSchema` gains `file`; the soft-delete sweep enumerates all four types rather than dropping its predicate Knowledge Base and Tables folders - `knowledge_base.folder_id` and `user_table_definitions.folder_id` are now written and served; both create/move paths admit a folder only through `findActiveFolder`, scoped to workspace AND resourceType - Restoring a knowledge base whose folder is still archived re-roots it rather than filing it somewhere the page never renders; a workspace move re-roots for the same reason - Both pages get folder rows, breadcrumbs, inline rename on rows and on the breadcrumb, move-to submenus, pin/unpin, and drag-a-row-onto-a-folder Shared folder UI - One `components/folders/` module now backs Knowledge, Tables, and Files: `folderBreadcrumbItems` (data for `Resource.Header`, which owns the crumb chrome), `folderRow`, `folderRowId`/`parseFolderedRowId`, `buildMoveOptions` / `buildDescendantIndex` / `renderMoveOptions`, `nextUntitledFolderName`, `FolderContextMenu`, `useFolderNavigation`, `useFolderRowDragDrop` - Deletes the per-page copies: `files/move-options.tsx` and the Tables-local folder context menu Recently Deleted - Restores folders of every type, not just workflow folders, driven by one declarative table rather than a branch per resource Folder pins resolve against `folder` unscoped: file, knowledge-base, and table folders are all pinnable and all live there, and a folder id addresses exactly one row.
1 parent 5b7cf99 commit 31da1eb

56 files changed

Lines changed: 3490 additions & 380 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/knowledge/[id]/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
deleteKnowledgeBase,
1212
getKnowledgeBaseById,
1313
KnowledgeBaseConflictError,
14+
KnowledgeBaseFolderError,
1415
KnowledgeBasePermissionError,
1516
updateKnowledgeBase,
1617
} from '@/lib/knowledge/service'
@@ -100,6 +101,7 @@ export const PUT = withRouteHandler(
100101
name: validatedData.name,
101102
description: validatedData.description,
102103
workspaceId: validatedData.workspaceId,
104+
folderId: validatedData.folderId,
103105
chunkingConfig: validatedData.chunkingConfig,
104106
},
105107
requestId,
@@ -143,6 +145,9 @@ export const PUT = withRouteHandler(
143145
if (error instanceof KnowledgeBaseConflictError) {
144146
return NextResponse.json({ error: error.message }, { status: 409 })
145147
}
148+
if (error instanceof KnowledgeBaseFolderError) {
149+
return NextResponse.json({ error: error.message }, { status: 400 })
150+
}
146151
if (error instanceof KnowledgeBasePermissionError) {
147152
logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`)
148153
return NextResponse.json({ error: error.message }, { status: 403 })

apps/sim/app/api/knowledge/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
createKnowledgeBase,
1616
getKnowledgeBases,
1717
KnowledgeBaseConflictError,
18+
KnowledgeBaseFolderError,
1819
KnowledgeBasePermissionError,
1920
type KnowledgeBaseScope,
2021
} from '@/lib/knowledge/service'
@@ -160,6 +161,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
160161
if (createError instanceof KnowledgeBaseConflictError) {
161162
return NextResponse.json({ error: createError.message }, { status: 409 })
162163
}
164+
if (createError instanceof KnowledgeBaseFolderError) {
165+
return NextResponse.json({ error: createError.message }, { status: 400 })
166+
}
163167
if (createError instanceof KnowledgeBasePermissionError) {
164168
logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`)
165169
return NextResponse.json({ error: createError.message }, { status: 403 })
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hybridAuthMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const {
9+
mockCheckAccess,
10+
mockDeleteTable,
11+
mockGetTableById,
12+
mockMoveTableToFolder,
13+
mockRenameTable,
14+
mockUpdateTableLocks,
15+
mockFindActiveFolder,
16+
mockGetLimits,
17+
} = vi.hoisted(() => ({
18+
mockCheckAccess: vi.fn(),
19+
mockDeleteTable: vi.fn(),
20+
mockGetTableById: vi.fn(),
21+
mockMoveTableToFolder: vi.fn(),
22+
mockRenameTable: vi.fn(),
23+
mockUpdateTableLocks: vi.fn(),
24+
mockFindActiveFolder: vi.fn(),
25+
mockGetLimits: vi.fn(),
26+
}))
27+
28+
vi.mock('@/lib/table', () => ({
29+
deleteTable: mockDeleteTable,
30+
getTableById: mockGetTableById,
31+
moveTableToFolder: mockMoveTableToFolder,
32+
renameTable: mockRenameTable,
33+
updateTableLocks: mockUpdateTableLocks,
34+
TableConflictError: class extends Error {},
35+
}))
36+
vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits }))
37+
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
38+
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() }))
39+
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
40+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
41+
getWorkspaceWithOwner: vi.fn(),
42+
getUserEntityPermissions: vi.fn(),
43+
}))
44+
vi.mock('@/app/api/table/utils', () => ({
45+
accessError: () => new Response('denied', { status: 403 }),
46+
checkAccess: mockCheckAccess,
47+
normalizeColumn: (column: unknown) => column,
48+
tableLockErrorResponse: () => null,
49+
}))
50+
51+
import { PATCH } from '@/app/api/table/[tableId]/route'
52+
53+
const TABLE = {
54+
id: 'tbl_1',
55+
name: 'people',
56+
workspaceId: 'workspace-1',
57+
folderId: null as string | null,
58+
schema: { columns: [] },
59+
locks: {
60+
schemaLocked: false,
61+
insertLocked: false,
62+
updateLocked: false,
63+
deleteLocked: false,
64+
},
65+
}
66+
67+
function patchRequest(body: unknown): NextRequest {
68+
return new NextRequest('http://localhost:3000/api/table/tbl_1', {
69+
method: 'PATCH',
70+
headers: { 'content-type': 'application/json' },
71+
body: JSON.stringify(body),
72+
})
73+
}
74+
75+
const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) }
76+
77+
describe('PATCH /api/table/[tableId] folder moves', () => {
78+
beforeEach(() => {
79+
vi.clearAllMocks()
80+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
81+
success: true,
82+
userId: 'user-1',
83+
authType: 'session',
84+
})
85+
mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE })
86+
mockGetTableById.mockResolvedValue({ ...TABLE, folderId: 'folder-1' })
87+
mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' })
88+
})
89+
90+
it('moves the table into a folder in the same workspace and tree', async () => {
91+
const response = await PATCH(
92+
patchRequest({ workspaceId: 'workspace-1', folderId: 'folder-1' }),
93+
routeContext
94+
)
95+
96+
expect(response.status).toBe(200)
97+
expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table')
98+
expect(mockMoveTableToFolder).toHaveBeenCalledWith(
99+
'tbl_1',
100+
'folder-1',
101+
expect.any(String),
102+
'user-1'
103+
)
104+
})
105+
106+
it('moves the table to the workspace root on an explicit null, with no folder lookup', async () => {
107+
mockGetTableById.mockResolvedValue({ ...TABLE, folderId: null })
108+
109+
const response = await PATCH(
110+
patchRequest({ workspaceId: 'workspace-1', folderId: null }),
111+
routeContext
112+
)
113+
114+
expect(response.status).toBe(200)
115+
expect(mockFindActiveFolder).not.toHaveBeenCalled()
116+
expect(mockMoveTableToFolder).toHaveBeenCalledWith('tbl_1', null, expect.any(String), 'user-1')
117+
})
118+
119+
it('leaves placement untouched when folderId is omitted', async () => {
120+
await PATCH(patchRequest({ workspaceId: 'workspace-1', name: 'renamed' }), routeContext)
121+
122+
expect(mockRenameTable).toHaveBeenCalled()
123+
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
124+
})
125+
126+
it('rejects a folder from another workspace or resource tree without writing', async () => {
127+
mockFindActiveFolder.mockResolvedValue(null)
128+
129+
const response = await PATCH(
130+
patchRequest({ workspaceId: 'workspace-1', folderId: 'kb-folder' }),
131+
routeContext
132+
)
133+
134+
expect(response.status).toBe(404)
135+
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
136+
})
137+
138+
it('rejects a body with no name, folder, or lock changes', async () => {
139+
const response = await PATCH(patchRequest({ workspaceId: 'workspace-1' }), routeContext)
140+
141+
expect(response.status).toBe(400)
142+
expect(mockMoveTableToFolder).not.toHaveBeenCalled()
143+
expect(mockRenameTable).not.toHaveBeenCalled()
144+
})
145+
})

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
77
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { findActiveFolder } from '@/lib/folders/queries'
1011
import { captureServerEvent } from '@/lib/posthog/server'
1112
import {
1213
deleteTable,
1314
getTableById,
15+
moveTableToFolder,
1416
renameTable,
1517
TableConflictError,
1618
type TableSchema,
@@ -80,6 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
8082
metadata: table.metadata ?? null,
8183
rowCount: table.rowCount,
8284
maxRows: maxRowsPerTable,
85+
folderId: table.folderId ?? null,
8386
locks: table.locks,
8487
createdAt:
8588
table.createdAt instanceof Date
@@ -184,6 +187,18 @@ export const PATCH = withRouteHandler(
184187
await renameTable(tableId, validated.name, requestId, authResult.userId)
185188
}
186189

190+
if (validated.folderId !== undefined) {
191+
// Scoped to `resourceType: 'table'` so a folder id from another resource's
192+
// tree can't be used to file the table somewhere Tables never lists.
193+
if (
194+
validated.folderId !== null &&
195+
!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))
196+
) {
197+
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
198+
}
199+
await moveTableToFolder(tableId, validated.folderId, requestId, authResult.userId)
200+
}
201+
187202
// Re-read so the response reflects both a rename and a lock change.
188203
const updated = await getTableById(tableId)
189204
if (!updated) {

apps/sim/app/api/table/import-async/route.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ const {
1111
mockListTables,
1212
mockRunTableImport,
1313
mockRunDetached,
14+
mockFindActiveFolder,
1415
MockTableConflictError,
1516
} = vi.hoisted(() => ({
1617
mockCreateTable: vi.fn(),
1718
mockGetLimits: vi.fn(),
1819
mockListTables: vi.fn(),
1920
mockRunTableImport: vi.fn(),
2021
mockRunDetached: vi.fn(),
22+
mockFindActiveFolder: vi.fn(),
2123
MockTableConflictError: class extends Error {
2224
readonly code = 'TABLE_EXISTS' as const
2325
},
@@ -37,6 +39,7 @@ vi.mock('@/lib/table', () => ({
3739
TableConflictError: MockTableConflictError,
3840
}))
3941
vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport }))
42+
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
4043
vi.mock('@/lib/core/utils/background', () => ({
4144
runDetached: mockRunDetached.mockImplementation(
4245
(_label: string, work: () => Promise<unknown>) => {
@@ -75,6 +78,36 @@ describe('POST /api/table/import-async', () => {
7578
mockListTables.mockResolvedValue([])
7679
mockCreateTable.mockResolvedValue({ id: 'tbl_async', name: 'data' })
7780
mockRunTableImport.mockResolvedValue(undefined)
81+
mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' })
82+
})
83+
84+
it('imports into the workspace root when no folder is given', async () => {
85+
await POST(makeRequest(validBody))
86+
87+
expect(mockFindActiveFolder).not.toHaveBeenCalled()
88+
expect(mockCreateTable).toHaveBeenCalledWith(
89+
expect.objectContaining({ folderId: undefined }),
90+
expect.any(String)
91+
)
92+
})
93+
94+
it('creates the imported table inside the requested folder', async () => {
95+
await POST(makeRequest({ ...validBody, folderId: 'folder-1' }))
96+
97+
expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table')
98+
expect(mockCreateTable).toHaveBeenCalledWith(
99+
expect.objectContaining({ folderId: 'folder-1' }),
100+
expect.any(String)
101+
)
102+
})
103+
104+
it('rejects a folder from another workspace or resource tree', async () => {
105+
mockFindActiveFolder.mockResolvedValue(null)
106+
107+
const response = await POST(makeRequest({ ...validBody, folderId: 'kb-folder' }))
108+
109+
expect(response.status).toBe(404)
110+
expect(mockCreateTable).not.toHaveBeenCalled()
78111
})
79112

80113
it('creates an importing table and kicks off the background import', async () => {

apps/sim/app/api/table/import-async/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
88
import { runDetached } from '@/lib/core/utils/background'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { findActiveFolder } from '@/lib/folders/queries'
1112
import { captureServerEvent } from '@/lib/posthog/server'
1213
import {
1314
createTable,
@@ -39,7 +40,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3940

4041
const parsed = await parseRequest(importTableAsyncContract, request, {})
4142
if (!parsed.success) return parsed.response
42-
const { workspaceId, fileKey, fileName, deleteSourceFile, timezone } = parsed.data.body
43+
const { workspaceId, fileKey, fileName, folderId, deleteSourceFile, timezone } = parsed.data.body
4344

4445
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
4546
if (permission !== 'write' && permission !== 'admin') {
@@ -51,6 +52,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5152
return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 })
5253
}
5354

55+
// Scoped to `resourceType: 'table'` so a folder from another resource's tree
56+
// can't file the imported table where Tables never lists it.
57+
if (folderId && !(await findActiveFolder(folderId, workspaceId, 'table'))) {
58+
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
59+
}
60+
5461
const ext = fileName.split('.').pop()?.toLowerCase()
5562
if (ext !== 'csv' && ext !== 'tsv') {
5663
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
@@ -84,6 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8491
description: `Imported from ${fileName}`,
8592
schema: { columns: [{ name: 'column_1', type: 'string' }] },
8693
workspaceId,
94+
folderId,
8795
userId,
8896
maxTables: planLimits.maxTables,
8997
jobStatus: 'running',

apps/sim/app/api/table/import-csv/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1010
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
1111
import { generateRequestId } from '@/lib/core/utils/request'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import { findActiveFolder } from '@/lib/folders/queries'
1314
import {
1415
batchInsertRows,
1516
CSV_MAX_BATCH_SIZE,
@@ -88,6 +89,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8889
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
8990
}
9091

92+
let folderId: string | null = null
93+
if (fields.folderId) {
94+
const folderIdResult = csvImportFormSchema.shape.folderId.safeParse(fields.folderId)
95+
if (!folderIdResult.success) {
96+
return NextResponse.json(
97+
{ error: getValidationErrorMessage(folderIdResult.error) },
98+
{ status: 400 }
99+
)
100+
}
101+
// Scoped to `resourceType: 'table'` so a folder from another resource's tree
102+
// can't file the imported table where Tables never lists it.
103+
if (!(await findActiveFolder(folderIdResult.data as string, workspaceId, 'table'))) {
104+
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
105+
}
106+
folderId = folderIdResult.data as string
107+
}
108+
91109
let timezone = (await getUserSettings(userId)).timezone ?? 'UTC'
92110
if (fields.timezone) {
93111
const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone)
@@ -161,6 +179,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
161179
description: `Imported from ${file.filename}`,
162180
schema,
163181
workspaceId,
182+
folderId,
164183
userId,
165184
maxTables: planLimits.maxTables,
166185
},

0 commit comments

Comments
 (0)