Files
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

391 lines
13 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { FolderLockedError } from '@sim/platform-authz/workflow'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { duplicateFolderContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
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 { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FolderDuplicateAPI')
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const { id: sourceFolderId } = await context.params
const requestId = generateRequestId()
const startTime = Date.now()
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized folder duplication attempt for ${sourceFolderId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(duplicateFolderContract, req, context)
if (!parsed.success) return parsed.response
const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
const sourceFolder = await db
.select()
.from(workflowFolder)
.where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt)))
.then((rows) => rows[0])
if (!sourceFolder) {
throw new Error('Source folder not found')
}
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
sourceFolder.workspaceId
)
if (!userPermission || userPermission === 'read') {
throw new Error('Source folder not found or access denied')
}
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
if (targetWorkspaceId !== sourceFolder.workspaceId) {
throw new Error('Cross-workspace folder duplication is not supported')
}
const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => {
const newFolderId = clientNewId || generateId()
const now = new Date()
const targetParentId = parentId ?? sourceFolder.parentId
await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId)
const folderParentCondition = targetParentId
? eq(workflowFolder.parentId, targetParentId)
: isNull(workflowFolder.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)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
const deduplicatedName = await deduplicateFolderName(
tx,
targetWorkspaceId,
targetParentId,
name
)
await tx.insert(workflowFolder).values({
id: newFolderId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
name: deduplicatedName,
color: color || sourceFolder.color,
parentId: targetParentId,
sortOrder,
isExpanded: false,
locked: false,
createdAt: now,
updatedAt: now,
})
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
await duplicateFolderStructure(
tx,
sourceFolderId,
newFolderId,
sourceFolder.workspaceId,
targetWorkspaceId,
session.user.id,
now,
folderMapping
)
const workflowStats = await duplicateWorkflowsInFolderTree(
tx,
sourceFolder.workspaceId,
targetWorkspaceId,
folderMapping,
session.user.id,
requestId
)
return { newFolderId, folderMapping, workflowStats }
})
const elapsed = Date.now() - startTime
logger.info(
`[${requestId}] Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`,
{
foldersCount: folderMapping.size,
workflowsCount: workflowStats.total,
workflowsSucceeded: workflowStats.succeeded,
}
)
recordAudit({
workspaceId: targetWorkspaceId,
actorId: session.user.id,
action: AuditAction.FOLDER_DUPLICATED,
resourceType: AuditResourceType.FOLDER,
resourceId: newFolderId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Duplicated folder "${sourceFolder.name}" as "${name}"`,
metadata: {
sourceId: sourceFolder.id,
affected: { workflows: workflowStats.succeeded, folders: folderMapping.size },
},
request: req,
})
const duplicatedFolder = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.id, newFolderId))
.then((rows) => rows[0])
return NextResponse.json({ folder: duplicatedFolder }, { status: 201 })
} catch (error) {
if (error instanceof Error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
if (error.message === 'Source folder not found') {
logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`)
return NextResponse.json({ error: 'Source folder not found' }, { status: 404 })
}
if (error.message === 'Source folder not found or access denied') {
logger.warn(
`[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}`
)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
if (error.message === 'Cross-workspace folder duplication is not supported') {
logger.warn(
`[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}`
)
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (
error.message === 'Target parent folder not found' ||
error.message === 'Cannot duplicate folder into itself or one of its descendants'
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
const elapsed = Date.now() - startTime
logger.error(
`[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`,
error
)
return NextResponse.json({ error: 'Failed to duplicate folder' }, { status: 500 })
}
}
)
async function assertTargetParentFolderMutable(
tx: DbOrTx,
parentId: string | null,
targetWorkspaceId: string,
sourceFolderId: string
): Promise<void> {
let currentFolderId = parentId
const visited = new Set<string>()
while (currentFolderId && !visited.has(currentFolderId)) {
visited.add(currentFolderId)
const [folder] = await tx
.select({
id: workflowFolder.id,
parentId: workflowFolder.parentId,
workspaceId: workflowFolder.workspaceId,
locked: workflowFolder.locked,
archivedAt: workflowFolder.archivedAt,
})
.from(workflowFolder)
.where(eq(workflowFolder.id, currentFolderId))
.limit(1)
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
throw new Error('Target parent folder not found')
}
if (folder.id === sourceFolderId) {
throw new Error('Cannot duplicate folder into itself or one of its descendants')
}
if (folder.locked) {
throw new FolderLockedError()
}
currentFolderId = folder.parentId
}
}
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,
newParentFolderId: string,
sourceWorkspaceId: string,
targetWorkspaceId: string,
userId: string,
timestamp: Date,
folderMapping: Map<string, string>
): Promise<void> {
const childFolders = await tx
.select()
.from(workflowFolder)
.where(
and(
eq(workflowFolder.parentId, sourceFolderId),
eq(workflowFolder.workspaceId, sourceWorkspaceId),
isNull(workflowFolder.archivedAt)
)
)
for (const childFolder of childFolders) {
const newChildFolderId = generateId()
folderMapping.set(childFolder.id, newChildFolderId)
await tx.insert(workflowFolder).values({
id: newChildFolderId,
userId,
workspaceId: targetWorkspaceId,
name: childFolder.name,
color: childFolder.color,
parentId: newParentFolderId,
sortOrder: childFolder.sortOrder,
isExpanded: false,
locked: false,
createdAt: timestamp,
updatedAt: timestamp,
})
await duplicateFolderStructure(
tx,
childFolder.id,
newChildFolderId,
sourceWorkspaceId,
targetWorkspaceId,
userId,
timestamp,
folderMapping
)
}
}
async function duplicateWorkflowsInFolderTree(
tx: DbOrTx,
sourceWorkspaceId: string,
targetWorkspaceId: string,
folderMapping: Map<string, string>,
userId: string,
requestId: string
): Promise<{ total: number; succeeded: number }> {
const stats = { total: 0, succeeded: 0 }
const workflowsByNewFolder = new Map<string, Array<typeof workflow.$inferSelect>>()
const workflowIdMap = new Map<string, string>()
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
const workflowsInFolder = await tx
.select()
.from(workflow)
.where(
and(
eq(workflow.folderId, oldFolderId),
eq(workflow.workspaceId, sourceWorkspaceId),
isNull(workflow.archivedAt)
)
)
stats.total += workflowsInFolder.length
workflowsByNewFolder.set(newFolderId, workflowsInFolder)
for (const sourceWorkflow of workflowsInFolder) {
workflowIdMap.set(sourceWorkflow.id, generateId())
}
}
for (const [newFolderId, workflowsInFolder] of workflowsByNewFolder.entries()) {
for (const sourceWorkflow of workflowsInFolder) {
await duplicateWorkflow({
sourceWorkflowId: sourceWorkflow.id,
userId,
name: sourceWorkflow.name,
description: sourceWorkflow.description || undefined,
workspaceId: targetWorkspaceId,
folderId: newFolderId,
requestId,
tx,
newWorkflowId: workflowIdMap.get(sourceWorkflow.id),
workflowIdMap,
})
stats.succeeded++
}
}
return stats
}