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
391 lines
13 KiB
TypeScript
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
|
|
}
|