Skip to content

Commit 65fce4c

Browse files
committed
fix(folders): return 409 instead of 500 on duplicate folder names
The generic folder table carries a partial unique index on active (workspaceId, resourceType, parentId, name) that workflow_folder never had, so duplicate sibling names are newly rejectable — and prod has 35 such groups today, deduped only by the migration backfill. Create, update and restore all reached the constraint with no 23505 handling: a duplicate name surfaced as an unhandled 500. Restore had no try/catch at all, and it is the easiest one to hit, since clearing deletedAt brings a row back under the index against siblings that may have taken the name meanwhile. All three now map the violation to a conflict the caller can act on.
1 parent 419bbb9 commit 65fce4c

1 file changed

Lines changed: 50 additions & 15 deletions

File tree

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

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
workflowSchedule,
1010
} from '@sim/db/schema'
1111
import { createLogger } from '@sim/logger'
12+
import { getPostgresErrorCode } from '@sim/utils/errors'
1213
import { generateId } from '@sim/utils/id'
1314
import { and, eq, inArray, isNull, min } from 'drizzle-orm'
1415
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
@@ -174,6 +175,18 @@ export async function performCreateFolder(
174175

175176
return { success: true, folder }
176177
} catch (error) {
178+
// `folder` carries a unique index on (workspaceId, resourceType, parent, name) for active
179+
// rows that `workflow_folder` never had, so a duplicate sibling name is newly rejectable
180+
// here. Map it to a 409 rather than letting it surface as a 500 — the client-side dedup
181+
// in useFolderCreateWithDedup is best-effort and races, and the copilot/import paths
182+
// create folders by name.
183+
if (getPostgresErrorCode(error) === '23505') {
184+
return {
185+
success: false,
186+
error: 'A folder with this name already exists in this location',
187+
errorCode: 'conflict',
188+
}
189+
}
177190
logger.error('Failed to create workflow folder', { error })
178191
return { success: false, error: 'Internal server error', errorCode: 'internal' }
179192
}
@@ -229,6 +242,13 @@ export async function performUpdateFolder(
229242

230243
return { success: true, folder }
231244
} catch (error) {
245+
if (getPostgresErrorCode(error) === '23505') {
246+
return {
247+
success: false,
248+
error: 'A folder with this name already exists in this location',
249+
errorCode: 'conflict',
250+
}
251+
}
232252
logger.error('Failed to update workflow folder', { error })
233253
return { success: false, error: 'Internal server error', errorCode: 'internal' }
234254
}
@@ -519,23 +539,38 @@ export async function performRestoreFolder(
519539
return { success: false, error: 'Cannot restore folder into an archived workspace' }
520540
}
521541

522-
const restoredStats = await db.transaction(async (tx) => {
523-
if (folder.parentId) {
524-
const [parentFolder] = await tx
525-
.select({ archivedAt: folderTable.deletedAt })
526-
.from(folderTable)
527-
.where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow')))
528-
529-
if (!parentFolder || parentFolder.archivedAt) {
530-
await tx
531-
.update(folderTable)
532-
.set({ parentId: null })
533-
.where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow')))
542+
let restoredStats: { folders: number; workflows: number }
543+
try {
544+
restoredStats = await db.transaction(async (tx) => {
545+
if (folder.parentId) {
546+
const [parentFolder] = await tx
547+
.select({ archivedAt: folderTable.deletedAt })
548+
.from(folderTable)
549+
.where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow')))
550+
551+
if (!parentFolder || parentFolder.archivedAt) {
552+
await tx
553+
.update(folderTable)
554+
.set({ parentId: null })
555+
.where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow')))
556+
}
534557
}
535-
}
536558

537-
return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx)
538-
})
559+
return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx)
560+
})
561+
} catch (error) {
562+
// Restoring clears `deletedAt`, which brings the row back under the generic table's
563+
// partial unique index on active (workspaceId, resourceType, parent, name) — a
564+
// constraint `workflow_folder` never had. If a sibling has since taken the name, report
565+
// it as a conflict rather than a 500, so the caller can rename and retry.
566+
if (getPostgresErrorCode(error) === '23505') {
567+
return {
568+
success: false,
569+
error: 'A folder with this name already exists in this location',
570+
}
571+
}
572+
throw error
573+
}
539574

540575
logger.info('Restored folder and all contents:', { folderId, restoredStats })
541576

0 commit comments

Comments
 (0)