Skip to content

Commit cb3611b

Browse files
authored
feat(folders): add resource pinning and generalize the folders contract (#6014)
- Adds per-user pinning for workflows, files, knowledge bases, and tables: new `pinned_item` table, `/api/pinned-items` routes, React Query hooks, and a shared `PinButton` wired into Tables, Knowledge, and Files with pinned-first ordering - Adds the generic `folder` table with an idempotent, replay-safe, collision-aware backfill from `workflow_folder` and `workspace_file_folders`; no cutover yet, folder reads still go through a documented legacy adapter - Moves `folderSchema` to the generic vocabulary (`resourceType`, `deletedAt`) and drops the unused `color`/`isExpanded`
1 parent b69fdbd commit cb3611b

35 files changed

Lines changed: 19957 additions & 108 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth'
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import type { DbOrTx } from '@/lib/db/types'
15+
import { toFolderApi } from '@/lib/folders/queries'
1516
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1617
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1718

@@ -33,7 +34,7 @@ export const POST = withRouteHandler(
3334
try {
3435
const parsed = await parseRequest(duplicateFolderContract, req, context)
3536
if (!parsed.success) return parsed.response
36-
const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body
37+
const { name, workspaceId, parentId, newId: clientNewId } = parsed.data.body
3738

3839
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
3940

@@ -106,7 +107,7 @@ export const POST = withRouteHandler(
106107
userId: session.user.id,
107108
workspaceId: targetWorkspaceId,
108109
name: deduplicatedName,
109-
color: color || sourceFolder.color,
110+
color: sourceFolder.color,
110111
parentId: targetParentId,
111112
sortOrder,
112113
isExpanded: false,
@@ -172,7 +173,7 @@ export const POST = withRouteHandler(
172173
.where(eq(workflowFolder.id, newFolderId))
173174
.then((rows) => rows[0])
174175

175-
return NextResponse.json({ folder: duplicatedFolder }, { status: 201 })
176+
return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 })
176177
} catch (error) {
177178
if (error instanceof Error) {
178179
if (error instanceof FolderLockedError) {

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,9 @@ describe('Individual Folder API Route', () => {
306306
})
307307

308308
describe('Input Validation', () => {
309-
it('should handle empty folder name', async () => {
309+
it('rejects an empty folder name', async () => {
310+
// The contract bounds `name` to 1-255 chars: renaming a folder to '' previously
311+
// slipped through as a no-op 200, which silently discarded the user's rename.
310312
mockAuthenticatedUser()
311313

312314
queueFolderLookup()
@@ -317,7 +319,23 @@ describe('Individual Folder API Route', () => {
317319

318320
const response = await PUT(req, { params })
319321

320-
expect(response.status).toBe(200)
322+
expect(response.status).toBe(400)
323+
})
324+
325+
it('rejects a whitespace-only folder name', async () => {
326+
// The write path trims before persisting, so validating the raw string would let
327+
// ' ' through and store an empty name — the same failure the '' case closes.
328+
mockAuthenticatedUser()
329+
330+
queueFolderLookup()
331+
const req = createMockRequest('PUT', {
332+
name: ' ',
333+
})
334+
const params = Promise.resolve({ id: 'folder-1' })
335+
336+
const response = await PUT(req, { params })
337+
338+
expect(response.status).toBe(400)
321339
})
322340

323341
it('should handle invalid JSON payload', async () => {

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ 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 { toFolderApi } from '@/lib/folders/queries'
1112
import { captureServerEvent } from '@/lib/posthog/server'
1213
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
1314
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -38,7 +39,7 @@ export const PUT = withRouteHandler(
3839
if (!parsed.success) return parsed.response
3940

4041
const { id } = parsed.data.params
41-
const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body
42+
const { name, locked, parentId, sortOrder } = parsed.data.body
4243

4344
// Verify the folder exists
4445
const existingFolder = await db
@@ -85,8 +86,6 @@ export const PUT = withRouteHandler(
8586
workspaceId: existingFolder.workspaceId,
8687
userId: session.user.id,
8788
name,
88-
color,
89-
isExpanded,
9089
locked,
9190
parentId,
9291
sortOrder,
@@ -100,7 +99,7 @@ export const PUT = withRouteHandler(
10099

101100
logger.info('Updated folder:', { id, updates: parsed.data.body })
102101

103-
return NextResponse.json({ folder: result.folder })
102+
return NextResponse.json({ folder: toFolderApi(result.folder) })
104103
} catch (error) {
105104
if (error instanceof FolderLockedError) {
106105
return NextResponse.json({ error: error.message }, { status: error.status })

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createFolderContract, listFoldersContract } 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 { listFoldersForWorkspace } from '@/lib/folders/queries'
8+
import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries'
99
import { captureServerEvent } from '@/lib/posthog/server'
1010
import { performCreateFolder } from '@/lib/workflows/orchestration'
1111
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -29,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2929

3030
const parsed = await parseRequest(listFoldersContract, request, {})
3131
if (!parsed.success) return parsed.response
32-
const { workspaceId, scope } = parsed.data.query
32+
const { workspaceId, resourceType, scope } = parsed.data.query
3333

3434
// Check if user has workspace permissions
3535
const workspacePermission = await getUserEntityPermissions(
@@ -42,7 +42,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4242
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
4343
}
4444

45-
const folders = await listFoldersForWorkspace(workspaceId, scope)
45+
const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType)
4646

4747
return NextResponse.json({ folders })
4848
} catch (error) {
@@ -66,7 +66,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6666
name,
6767
workspaceId,
6868
parentId,
69-
color,
7069
sortOrder: providedSortOrder,
7170
} = parsed.data.body
7271

@@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9190
workspaceId,
9291
name,
9392
parentId,
94-
color,
9593
sortOrder: providedSortOrder,
9694
})
9795

@@ -113,7 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
113111
{ groups: { workspace: workspaceId } }
114112
)
115113

116-
return NextResponse.json({ folder: newFolder })
114+
return NextResponse.json({ folder: toFolderApi(newFolder) })
117115
} catch (error) {
118116
if (error instanceof FolderLockedError) {
119117
return NextResponse.json({ error: error.message }, { status: error.status })
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Tests for the unpin API route.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { authMockFns, createMockRequest, schemaMock } from '@sim/testing'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockLogger, mockDb } = vi.hoisted(() => ({
10+
mockLogger: {
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
debug: vi.fn(),
15+
trace: vi.fn(),
16+
fatal: vi.fn(),
17+
child: vi.fn(),
18+
},
19+
mockDb: { delete: vi.fn() },
20+
}))
21+
22+
vi.mock('@sim/logger', () => ({
23+
createLogger: vi.fn().mockReturnValue(mockLogger),
24+
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
25+
getRequestContext: () => undefined,
26+
}))
27+
vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock }))
28+
29+
import { DELETE } from '@/app/api/pinned-items/[resourceType]/[resourceId]/route'
30+
31+
const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test User' }
32+
33+
function routeContext(resourceType: string, resourceId: string) {
34+
return { params: Promise.resolve({ resourceType, resourceId }) }
35+
}
36+
37+
describe('Unpin API', () => {
38+
const mockWhere = vi.fn()
39+
const mockReturning = vi.fn()
40+
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
44+
mockDb.delete.mockReturnValue({ where: mockWhere })
45+
mockWhere.mockReturnValue({ returning: mockReturning })
46+
mockReturning.mockReturnValue([{ id: 'pinned-1' }])
47+
48+
authMockFns.mockGetSession.mockResolvedValue({ user: mockUser })
49+
})
50+
51+
it('unpins a resource', async () => {
52+
const response = await DELETE(
53+
createMockRequest('DELETE'),
54+
routeContext('workflow', 'workflow-1')
55+
)
56+
57+
expect(response.status).toBe(200)
58+
await expect(response.json()).resolves.toEqual({ success: true })
59+
expect(mockDb.delete).toHaveBeenCalled()
60+
})
61+
62+
it('returns 404 when no matching pin exists', async () => {
63+
mockReturning.mockReturnValue([])
64+
65+
const response = await DELETE(
66+
createMockRequest('DELETE'),
67+
routeContext('workflow', 'workflow-1')
68+
)
69+
70+
expect(response.status).toBe(404)
71+
})
72+
73+
it('rejects an unknown resourceType at the contract boundary', async () => {
74+
const response = await DELETE(createMockRequest('DELETE'), routeContext('nope', 'resource-1'))
75+
76+
expect(response.status).toBe(400)
77+
expect(mockDb.delete).not.toHaveBeenCalled()
78+
})
79+
80+
it('returns 401 when unauthenticated', async () => {
81+
authMockFns.mockGetSession.mockResolvedValue(null)
82+
83+
const response = await DELETE(
84+
createMockRequest('DELETE'),
85+
routeContext('workflow', 'workflow-1')
86+
)
87+
88+
expect(response.status).toBe(401)
89+
expect(mockDb.delete).not.toHaveBeenCalled()
90+
})
91+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { db, pinnedItem } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { and, eq } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { deletePinnedItemContract } from '@/lib/api/contracts'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { getSession } from '@/lib/auth'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('PinnedItemDeleteAPI')
11+
12+
interface RouteContext {
13+
params: Promise<{ resourceType: string; resourceId: string }>
14+
}
15+
16+
/**
17+
* Unpins a resource, addressed by its composite key rather than the pin's own id so
18+
* callers can unpin from a resource row without first looking the pin up.
19+
*
20+
* No workspace permission check is needed: the delete is scoped to the session
21+
* user's own pins, so a caller can only ever remove a row they created.
22+
*/
23+
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
24+
const session = await getSession()
25+
if (!session?.user?.id) {
26+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const parsed = await parseRequest(deletePinnedItemContract, request, context)
30+
if (!parsed.success) return parsed.response
31+
const { resourceType, resourceId } = parsed.data.params
32+
33+
const deleted = await db
34+
.delete(pinnedItem)
35+
.where(
36+
and(
37+
eq(pinnedItem.userId, session.user.id),
38+
eq(pinnedItem.resourceType, resourceType),
39+
eq(pinnedItem.resourceId, resourceId)
40+
)
41+
)
42+
.returning({ id: pinnedItem.id })
43+
44+
if (deleted.length === 0) {
45+
return NextResponse.json({ error: 'Pinned item not found' }, { status: 404 })
46+
}
47+
48+
logger.info('Unpinned resource', { resourceType, resourceId })
49+
50+
return NextResponse.json({ success: true })
51+
})

0 commit comments

Comments
 (0)