|
9 | 9 | workflowSchedule, |
10 | 10 | } from '@sim/db/schema' |
11 | 11 | import { createLogger } from '@sim/logger' |
| 12 | +import { getPostgresErrorCode } from '@sim/utils/errors' |
12 | 13 | import { generateId } from '@sim/utils/id' |
13 | 14 | import { and, eq, inArray, isNull, min } from 'drizzle-orm' |
14 | 15 | import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' |
@@ -174,6 +175,18 @@ export async function performCreateFolder( |
174 | 175 |
|
175 | 176 | return { success: true, folder } |
176 | 177 | } 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 | + } |
177 | 190 | logger.error('Failed to create workflow folder', { error }) |
178 | 191 | return { success: false, error: 'Internal server error', errorCode: 'internal' } |
179 | 192 | } |
@@ -229,6 +242,13 @@ export async function performUpdateFolder( |
229 | 242 |
|
230 | 243 | return { success: true, folder } |
231 | 244 | } 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 | + } |
232 | 252 | logger.error('Failed to update workflow folder', { error }) |
233 | 253 | return { success: false, error: 'Internal server error', errorCode: 'internal' } |
234 | 254 | } |
@@ -519,23 +539,38 @@ export async function performRestoreFolder( |
519 | 539 | return { success: false, error: 'Cannot restore folder into an archived workspace' } |
520 | 540 | } |
521 | 541 |
|
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 | + } |
534 | 557 | } |
535 | | - } |
536 | 558 |
|
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 | + } |
539 | 574 |
|
540 | 575 | logger.info('Restored folder and all contents:', { folderId, restoredStats }) |
541 | 576 |
|
|
0 commit comments