Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { DbOrTx } from '@/lib/db/types'
import { toFolderApi } from '@/lib/folders/queries'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

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

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

Expand Down Expand Up @@ -106,7 +107,7 @@ export const POST = withRouteHandler(
userId: session.user.id,
workspaceId: targetWorkspaceId,
name: deduplicatedName,
color: color || sourceFolder.color,
color: sourceFolder.color,
parentId: targetParentId,
sortOrder,
isExpanded: false,
Expand Down Expand Up @@ -172,7 +173,7 @@ export const POST = withRouteHandler(
.where(eq(workflowFolder.id, newFolderId))
.then((rows) => rows[0])

return NextResponse.json({ folder: duplicatedFolder }, { status: 201 })
return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 })
} catch (error) {
if (error instanceof Error) {
if (error instanceof FolderLockedError) {
Expand Down
22 changes: 20 additions & 2 deletions apps/sim/app/api/folders/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,9 @@ describe('Individual Folder API Route', () => {
})

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

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

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

expect(response.status).toBe(200)
expect(response.status).toBe(400)
})

it('rejects a whitespace-only folder name', async () => {
// The write path trims before persisting, so validating the raw string would let
// ' ' through and store an empty name — the same failure the '' case closes.
mockAuthenticatedUser()

queueFolderLookup()
const req = createMockRequest('PUT', {
name: ' ',
})
const params = Promise.resolve({ id: 'folder-1' })

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

expect(response.status).toBe(400)
})

it('should handle invalid JSON payload', async () => {
Expand Down
7 changes: 3 additions & 4 deletions apps/sim/app/api/folders/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { updateFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { toFolderApi } from '@/lib/folders/queries'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
Expand Down Expand Up @@ -38,7 +39,7 @@ export const PUT = withRouteHandler(
if (!parsed.success) return parsed.response

const { id } = parsed.data.params
const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body
const { name, locked, parentId, sortOrder } = parsed.data.body

// Verify the folder exists
const existingFolder = await db
Expand Down Expand Up @@ -85,8 +86,6 @@ export const PUT = withRouteHandler(
workspaceId: existingFolder.workspaceId,
userId: session.user.id,
name,
color,
isExpanded,
locked,
parentId,
sortOrder,
Expand All @@ -100,7 +99,7 @@ export const PUT = withRouteHandler(

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

return NextResponse.json({ folder: result.folder })
return NextResponse.json({ folder: toFolderApi(result.folder) })
} catch (error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
Expand Down
10 changes: 4 additions & 6 deletions apps/sim/app/api/folders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createFolderContract, listFoldersContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listFoldersForWorkspace } from '@/lib/folders/queries'
import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries'
import { captureServerEvent } from '@/lib/posthog/server'
import { performCreateFolder } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
Expand All @@ -29,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {

const parsed = await parseRequest(listFoldersContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId, scope } = parsed.data.query
const { workspaceId, resourceType, scope } = parsed.data.query

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

const folders = await listFoldersForWorkspace(workspaceId, scope)
const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType)

return NextResponse.json({ folders })
} catch (error) {
Expand All @@ -66,7 +66,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
name,
workspaceId,
parentId,
color,
Comment thread
waleedlatif1 marked this conversation as resolved.
sortOrder: providedSortOrder,
} = parsed.data.body

Expand All @@ -91,7 +90,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
workspaceId,
name,
parentId,
color,
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
sortOrder: providedSortOrder,
})

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

return NextResponse.json({ folder: newFolder })
return NextResponse.json({ folder: toFolderApi(newFolder) })
} catch (error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Tests for the unpin API route.
*
* @vitest-environment node
*/
import { authMockFns, createMockRequest, schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockLogger, mockDb } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
},
mockDb: { delete: vi.fn() },
}))

vi.mock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
}))
vi.mock('@sim/db', () => ({ db: mockDb, ...schemaMock }))

import { DELETE } from '@/app/api/pinned-items/[resourceType]/[resourceId]/route'

const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test User' }

function routeContext(resourceType: string, resourceId: string) {
return { params: Promise.resolve({ resourceType, resourceId }) }
}

describe('Unpin API', () => {
const mockWhere = vi.fn()
const mockReturning = vi.fn()

beforeEach(() => {
vi.clearAllMocks()

mockDb.delete.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ returning: mockReturning })
mockReturning.mockReturnValue([{ id: 'pinned-1' }])

authMockFns.mockGetSession.mockResolvedValue({ user: mockUser })
})

it('unpins a resource', async () => {
const response = await DELETE(
createMockRequest('DELETE'),
routeContext('workflow', 'workflow-1')
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ success: true })
expect(mockDb.delete).toHaveBeenCalled()
})

it('returns 404 when no matching pin exists', async () => {
mockReturning.mockReturnValue([])

const response = await DELETE(
createMockRequest('DELETE'),
routeContext('workflow', 'workflow-1')
)

expect(response.status).toBe(404)
})

it('rejects an unknown resourceType at the contract boundary', async () => {
const response = await DELETE(createMockRequest('DELETE'), routeContext('nope', 'resource-1'))

expect(response.status).toBe(400)
expect(mockDb.delete).not.toHaveBeenCalled()
})

it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

const response = await DELETE(
createMockRequest('DELETE'),
routeContext('workflow', 'workflow-1')
)

expect(response.status).toBe(401)
expect(mockDb.delete).not.toHaveBeenCalled()
})
})
51 changes: 51 additions & 0 deletions apps/sim/app/api/pinned-items/[resourceType]/[resourceId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { db, pinnedItem } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deletePinnedItemContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('PinnedItemDeleteAPI')

interface RouteContext {
params: Promise<{ resourceType: string; resourceId: string }>
}

/**
* Unpins a resource, addressed by its composite key rather than the pin's own id so
* callers can unpin from a resource row without first looking the pin up.
*
* No workspace permission check is needed: the delete is scoped to the session
* user's own pins, so a caller can only ever remove a row they created.
*/
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(deletePinnedItemContract, request, context)
if (!parsed.success) return parsed.response
const { resourceType, resourceId } = parsed.data.params

const deleted = await db
.delete(pinnedItem)
.where(
and(
eq(pinnedItem.userId, session.user.id),
eq(pinnedItem.resourceType, resourceType),
eq(pinnedItem.resourceId, resourceId)
)
)
.returning({ id: pinnedItem.id })

if (deleted.length === 0) {
return NextResponse.json({ error: 'Pinned item not found' }, { status: 404 })
}

logger.info('Unpinned resource', { resourceType, resourceId })

return NextResponse.json({ success: true })
})
Loading
Loading