Skip to content

Commit 64cb20d

Browse files
committed
fix(folders): finish the unique-name handling across every write path
Three gaps left by the previous pass, all from the same new constraint. - restore re-roots a folder whose parent is still archived, but the dedup ran against the original parentId, so a root-level clash was missed and clearing deletedAt still hit the index — defeating the dedup in exactly the case it exists for. It now dedups against the resolved parent - the folder PUT handler mapped only not_found and validation, so the conflict errorCode added for renames fell through to 500. It now shares folderMutationStatus with the POST route - admin workspace import inserted path segments unconditionally, so a segment matching an existing folder failed the import. It now reuses the existing folder (mkdir -p), which is also the semantics importing into a folder path should have — the same path twice lands in one tree
1 parent d3e3078 commit 64cb20d

3 files changed

Lines changed: 70 additions & 24 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import { captureServerEvent } from '@/lib/posthog/server'
1313
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
1414
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1515

16+
/** Maps an orchestration errorCode to its HTTP status; mirrors the POST /api/folders route. */
17+
function folderMutationStatus(errorCode: string | undefined): number {
18+
if (errorCode === 'validation') return 400
19+
if (errorCode === 'conflict') return 409
20+
if (errorCode === 'not_found') return 404
21+
return 500
22+
}
23+
1624
const logger = createLogger('FoldersIDAPI')
1725

1826
// PUT - Update a folder
@@ -92,8 +100,7 @@ export const PUT = withRouteHandler(
92100
})
93101

94102
if (!result.success || !result.folder) {
95-
const status =
96-
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
103+
const status = folderMutationStatus(result.errorCode)
97104
return NextResponse.json({ error: result.error }, { status })
98105
}
99106

apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { folder as folderTable, workflow } from '@sim/db/schema'
2828
import { createLogger } from '@sim/logger'
2929
import { getErrorMessage } from '@sim/utils/errors'
3030
import { generateId } from '@sim/utils/id'
31-
import { eq } from 'drizzle-orm'
31+
import { and, eq, isNull } from 'drizzle-orm'
3232
import { NextResponse } from 'next/server'
3333
import {
3434
adminV1ImportWorkspaceContract,
@@ -76,6 +76,51 @@ interface ParsedWorkflow {
7676
folderPath: string[]
7777
}
7878

79+
/**
80+
* Returns the id of the active workflow folder named `name` under `parentId`, creating it if
81+
* absent — `mkdir -p` semantics.
82+
*
83+
* The generic `folder` table enforces active sibling-name uniqueness, which
84+
* `workflow_folder` did not, so blindly inserting an import path segment that already exists
85+
* now fails the whole import on a unique violation. Reusing the existing folder is also the
86+
* behaviour an import of a folder *path* should have: importing into "Reports/2026" twice
87+
* should land in one tree, not two.
88+
*/
89+
async function ensureImportFolder(
90+
workspaceId: string,
91+
userId: string,
92+
name: string,
93+
parentId: string | null
94+
): Promise<string> {
95+
const [existing] = await db
96+
.select({ id: folderTable.id })
97+
.from(folderTable)
98+
.where(
99+
and(
100+
eq(folderTable.workspaceId, workspaceId),
101+
eq(folderTable.resourceType, 'workflow'),
102+
eq(folderTable.name, name),
103+
parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId),
104+
isNull(folderTable.deletedAt)
105+
)
106+
)
107+
.limit(1)
108+
if (existing) return existing.id
109+
110+
const folderId = generateId()
111+
await db.insert(folderTable).values({
112+
id: folderId,
113+
resourceType: 'workflow',
114+
name,
115+
userId,
116+
workspaceId,
117+
parentId,
118+
createdAt: new Date(),
119+
updatedAt: new Date(),
120+
})
121+
return folderId
122+
}
123+
79124
export const POST = withRouteHandler(
80125
withAdminAuthParams<RouteParams>(async (request, context) => {
81126
const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context)
@@ -151,17 +196,12 @@ export const POST = withRouteHandler(
151196

152197
let rootFolderId: string | undefined
153198
if (rootFolderName && createFolders) {
154-
rootFolderId = generateId()
155-
await db.insert(folderTable).values({
156-
id: rootFolderId,
157-
resourceType: 'workflow',
158-
name: rootFolderName,
159-
userId: workspaceData.ownerId,
199+
rootFolderId = await ensureImportFolder(
160200
workspaceId,
161-
parentId: null,
162-
createdAt: new Date(),
163-
updatedAt: new Date(),
164-
})
201+
workspaceData.ownerId,
202+
rootFolderName,
203+
null
204+
)
165205
}
166206

167207
const folderMap = new Map<string, string>()
@@ -230,17 +270,12 @@ async function importSingleWorkflow(
230270
const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment
231271

232272
if (!folderMap.has(fullPath)) {
233-
const folderId = generateId()
234-
await db.insert(folderTable).values({
235-
id: folderId,
236-
resourceType: 'workflow',
237-
name: wf.folderPath[i],
238-
userId: ownerId,
273+
const folderId = await ensureImportFolder(
239274
workspaceId,
240-
parentId,
241-
createdAt: new Date(),
242-
updatedAt: new Date(),
243-
})
275+
ownerId,
276+
wf.folderPath[i],
277+
parentId
278+
)
244279
folderMap.set(fullPath, folderId)
245280
parentId = folderId
246281
} else {

apps/sim/lib/workflows/orchestration/folder-lifecycle.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,17 @@ export async function performRestoreFolder(
543543
let restoredStats: { folders: number; workflows: number }
544544
try {
545545
restoredStats = await db.transaction(async (tx) => {
546+
// A folder whose parent is still archived is re-rooted, so the name it has to be
547+
// unique against is its *resolved* parent's sibling set, not its original one.
548+
let resolvedParentId = folder.parentId
546549
if (folder.parentId) {
547550
const [parentFolder] = await tx
548551
.select({ archivedAt: folderTable.deletedAt })
549552
.from(folderTable)
550553
.where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow')))
551554

552555
if (!parentFolder || parentFolder.archivedAt) {
556+
resolvedParentId = null
553557
await tx
554558
.update(folderTable)
555559
.set({ parentId: null })
@@ -566,7 +570,7 @@ export async function performRestoreFolder(
566570
const restoredName = await deduplicateFolderName(
567571
tx,
568572
workspaceId,
569-
folder.parentId,
573+
resolvedParentId,
570574
folder.name
571575
)
572576
if (restoredName !== folder.name) {

0 commit comments

Comments
 (0)