Skip to content

Commit 9ab72ac

Browse files
committed
feat(folders): add resource pinning and generalize the folders contract
Adds per-user pinning for workflows, files, knowledge bases, and tables, and moves the folders API onto a generic, resource-typed contract ahead of the shared `folder` table cutover. - new `pinned_item` table (migration 0271) plus `/api/pinned-items` routes, React Query hooks, and a shared `PinButton` - pin toggle wired into Tables, Knowledge, and Files with pinned-first ordering across every sort column - new `folder` table (migration 0272) with an idempotent, collision-aware backfill from `workflow_folder` and `workspace_file_folders` - `folderSchema` gains `resourceType`, renames `archivedAt` to `deletedAt`, and drops the unused `color`/`isExpanded` fields
1 parent ec3156f commit 9ab72ac

37 files changed

Lines changed: 37677 additions & 101 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const POST = withRouteHandler(
3333
try {
3434
const parsed = await parseRequest(duplicateFolderContract, req, context)
3535
if (!parsed.success) return parsed.response
36-
const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body
36+
const { name, workspaceId, parentId, newId: clientNewId } = parsed.data.body
3737

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

@@ -106,7 +106,7 @@ export const POST = withRouteHandler(
106106
userId: session.user.id,
107107
workspaceId: targetWorkspaceId,
108108
name: deduplicatedName,
109-
color: color || sourceFolder.color,
109+
color: sourceFolder.color,
110110
parentId: targetParentId,
111111
sortOrder,
112112
isExpanded: false,

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

Lines changed: 4 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,7 @@ 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)
321323
})
322324

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

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const PUT = withRouteHandler(
3838
if (!parsed.success) return parsed.response
3939

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

4343
// Verify the folder exists
4444
const existingFolder = await db
@@ -85,8 +85,6 @@ export const PUT = withRouteHandler(
8585
workspaceId: existingFolder.workspaceId,
8686
userId: session.user.id,
8787
name,
88-
color,
89-
isExpanded,
9088
locked,
9189
parentId,
9290
sortOrder,

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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)