@@ -3,22 +3,48 @@ import { db } from '@sim/db'
33import { folder as folderTable , workflow } from '@sim/db/schema'
44import { createLogger } from '@sim/logger'
55import { FolderLockedError } from '@sim/platform-authz/workflow'
6+ import { getPostgresErrorCode } from '@sim/utils/errors'
67import { generateId } from '@sim/utils/id'
7- import { and , eq , isNull , min } from 'drizzle-orm'
8+ import { and , eq , isNull } from 'drizzle-orm'
89import { type NextRequest , NextResponse } from 'next/server'
910import { duplicateFolderContract } from '@/lib/api/contracts'
1011import { parseRequest } from '@/lib/api/server'
1112import { getSession } from '@/lib/auth'
1213import { generateRequestId } from '@/lib/core/utils/request'
1314import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1415import type { DbOrTx } from '@/lib/db/types'
16+ import { nextFolderSortOrder } from '@/lib/folders/lifecycle'
1517import { deduplicateFolderName } from '@/lib/folders/naming'
1618import { toFolderApi } from '@/lib/folders/queries'
1719import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1820import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1921
2022const logger = createLogger ( 'FolderDuplicateAPI' )
2123
24+ /**
25+ * Duplication only ever copies workflow folders. Named once so the scope is stated rather
26+ * than restated as a literal at every query — the engine reads its resourceType from config
27+ * for exactly this reason.
28+ */
29+ const FOLDER_RESOURCE_TYPE = 'workflow' as const
30+
31+ /**
32+ * Carries the HTTP status with the failure, so the handler maps errors by type instead of by
33+ * comparing `error.message` to a literal — a coupling that breaks silently the moment
34+ * someone rewords a message.
35+ */
36+ class FolderDuplicationError extends Error {
37+ constructor (
38+ message : string ,
39+ readonly status : number ,
40+ /** Message returned to the caller when it must differ from the logged one. */
41+ readonly publicMessage : string = message
42+ ) {
43+ super ( message )
44+ this . name = 'FolderDuplicationError'
45+ }
46+ }
47+
2248// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
2349export const POST = withRouteHandler (
2450 async ( req : NextRequest , context : { params : Promise < { id : string } > } ) => {
@@ -46,13 +72,13 @@ export const POST = withRouteHandler(
4672 and (
4773 eq ( folderTable . id , sourceFolderId ) ,
4874 isNull ( folderTable . deletedAt ) ,
49- eq ( folderTable . resourceType , 'workflow' )
75+ eq ( folderTable . resourceType , FOLDER_RESOURCE_TYPE )
5076 )
5177 )
5278 . then ( ( rows ) => rows [ 0 ] )
5379
5480 if ( ! sourceFolder ) {
55- throw new Error ( 'Source folder not found' )
81+ throw new FolderDuplicationError ( 'Source folder not found' , 404 )
5682 }
5783
5884 const userPermission = await getUserEntityPermissions (
@@ -62,12 +88,16 @@ export const POST = withRouteHandler(
6288 )
6389
6490 if ( ! userPermission || userPermission === 'read' ) {
65- throw new Error ( 'Source folder not found or access denied' )
91+ throw new FolderDuplicationError (
92+ 'Source folder not found or access denied' ,
93+ 403 ,
94+ 'Access denied'
95+ )
6696 }
6797
6898 const targetWorkspaceId = workspaceId || sourceFolder . workspaceId
6999 if ( targetWorkspaceId !== sourceFolder . workspaceId ) {
70- throw new Error ( 'Cross-workspace folder duplication is not supported' )
100+ throw new FolderDuplicationError ( 'Cross-workspace folder duplication is not supported' , 400 )
71101 }
72102
73103 const { newFolderId, folderMapping, workflowStats } = await db . transaction ( async ( tx ) => {
@@ -76,57 +106,55 @@ export const POST = withRouteHandler(
76106 const targetParentId = parentId ?? sourceFolder . parentId
77107 await assertTargetParentFolderMutable ( tx , targetParentId , targetWorkspaceId , sourceFolderId )
78108
79- const folderParentCondition = targetParentId
80- ? eq ( folderTable . parentId , targetParentId )
81- : isNull ( folderTable . parentId )
82- const workflowParentCondition = targetParentId
83- ? eq ( workflow . folderId , targetParentId )
84- : isNull ( workflow . folderId )
85-
86- const [ [ folderResult ] , [ workflowResult ] ] = await Promise . all ( [
87- tx
88- . select ( { minSortOrder : min ( folderTable . sortOrder ) } )
89- . from ( folderTable )
90- . where (
91- and (
92- eq ( folderTable . workspaceId , targetWorkspaceId ) ,
93- eq ( folderTable . resourceType , 'workflow' ) ,
94- folderParentCondition
95- )
96- ) ,
109+ // Placement is the engine's rule (folders and workflows share one ordering space),
110+ // so it is read from there rather than recomputed here.
111+ const sortOrder = await nextFolderSortOrder (
112+ FOLDER_RESOURCE_TYPE ,
113+ targetWorkspaceId ,
114+ targetParentId ,
97115 tx
98- . select ( { minSortOrder : min ( workflow . sortOrder ) } )
99- . from ( workflow )
100- . where ( and ( eq ( workflow . workspaceId , targetWorkspaceId ) , workflowParentCondition ) ) ,
101- ] )
102-
103- const minSortOrder = [ folderResult ?. minSortOrder , workflowResult ?. minSortOrder ] . reduce <
104- number | null
105- > ( ( currentMin , candidate ) => {
106- if ( candidate == null ) return currentMin
107- if ( currentMin == null ) return candidate
108- return Math . min ( currentMin , candidate )
109- } , null )
110- const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
116+ )
117+
111118 const deduplicatedName = await deduplicateFolderName (
112119 tx ,
113120 targetWorkspaceId ,
114121 targetParentId ,
115- name
122+ name ,
123+ FOLDER_RESOURCE_TYPE
116124 )
117125
118- await tx . insert ( folderTable ) . values ( {
119- id : newFolderId ,
120- resourceType : 'workflow' ,
121- userId : session . user . id ,
122- workspaceId : targetWorkspaceId ,
123- name : deduplicatedName ,
124- parentId : targetParentId ,
125- sortOrder,
126- locked : false ,
127- createdAt : now ,
128- updatedAt : now ,
129- } )
126+ try {
127+ await tx . insert ( folderTable ) . values ( {
128+ id : newFolderId ,
129+ resourceType : FOLDER_RESOURCE_TYPE ,
130+ userId : session . user . id ,
131+ workspaceId : targetWorkspaceId ,
132+ name : deduplicatedName ,
133+ parentId : targetParentId ,
134+ sortOrder,
135+ locked : false ,
136+ createdAt : now ,
137+ updatedAt : now ,
138+ } )
139+ } catch ( insertError ) {
140+ /**
141+ * Scoped to THIS insert on purpose. A 23505 here is one of two real conflicts the
142+ * caller can act on: `newId` is client-supplied, so replaying a duplicate whose
143+ * response was lost hits the primary key; and `deduplicateFolderName` runs before
144+ * the write, so a concurrent create can still take the name in between.
145+ *
146+ * Catching 23505 across the whole handler instead would relabel any unique violation
147+ * raised while copying the workflows inside the folder — a different constraint, on a
148+ * different table — as a folder-name conflict, which is both wrong and misleading.
149+ * Those keep falling through to the generic 500.
150+ */
151+ if ( getPostgresErrorCode ( insertError ) !== '23505' ) throw insertError
152+ throw new FolderDuplicationError (
153+ `Folder duplication conflicted for ${ sourceFolderId } ` ,
154+ 409 ,
155+ 'A folder with this name already exists in this location'
156+ )
157+ }
130158
131159 const folderMapping = new Map < string , string > ( [ [ sourceFolderId , newFolderId ] ] )
132160 await duplicateFolderStructure (
@@ -182,41 +210,23 @@ export const POST = withRouteHandler(
182210 const duplicatedFolder = await db
183211 . select ( )
184212 . from ( folderTable )
185- . where ( and ( eq ( folderTable . id , newFolderId ) , eq ( folderTable . resourceType , 'workflow' ) ) )
213+ . where (
214+ and ( eq ( folderTable . id , newFolderId ) , eq ( folderTable . resourceType , FOLDER_RESOURCE_TYPE ) )
215+ )
186216 . then ( ( rows ) => rows [ 0 ] )
187217
188218 return NextResponse . json ( { folder : toFolderApi ( duplicatedFolder ) } , { status : 201 } )
189219 } catch ( error ) {
190- if ( error instanceof Error ) {
191- if ( error instanceof FolderLockedError ) {
192- return NextResponse . json ( { error : error . message } , { status : error . status } )
193- }
194-
195- if ( error . message === 'Source folder not found' ) {
196- logger . warn ( `[${ requestId } ] Source folder ${ sourceFolderId } not found` )
197- return NextResponse . json ( { error : 'Source folder not found' } , { status : 404 } )
198- }
199-
200- if ( error . message === 'Source folder not found or access denied' ) {
201- logger . warn (
202- `[${ requestId } ] User ${ session . user . id } denied access to source folder ${ sourceFolderId } `
203- )
204- return NextResponse . json ( { error : 'Access denied' } , { status : 403 } )
205- }
206-
207- if ( error . message === 'Cross-workspace folder duplication is not supported' ) {
208- logger . warn (
209- `[${ requestId } ] User ${ session . user . id } attempted cross-workspace folder duplication for ${ sourceFolderId } `
210- )
211- return NextResponse . json ( { error : error . message } , { status : 400 } )
212- }
220+ if ( error instanceof FolderLockedError ) {
221+ return NextResponse . json ( { error : error . message } , { status : error . status } )
222+ }
213223
214- if (
215- error . message === 'Target parent folder not found' ||
216- error . message === 'Cannot duplicate folder into itself or one of its descendants'
217- ) {
218- return NextResponse . json ( { error : error . message } , { status : 400 } )
219- }
224+ if ( error instanceof FolderDuplicationError ) {
225+ logger . warn ( `[ ${ requestId } ] Folder duplication rejected: ${ error . message } ` , {
226+ sourceFolderId ,
227+ userId : session . user . id ,
228+ } )
229+ return NextResponse . json ( { error : error . publicMessage } , { status : error . status } )
220230 }
221231
222232 const elapsed = Date . now ( ) - startTime
@@ -249,14 +259,19 @@ async function assertTargetParentFolderMutable(
249259 archivedAt : folderTable . deletedAt ,
250260 } )
251261 . from ( folderTable )
252- . where ( and ( eq ( folderTable . id , currentFolderId ) , eq ( folderTable . resourceType , 'workflow' ) ) )
262+ . where (
263+ and ( eq ( folderTable . id , currentFolderId ) , eq ( folderTable . resourceType , FOLDER_RESOURCE_TYPE ) )
264+ )
253265 . limit ( 1 )
254266
255267 if ( ! folder || folder . workspaceId !== targetWorkspaceId || folder . archivedAt ) {
256- throw new Error ( 'Target parent folder not found' )
268+ throw new FolderDuplicationError ( 'Target parent folder not found' , 400 )
257269 }
258270 if ( folder . id === sourceFolderId ) {
259- throw new Error ( 'Cannot duplicate folder into itself or one of its descendants' )
271+ throw new FolderDuplicationError (
272+ 'Cannot duplicate folder into itself or one of its descendants' ,
273+ 400
274+ )
260275 }
261276 if ( folder . locked ) {
262277 throw new FolderLockedError ( )
@@ -283,7 +298,7 @@ async function duplicateFolderStructure(
283298 and (
284299 eq ( folderTable . parentId , sourceFolderId ) ,
285300 eq ( folderTable . workspaceId , sourceWorkspaceId ) ,
286- eq ( folderTable . resourceType , 'workflow' ) ,
301+ eq ( folderTable . resourceType , FOLDER_RESOURCE_TYPE ) ,
287302 isNull ( folderTable . deletedAt )
288303 )
289304 )
@@ -294,7 +309,7 @@ async function duplicateFolderStructure(
294309
295310 await tx . insert ( folderTable ) . values ( {
296311 id : newChildFolderId ,
297- resourceType : 'workflow' ,
312+ resourceType : FOLDER_RESOURCE_TYPE ,
298313 userId,
299314 workspaceId : targetWorkspaceId ,
300315 name : childFolder . name ,
0 commit comments