Skip to content
97 changes: 39 additions & 58 deletions apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { folder as folderTable, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { FolderLockedError } from '@sim/platform-authz/workflow'
import { generateId } from '@sim/utils/id'
Expand All @@ -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 { deduplicateFolderName } from '@/lib/folders/naming'
import { toFolderApi } from '@/lib/folders/queries'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
Expand Down Expand Up @@ -40,8 +41,14 @@ export const POST = withRouteHandler(

const sourceFolder = await db
.select()
.from(workflowFolder)
.where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt)))
.from(folderTable)
.where(
and(
eq(folderTable.id, sourceFolderId),
isNull(folderTable.deletedAt),
eq(folderTable.resourceType, 'workflow')
)
)
.then((rows) => rows[0])

if (!sourceFolder) {
Expand Down Expand Up @@ -70,17 +77,23 @@ export const POST = withRouteHandler(
await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId)

const folderParentCondition = targetParentId
? eq(workflowFolder.parentId, targetParentId)
: isNull(workflowFolder.parentId)
? eq(folderTable.parentId, targetParentId)
: isNull(folderTable.parentId)
const workflowParentCondition = targetParentId
? eq(workflow.folderId, targetParentId)
: isNull(workflow.folderId)

const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
.select({ minSortOrder: min(folderTable.sortOrder) })
.from(folderTable)
.where(
and(
eq(folderTable.workspaceId, targetWorkspaceId),
eq(folderTable.resourceType, 'workflow'),
folderParentCondition
)
),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
Expand All @@ -102,15 +115,14 @@ export const POST = withRouteHandler(
name
)

await tx.insert(workflowFolder).values({
await tx.insert(folderTable).values({
id: newFolderId,
resourceType: 'workflow',
userId: session.user.id,
workspaceId: targetWorkspaceId,
name: deduplicatedName,
color: sourceFolder.color,
parentId: targetParentId,
sortOrder,
isExpanded: false,
locked: false,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -169,8 +181,8 @@ export const POST = withRouteHandler(

const duplicatedFolder = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.id, newFolderId))
.from(folderTable)
.where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow')))
.then((rows) => rows[0])

return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 })
Expand Down Expand Up @@ -230,14 +242,14 @@ async function assertTargetParentFolderMutable(
visited.add(currentFolderId)
const [folder] = await tx
.select({
id: workflowFolder.id,
parentId: workflowFolder.parentId,
workspaceId: workflowFolder.workspaceId,
locked: workflowFolder.locked,
archivedAt: workflowFolder.archivedAt,
id: folderTable.id,
parentId: folderTable.parentId,
workspaceId: folderTable.workspaceId,
locked: folderTable.locked,
archivedAt: folderTable.deletedAt,
})
.from(workflowFolder)
.where(eq(workflowFolder.id, currentFolderId))
.from(folderTable)
.where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow')))
.limit(1)

if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
Expand All @@ -254,37 +266,6 @@ async function assertTargetParentFolderMutable(
}
}

async function deduplicateFolderName(
tx: DbOrTx,
workspaceId: string,
parentId: string | null,
requestedName: string
): Promise<string> {
const parentCondition = parentId
? eq(workflowFolder.parentId, parentId)
: isNull(workflowFolder.parentId)
const siblingRows = await tx
.select({ name: workflowFolder.name })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentCondition,
isNull(workflowFolder.archivedAt)
)
)
const siblingNames = new Set(siblingRows.map((row) => row.name))
if (!siblingNames.has(requestedName)) return requestedName

let suffix = 1
let candidate = `${requestedName} (${suffix})`
while (siblingNames.has(candidate)) {
suffix += 1
candidate = `${requestedName} (${suffix})`
}
return candidate
}

async function duplicateFolderStructure(
tx: DbOrTx,
sourceFolderId: string,
Expand All @@ -297,28 +278,28 @@ async function duplicateFolderStructure(
): Promise<void> {
const childFolders = await tx
.select()
.from(workflowFolder)
.from(folderTable)
.where(
and(
eq(workflowFolder.parentId, sourceFolderId),
eq(workflowFolder.workspaceId, sourceWorkspaceId),
isNull(workflowFolder.archivedAt)
eq(folderTable.parentId, sourceFolderId),
eq(folderTable.workspaceId, sourceWorkspaceId),
eq(folderTable.resourceType, 'workflow'),
isNull(folderTable.deletedAt)
)
)

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

await tx.insert(workflowFolder).values({
await tx.insert(folderTable).values({
id: newChildFolderId,
resourceType: 'workflow',
userId,
workspaceId: targetWorkspaceId,
name: childFolder.name,
color: childFolder.color,
parentId: newParentFolderId,
sortOrder: childFolder.sortOrder,
isExpanded: false,
locked: false,
createdAt: timestamp,
updatedAt: timestamp,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/folders/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const mockFolder = {

/** Queues the folder-existence lookup the route runs before authorizing. */
function queueFolderLookup(folder: Record<string, unknown> = mockFolder) {
queueTableRows(schemaMock.workflowFolder, [folder])
queueTableRows(schemaMock.folder, [folder])
}

/** Makes the next folder lookup throw, exercising the route's 500 path. */
Expand Down
23 changes: 15 additions & 8 deletions apps/sim/app/api/folders/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { folder as folderTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
Expand All @@ -13,6 +13,14 @@ import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

/** Maps an orchestration errorCode to its HTTP status; mirrors the POST /api/folders route. */
function folderMutationStatus(errorCode: string | undefined): number {
if (errorCode === 'validation') return 400
if (errorCode === 'conflict') return 409
if (errorCode === 'not_found') return 404
return 500
}

const logger = createLogger('FoldersIDAPI')

// PUT - Update a folder
Expand Down Expand Up @@ -44,8 +52,8 @@ export const PUT = withRouteHandler(
// Verify the folder exists
const existingFolder = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.id, id))
.from(folderTable)
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow')))
.then((rows) => rows[0])

if (!existingFolder) {
Expand Down Expand Up @@ -92,8 +100,7 @@ export const PUT = withRouteHandler(
})

if (!result.success || !result.folder) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
const status = folderMutationStatus(result.errorCode)
return NextResponse.json({ error: result.error }, { status })
}

Expand Down Expand Up @@ -125,8 +132,8 @@ export const DELETE = withRouteHandler(
// Verify the folder exists
const existingFolder = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.id, id))
.from(folderTable)
Comment thread
waleedlatif1 marked this conversation as resolved.
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow')))
.then((rows) => rows[0])

if (!existingFolder) {
Expand Down
35 changes: 21 additions & 14 deletions apps/sim/app/api/folders/reorder/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { folder as folderTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
import { eq, inArray } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { reorderFoldersContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
Expand Down Expand Up @@ -37,9 +37,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {

const folderIds = updates.map((u) => u.id)
const existingFolders = await db
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
.from(workflowFolder)
.where(inArray(workflowFolder.id, folderIds))
.select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
.from(folderTable)
.where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, 'workflow')))

const validIds = new Set(
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
Expand All @@ -58,12 +58,14 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
if (targetParentIds.length > 0) {
const parentFolders = await db
.select({
id: workflowFolder.id,
workspaceId: workflowFolder.workspaceId,
archivedAt: workflowFolder.archivedAt,
id: folderTable.id,
workspaceId: folderTable.workspaceId,
archivedAt: folderTable.deletedAt,
})
.from(workflowFolder)
.where(inArray(workflowFolder.id, targetParentIds))
.from(folderTable)
.where(
and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, 'workflow'))
)

const validParentIds = new Set(
parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id)
Expand All @@ -81,9 +83,11 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
}

const workspaceFolders = await db
.select({ id: workflowFolder.id, parentId: workflowFolder.parentId })
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.select({ id: folderTable.id, parentId: folderTable.parentId })
.from(folderTable)
.where(
and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow'))
)

const parentById = new Map<string, string | null>()
for (const folder of workspaceFolders) {
Expand Down Expand Up @@ -126,7 +130,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
if (update.parentId !== undefined) {
updateData.parentId = update.parentId || null
}
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
await tx
.update(folderTable)
.set(updateData)
.where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, 'workflow')))
}
})

Expand Down
30 changes: 0 additions & 30 deletions apps/sim/app/api/folders/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,35 +579,5 @@ describe('Folders API Route', () => {
expect(capturedValues).not.toBeNull()
expect(capturedValues!.name).toBe('Test Folder With Spaces')
})

it('should use default color when not provided', async () => {
mockAuthenticatedUser()

let capturedValues: CapturedFolderValues | null = null

mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
onInsertValues: (values) => {
capturedValues = values
},
})
)
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
capturedValues = values
return { returning: mockReturning }
})

const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})

await POST(req)

expect(capturedValues).not.toBeNull()
expect(capturedValues!.color).toBe('#6B7280')
})
})
})
Loading
Loading