mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 01:23:02 +08:00
feat(workflows): lock/duplicate improvements for workflows (#4387)
* feat(workflows): lock/duplicate improvements * fix duplicate var remap bug * address comments * remove dead vars * fix tests * address comments * code cleanup * address comments * address comments * minor change * remove dead code
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
} from '@sim/realtime-protocol/constants'
|
||||
import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
|
||||
import { ZodError } from 'zod'
|
||||
import { persistWorkflowOperation } from '@/database/operations'
|
||||
import type { AuthenticatedSocket } from '@/middleware/auth'
|
||||
@@ -139,6 +140,24 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
emitOperationError(
|
||||
{
|
||||
type: 'WORKFLOW_LOCKED',
|
||||
message: error.message,
|
||||
operation,
|
||||
target,
|
||||
},
|
||||
{ error: error.message, retryable: false }
|
||||
)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Broadcast first for position updates to minimize latency, then persist
|
||||
// For other operations, persist first for consistency
|
||||
if (isPositionUpdate) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
|
||||
import { workflow, workflowBlocks } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { AuthenticatedSocket } from '@/middleware/auth'
|
||||
import { checkRolePermission } from '@/middleware/permissions'
|
||||
@@ -151,6 +152,28 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
socket.emit('operation-forbidden', {
|
||||
type: 'WORKFLOW_LOCKED',
|
||||
message: error.message,
|
||||
operation: SUBBLOCK_OPERATIONS.UPDATE,
|
||||
target: 'subblock',
|
||||
})
|
||||
if (operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId,
|
||||
error: error.message,
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Update user activity
|
||||
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
|
||||
|
||||
@@ -231,6 +254,22 @@ async function flushSubblockUpdate(
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
pending.opToSocket.forEach((socketId, opId) => {
|
||||
io.to(socketId).emit('operation-failed', {
|
||||
operationId: opId,
|
||||
error: error.message,
|
||||
retryable: false,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let updateSuccessful = false
|
||||
let blockLocked = false
|
||||
await db.transaction(async (tx) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { AuthenticatedSocket } from '@/middleware/auth'
|
||||
import { checkRolePermission } from '@/middleware/permissions'
|
||||
@@ -140,6 +141,28 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
socket.emit('operation-forbidden', {
|
||||
type: 'WORKFLOW_LOCKED',
|
||||
message: error.message,
|
||||
operation: VARIABLE_OPERATIONS.UPDATE,
|
||||
target: 'variable',
|
||||
})
|
||||
if (operationId) {
|
||||
socket.emit('operation-failed', {
|
||||
operationId,
|
||||
error: error.message,
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Update user activity
|
||||
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
|
||||
|
||||
@@ -218,6 +241,22 @@ async function flushVariableUpdate(
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
pending.opToSocket.forEach((socketId, opId) => {
|
||||
io.to(socketId).emit('operation-failed', {
|
||||
operationId: opId,
|
||||
error: error.message,
|
||||
retryable: false,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let updateSuccessful = false
|
||||
await db.transaction(async (tx) => {
|
||||
const [workflowRecord] = await tx
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { FolderLockedError } from '@sim/workflow-authz'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { duplicateFolderContract } from '@/lib/api/contracts'
|
||||
@@ -10,6 +11,7 @@ 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'
|
||||
|
||||
@@ -38,7 +40,7 @@ export const POST = withRouteHandler(
|
||||
const sourceFolder = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, sourceFolderId))
|
||||
.where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sourceFolder) {
|
||||
@@ -56,11 +58,15 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||
if (targetWorkspaceId !== sourceFolder.workspaceId) {
|
||||
throw new Error('Cross-workspace folder duplication is not supported')
|
||||
}
|
||||
|
||||
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
|
||||
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)
|
||||
@@ -88,16 +94,23 @@ export const POST = withRouteHandler(
|
||||
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,
|
||||
name: deduplicatedName,
|
||||
color: color || sourceFolder.color,
|
||||
parentId: targetParentId,
|
||||
sortOrder,
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -114,16 +127,17 @@ export const POST = withRouteHandler(
|
||||
folderMapping
|
||||
)
|
||||
|
||||
return { newFolderId, folderMapping }
|
||||
})
|
||||
const workflowStats = await duplicateWorkflowsInFolderTree(
|
||||
tx,
|
||||
sourceFolder.workspaceId,
|
||||
targetWorkspaceId,
|
||||
folderMapping,
|
||||
session.user.id,
|
||||
requestId
|
||||
)
|
||||
|
||||
const workflowStats = await duplicateWorkflowsInFolderTree(
|
||||
sourceFolder.workspaceId,
|
||||
targetWorkspaceId,
|
||||
folderMapping,
|
||||
session.user.id,
|
||||
requestId
|
||||
)
|
||||
return { newFolderId, folderMapping, workflowStats }
|
||||
})
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
@@ -132,7 +146,6 @@ export const POST = withRouteHandler(
|
||||
foldersCount: folderMapping.size,
|
||||
workflowsCount: workflowStats.total,
|
||||
workflowsSucceeded: workflowStats.succeeded,
|
||||
workflowsFailed: workflowStats.failed,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -162,6 +175,10 @@ export const POST = withRouteHandler(
|
||||
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 })
|
||||
@@ -173,6 +190,20 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
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
|
||||
@@ -185,8 +216,76 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
)
|
||||
|
||||
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: any,
|
||||
tx: DbOrTx,
|
||||
sourceFolderId: string,
|
||||
newParentFolderId: string,
|
||||
sourceWorkspaceId: string,
|
||||
@@ -201,7 +300,8 @@ async function duplicateFolderStructure(
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.parentId, sourceFolderId),
|
||||
eq(workflowFolder.workspaceId, sourceWorkspaceId)
|
||||
eq(workflowFolder.workspaceId, sourceWorkspaceId),
|
||||
isNull(workflowFolder.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -218,6 +318,7 @@ async function duplicateFolderStructure(
|
||||
parentId: newParentFolderId,
|
||||
sortOrder: childFolder.sortOrder,
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
@@ -236,40 +337,53 @@ async function duplicateFolderStructure(
|
||||
}
|
||||
|
||||
async function duplicateWorkflowsInFolderTree(
|
||||
tx: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
folderMapping: Map<string, string>,
|
||||
userId: string,
|
||||
requestId: string
|
||||
): Promise<{ total: number; succeeded: number; failed: number }> {
|
||||
const stats = { total: 0, succeeded: 0, failed: 0 }
|
||||
): 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 db
|
||||
const workflowsInFolder = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.folderId, oldFolderId), eq(workflow.workspaceId, sourceWorkspaceId)))
|
||||
.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) {
|
||||
try {
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: sourceWorkflow.id,
|
||||
userId,
|
||||
name: sourceWorkflow.name,
|
||||
description: sourceWorkflow.description || undefined,
|
||||
color: sourceWorkflow.color,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: newFolderId,
|
||||
requestId,
|
||||
})
|
||||
workflowIdMap.set(sourceWorkflow.id, generateId())
|
||||
}
|
||||
}
|
||||
|
||||
stats.succeeded++
|
||||
} catch (error) {
|
||||
stats.failed++
|
||||
logger.error(`[${requestId}] Error duplicating workflow ${sourceWorkflow.id}:`, error)
|
||||
}
|
||||
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,
|
||||
color: sourceWorkflow.color,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: newFolderId,
|
||||
requestId,
|
||||
tx,
|
||||
newWorkflowId: workflowIdMap.get(sourceWorkflow.id),
|
||||
workflowIdMap,
|
||||
})
|
||||
|
||||
stats.succeeded++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateFolderContract } from '@/lib/api/contracts'
|
||||
@@ -38,7 +39,7 @@ export const PUT = withRouteHandler(
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const { name, color, isExpanded, parentId, sortOrder } = parsed.data.body
|
||||
const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body
|
||||
|
||||
// Verify the folder exists
|
||||
const existingFolder = await db
|
||||
@@ -65,6 +66,21 @@ export const PUT = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
if (locked !== undefined && workspacePermission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required to lock folders' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
|
||||
if (hasNonLockUpdate) {
|
||||
await assertFolderMutable(id)
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
await assertFolderMutable(parentId)
|
||||
}
|
||||
|
||||
// Prevent setting a folder as its own parent or creating circular references
|
||||
if (parentId && parentId === id) {
|
||||
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
|
||||
@@ -85,6 +101,7 @@ export const PUT = withRouteHandler(
|
||||
if (name !== undefined) updates.name = name.trim()
|
||||
if (color !== undefined) updates.color = color
|
||||
if (isExpanded !== undefined) updates.isExpanded = isExpanded
|
||||
if (locked !== undefined) updates.locked = locked
|
||||
if (parentId !== undefined) updates.parentId = parentId || null
|
||||
if (sortOrder !== undefined) updates.sortOrder = sortOrder
|
||||
|
||||
@@ -98,6 +115,10 @@ export const PUT = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ folder: updatedFolder })
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error('Error updating folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
@@ -139,6 +160,8 @@ export const DELETE = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
await assertFolderMutable(id)
|
||||
|
||||
const result = await performDeleteFolder({
|
||||
folderId: id,
|
||||
workspaceId: existingFolder.workspaceId,
|
||||
@@ -164,6 +187,10 @@ export const DELETE = withRouteHandler(
|
||||
deletedItems: result.deletedItems,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error('Error deleting folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { reorderFoldersContract } from '@/lib/api/contracts'
|
||||
@@ -50,6 +51,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
for (const update of validUpdates) {
|
||||
await assertFolderMutable(update.id)
|
||||
if (update.parentId !== undefined) {
|
||||
await assertFolderMutable(update.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
@@ -69,6 +77,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering folders`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateScheduleContract } from '@/lib/api/contracts/schedules'
|
||||
@@ -117,6 +121,9 @@ export const PUT = withRouteHandler(
|
||||
const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write')
|
||||
if (result instanceof NextResponse) return result
|
||||
const { schedule, workspaceId } = result
|
||||
if (schedule.workflowId) {
|
||||
await assertWorkflowMutable(schedule.workflowId)
|
||||
}
|
||||
|
||||
const { action } = validatedBody
|
||||
|
||||
@@ -264,6 +271,10 @@ export const PUT = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating schedule`, error)
|
||||
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
@@ -131,6 +135,7 @@ export const PATCH = withRouteHandler(
|
||||
logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
await assertWorkflowMutable(webhookData.workflow.id)
|
||||
|
||||
const updatedWebhook = await db
|
||||
.update(webhook)
|
||||
@@ -145,6 +150,10 @@ export const PATCH = withRouteHandler(
|
||||
logger.info(`[${requestId}] Successfully updated webhook: ${id}`)
|
||||
return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating webhook`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
@@ -201,6 +210,7 @@ export const DELETE = withRouteHandler(
|
||||
logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
await assertWorkflowMutable(webhookData.workflow.id)
|
||||
|
||||
const foundWebhook = webhookData.webhook
|
||||
const credentialSetId = foundWebhook.credentialSetId as string | undefined
|
||||
@@ -301,6 +311,10 @@ export const DELETE = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting webhook`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
|
||||
@@ -3,7 +3,11 @@ import { db } from '@sim/db'
|
||||
import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId, generateShortId } from '@sim/utils/id'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { listWebhooksContract, upsertWebhookContract } from '@/lib/api/contracts/webhooks'
|
||||
@@ -293,6 +297,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
// Determine existing webhook to update (prefer by workflow+block for credential-based providers)
|
||||
let targetWebhookId: string | null = null
|
||||
@@ -720,6 +725,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const status = targetWebhookId ? 200 : 201
|
||||
return NextResponse.json({ webhook: savedWebhook }, { status })
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating/updating webhook`, {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
@@ -72,6 +76,8 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
let currentWorkflowData: NormalizedWorkflowData | null
|
||||
|
||||
if (layoutOptions.blocks && layoutOptions.edges) {
|
||||
@@ -141,6 +147,10 @@ export const POST = withRouteHandler(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db, workflow } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { updatePublicApiContract } from '@/lib/api/contracts/deployments'
|
||||
@@ -92,6 +93,7 @@ export const POST = withRouteHandler(
|
||||
logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`)
|
||||
return createErrorResponse('Unable to determine deploying user', 400)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
const result = await performFullDeploy({
|
||||
workflowId: id,
|
||||
@@ -130,6 +132,9 @@ export const POST = withRouteHandler(
|
||||
warnings: result.warnings,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to deploy workflow'
|
||||
logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error })
|
||||
return createErrorResponse(message, 500)
|
||||
@@ -159,6 +164,7 @@ export const PATCH = withRouteHandler(
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
if (isPublicApi) {
|
||||
try {
|
||||
@@ -185,6 +191,9 @@ export const PATCH = withRouteHandler(
|
||||
|
||||
return createSuccessResponse({ isPublicApi })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to update deployment settings'
|
||||
logger.error(`[${requestId}] Error updating deployment settings`, { error })
|
||||
@@ -207,6 +216,7 @@ export const DELETE = withRouteHandler(
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
const result = await performFullUndeploy({
|
||||
workflowId: id,
|
||||
@@ -232,6 +242,9 @@ export const DELETE = withRouteHandler(
|
||||
apiKey: null,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to undeploy workflow'
|
||||
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error })
|
||||
return createErrorResponse(message, 500)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
@@ -29,6 +30,7 @@ export const POST = withRouteHandler(
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
const versionValidation = workflowDeploymentVersionParamSchema.safeParse(version)
|
||||
if (!versionValidation.success) {
|
||||
@@ -57,6 +59,10 @@ export const POST = withRouteHandler(
|
||||
lastSaved: result.lastSaved,
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
logger.error('Error reverting to deployment version', error)
|
||||
return createErrorResponse(error.message || 'Failed to revert', 500)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { FolderLockedError } from '@sim/workflow-authz'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
@@ -94,6 +95,10 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json(result, { 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 workflow not found') {
|
||||
logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`)
|
||||
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
|
||||
@@ -105,6 +110,21 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (error.message === 'Cross-workspace workflow duplication is not supported') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
if (error.message === 'Folder is locked') {
|
||||
return NextResponse.json({ error: error.message }, { status: 423 })
|
||||
}
|
||||
|
||||
if (error.message === 'Target folder not found') {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { restoreWorkflowContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
@@ -44,6 +45,11 @@ export const POST = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (workflowData.locked) {
|
||||
throw new WorkflowLockedError('Workflow is locked')
|
||||
}
|
||||
await assertFolderMutable(workflowData.folderId)
|
||||
|
||||
const result = await restoreWorkflow(workflowId, { requestId })
|
||||
|
||||
if (!result.restored) {
|
||||
@@ -78,6 +84,10 @@ export const POST = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertFolderMutable,
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
FolderLockedError,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkflowContract } from '@/lib/api/contracts/workflows'
|
||||
@@ -191,6 +197,8 @@ export const DELETE = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
const deleteTemplatesParam = searchParams.get('deleteTemplates')
|
||||
@@ -245,6 +253,10 @@ export const DELETE = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
@@ -300,12 +312,31 @@ export const PUT = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required to lock workflows' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked')
|
||||
if (hasNonLockUpdate) {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
}
|
||||
if (updates.folderId !== undefined) {
|
||||
await assertFolderMutable(updates.folderId)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (updates.name !== undefined) updateData.name = updates.name
|
||||
if (updates.description !== undefined) updateData.description = updates.description
|
||||
if (updates.color !== undefined) updateData.color = updates.color
|
||||
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
|
||||
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
|
||||
if (updates.locked !== undefined) updateData.locked = updates.locked
|
||||
|
||||
if (updates.name !== undefined || updates.folderId !== undefined) {
|
||||
const targetName = updates.name ?? workflowData.name
|
||||
@@ -359,6 +390,7 @@ export const PUT = withRouteHandler(
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
locked: workflow.locked,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
archivedAt: workflow.archivedAt,
|
||||
@@ -371,6 +403,10 @@ export const PUT = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -2,7 +2,11 @@ import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows'
|
||||
@@ -129,6 +133,8 @@ export const PUT = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
// Note: prior versions cross-checked that each variable's `workflowId`
|
||||
// equalled the path param. The write contract does not carry `workflowId`
|
||||
// per variable (the path param is the source of truth), so the check
|
||||
@@ -272,6 +278,10 @@ export const PUT = withRouteHandler(
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(
|
||||
`[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workflowVariablesContract } from '@/lib/api/contracts/workflows'
|
||||
@@ -50,6 +54,8 @@ export const POST = withRouteHandler(
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
const parsed = await parseRequest(workflowVariablesContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { variables } = parsed.data.body
|
||||
@@ -88,6 +94,10 @@ export const POST = withRouteHandler(
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating workflow variables`, error)
|
||||
return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertFolderMutable,
|
||||
assertWorkflowMutable,
|
||||
FolderLockedError,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/workflow-authz'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows'
|
||||
@@ -50,6 +56,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
for (const update of validUpdates) {
|
||||
await assertWorkflowMutable(update.id)
|
||||
if (update.folderId !== undefined) {
|
||||
await assertFolderMutable(update.folderId)
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
@@ -69,6 +82,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering workflows`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
archivedAt: workflow.archivedAt,
|
||||
locked: workflow.locked,
|
||||
} as const
|
||||
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { duplicateWorkspaceContract } from '@/lib/api/contracts/workspaces'
|
||||
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 { duplicateWorkspace } from '@/lib/workspaces/duplicate'
|
||||
|
||||
const logger = createLogger('WorkspaceDuplicateAPI')
|
||||
|
||||
// POST /api/workspaces/[id]/duplicate - Duplicate a workspace with all its workflows
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: sourceWorkspaceId } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(duplicateWorkspaceContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name } = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}`
|
||||
)
|
||||
|
||||
const result = await duplicateWorkspace({
|
||||
sourceWorkspaceId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
requestId,
|
||||
})
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: sourceWorkspaceId,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.WORKSPACE_DUPLICATED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: result.id,
|
||||
resourceName: name,
|
||||
description: `Duplicated workspace to "${name}"`,
|
||||
metadata: {
|
||||
sourceWorkspaceId,
|
||||
affected: { workflows: result.workflowsCount, folders: result.foldersCount },
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Source workspace not found') {
|
||||
logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`)
|
||||
return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.message === 'Source workspace not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(
|
||||
`[${requestId}] Error duplicating workspace ${sourceWorkspaceId} after ${elapsed}ms:`,
|
||||
error
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
+4
-4
@@ -517,7 +517,7 @@ export function IntegrationsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const [isShareingWithWorkspace, setIsSharingWithWorkspace] = useState(false)
|
||||
const [isSharingWithWorkspace, setIsSharingWithWorkspace] = useState(false)
|
||||
|
||||
const handleShareWithWorkspace = async () => {
|
||||
if (!selectedCredential || !isSelectedAdmin) return
|
||||
@@ -1416,14 +1416,14 @@ export function IntegrationsManager() {
|
||||
}`}
|
||||
</Button>
|
||||
)}
|
||||
{(workspaceUserOptions.length > 0 || isShareingWithWorkspace) && (
|
||||
{(workspaceUserOptions.length > 0 || isSharingWithWorkspace) && (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleShareWithWorkspace}
|
||||
disabled={isShareingWithWorkspace || workspaceUserOptions.length === 0}
|
||||
disabled={isSharingWithWorkspace || workspaceUserOptions.length === 0}
|
||||
>
|
||||
<Share2 className='mr-1.5 h-[13px] w-[13px]' />
|
||||
{isShareingWithWorkspace ? 'Sharing...' : 'Share'}
|
||||
{isSharingWithWorkspace ? 'Sharing...' : 'Share with workspace'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
||||
+2
-1
@@ -495,6 +495,7 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
const isEmpty = content.trim().length === 0
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
|
||||
|
||||
/**
|
||||
* Calculate deterministic dimensions based on content structure.
|
||||
@@ -522,7 +523,7 @@ export const NoteBlock = memo(function NoteBlock({
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--divider)] border-b p-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center'>
|
||||
|
||||
+12
-3
@@ -16,9 +16,15 @@ interface DeployProps {
|
||||
activeWorkflowId: string | null
|
||||
userPermissions: WorkspaceUserPermissions
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
|
||||
export function Deploy({
|
||||
activeWorkflowId,
|
||||
userPermissions,
|
||||
className,
|
||||
disabled = false,
|
||||
}: DeployProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
|
||||
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
|
||||
@@ -50,10 +56,10 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
|
||||
|
||||
const isEmpty = !hasBlocks()
|
||||
const canDeploy = userPermissions.canAdmin
|
||||
const isDisabled = isDeploying || !canDeploy || isEmpty
|
||||
const isDisabled = disabled || isDeploying || !canDeploy || isEmpty
|
||||
|
||||
const onDeployClick = async () => {
|
||||
if (!canDeploy || !activeWorkflowId) return
|
||||
if (disabled || !canDeploy || !activeWorkflowId) return
|
||||
|
||||
const result = await handleDeployClick()
|
||||
if (result.shouldOpenModal) {
|
||||
@@ -68,6 +74,9 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
|
||||
if (!canDeploy) {
|
||||
return 'Admin permissions required'
|
||||
}
|
||||
if (disabled) {
|
||||
return 'Workflow is locked'
|
||||
}
|
||||
if (isDeploying) {
|
||||
return 'Deploying...'
|
||||
}
|
||||
|
||||
+18
-1
@@ -22,6 +22,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface VariableAssignment {
|
||||
id: string
|
||||
@@ -89,6 +90,17 @@ export function VariablesInput({
|
||||
const workflowVariables = useVariablesStore((s) => s.variables)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
/**
|
||||
* Gate destructive cleanup on the active workflow being fully hydrated. Without this gate,
|
||||
* a brief render frame where the variables store has not yet hydrated for the current
|
||||
* workflow would treat all assignments as orphaned and persist an empty array — destroying
|
||||
* legitimate references. Only trust `currentWorkflowVariables` once hydration says it owns
|
||||
* this workflow's data.
|
||||
*/
|
||||
const isHydratedForWorkflow = useWorkflowRegistry(
|
||||
(s) => s.hydration.phase === 'ready' && s.hydration.workflowId === workflowId
|
||||
)
|
||||
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeFieldId, setActiveFieldId] = useState<string | null>(null)
|
||||
@@ -133,6 +145,11 @@ export function VariablesInput({
|
||||
|
||||
useEffect(() => {
|
||||
if (isReadOnly || assignments.length === 0) return
|
||||
// Only purge stale `variableId` references after the variables store is authoritative
|
||||
// for this workflow. Otherwise a transient empty store on mount would wipe valid
|
||||
// assignments — see the duplicate-of-duplicate regression where this destroyed
|
||||
// remapped variable references on the second copy.
|
||||
if (!isHydratedForWorkflow) return
|
||||
|
||||
const currentVariableIds = new Set(currentWorkflowVariables.map((v) => v.id))
|
||||
const validAssignments = assignments.filter((assignment) => {
|
||||
@@ -145,7 +162,7 @@ export function VariablesInput({
|
||||
} else if (validAssignments.length !== assignments.length) {
|
||||
setStoreValue(validAssignments.length > 0 ? validAssignments : [])
|
||||
}
|
||||
}, [currentWorkflowVariables, assignments, isReadOnly, setStoreValue])
|
||||
}, [currentWorkflowVariables, assignments, isReadOnly, isHydratedForWorkflow, setStoreValue])
|
||||
|
||||
const addAssignment = () => {
|
||||
if (isReadOnly || allVariablesAssigned) return
|
||||
|
||||
+14
-8
@@ -48,7 +48,9 @@ import {
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockType } from '@/blocks/types'
|
||||
import { useWorkflowState } from '@/hooks/queries/workflows'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
|
||||
import { useWorkflowMap, useWorkflowState } from '@/hooks/queries/workflows'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -112,15 +114,19 @@ export function Editor() {
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const workflowId = activeWorkflowId ?? (params.workflowId as string | undefined)
|
||||
const { data: workflows = {} } = useWorkflowMap(workspaceId)
|
||||
const { data: folders = {} } = useFolderMap(workspaceId)
|
||||
const workflowMetadata = workflowId ? workflows[workflowId] : undefined
|
||||
const workflowLocked = isWorkflowEffectivelyLocked(workflowMetadata, folders)
|
||||
|
||||
// Check if block is locked (or inside a locked ancestor) and compute edit permission
|
||||
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
|
||||
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
|
||||
const canEditBlock = userPermissions.canEdit && !isLocked
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const canEditBlock = userPermissions.canEdit && !workflowLocked && !isLocked
|
||||
|
||||
const { advancedMode, triggerMode } = useEditorBlockProperties(
|
||||
currentBlockId,
|
||||
@@ -254,19 +260,19 @@ export function Editor() {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return
|
||||
|
||||
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return
|
||||
if (!userPermissions.canEdit || workflowLocked || isBlockProtected(blockId, blocks)) return
|
||||
|
||||
renamingBlockIdRef.current = blockId
|
||||
setEditedName(block.name || '')
|
||||
setIsRenaming(true)
|
||||
}, [userPermissions.canEdit])
|
||||
}, [userPermissions.canEdit, workflowLocked])
|
||||
|
||||
/**
|
||||
* Saves the renamed block using the captured block ID from when rename started.
|
||||
*/
|
||||
const handleSaveRename = useCallback(() => {
|
||||
const blockIdToRename = renamingBlockIdRef.current
|
||||
if (!blockIdToRename || !isRenaming) return
|
||||
if (!blockIdToRename || !isRenaming || workflowLocked) return
|
||||
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const blockToRename = blocks[blockIdToRename]
|
||||
@@ -280,7 +286,7 @@ export function Editor() {
|
||||
}
|
||||
renamingBlockIdRef.current = null
|
||||
setIsRenaming(false)
|
||||
}, [isRenaming, editedName, collaborativeUpdateBlockName])
|
||||
}, [isRenaming, editedName, collaborativeUpdateBlockName, workflowLocked])
|
||||
|
||||
/**
|
||||
* Handles canceling the rename process.
|
||||
|
||||
@@ -72,6 +72,8 @@ import {
|
||||
copilotChatsKeys,
|
||||
useCopilotChats,
|
||||
} from '@/hooks/queries/copilot-chats'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
|
||||
import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -150,6 +152,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
|
||||
const duplicateWorkflowMutation = useDuplicateWorkflowMutation()
|
||||
const { data: workflows = {} } = useWorkflowMap(workspaceId)
|
||||
const { data: folders = {} } = useFolderMap(workspaceId)
|
||||
const { activeWorkflowId, hydration } = useWorkflowRegistry(
|
||||
useShallow((state) => ({
|
||||
activeWorkflowId: state.activeWorkflowId,
|
||||
@@ -234,6 +237,8 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
)
|
||||
|
||||
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
|
||||
const workflowLocked = isWorkflowEffectivelyLocked(currentWorkflow, folders)
|
||||
const canMutateWorkflow = userPermissions.canEdit && !workflowLocked
|
||||
const { isSnapshotView } = useCurrentWorkflow()
|
||||
|
||||
const { chatId: copilotChatId, setChatId: setCopilotChatId } = useCopilotChatSelection(
|
||||
@@ -481,7 +486,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
* Handles auto-layout of workflow blocks
|
||||
*/
|
||||
const handleAutoLayout = useCallback(async () => {
|
||||
if (isExecuting || !userPermissions.canEdit || isAutoLayouting) {
|
||||
if (isExecuting || !canMutateWorkflow || isAutoLayouting) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -498,13 +503,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
} finally {
|
||||
setIsAutoLayouting(false)
|
||||
}
|
||||
}, [
|
||||
isExecuting,
|
||||
userPermissions.canEdit,
|
||||
isAutoLayouting,
|
||||
autoLayoutWithFitView,
|
||||
activeWorkflowId,
|
||||
])
|
||||
}, [isExecuting, canMutateWorkflow, isAutoLayouting, autoLayoutWithFitView, activeWorkflowId])
|
||||
|
||||
/**
|
||||
* Handles exporting workflow as JSON
|
||||
@@ -656,7 +655,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
<DropdownMenuItem
|
||||
onSelect={handleAutoLayout}
|
||||
disabled={
|
||||
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
|
||||
isExecuting || !canMutateWorkflow || isAutoLayouting || hasLockedBlocks
|
||||
}
|
||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
||||
>
|
||||
@@ -668,7 +667,15 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
Variables
|
||||
</DropdownMenuItem>
|
||||
{userPermissions.canAdmin && !isSnapshotView && (
|
||||
<DropdownMenuItem onSelect={handleToggleWorkflowLock} disabled={!hasBlocks}>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleToggleWorkflowLock}
|
||||
disabled={!hasBlocks || workflowLocked}
|
||||
title={
|
||||
workflowLocked
|
||||
? 'Workflow is locked at the row or folder level — release it from the workflow notification or folder menu'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{allBlocksLocked ? <Unlock /> : <Lock />}
|
||||
{allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'}
|
||||
</DropdownMenuItem>
|
||||
@@ -691,7 +698,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
onSelect={() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}}
|
||||
disabled={!userPermissions.canEdit || Object.keys(workflows).length <= 1}
|
||||
disabled={!canMutateWorkflow || Object.keys(workflows).length <= 1}
|
||||
>
|
||||
<Trash />
|
||||
Delete workflow
|
||||
@@ -709,7 +716,11 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
|
||||
{/* Deploy and Run */}
|
||||
<div className='flex gap-1.5' data-tour='deploy-run'>
|
||||
<Deploy activeWorkflowId={activeWorkflowId} userPermissions={userPermissions} />
|
||||
<Deploy
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
userPermissions={userPermissions}
|
||||
disabled={workflowLocked}
|
||||
/>
|
||||
<Button
|
||||
className='h-[30px] gap-2 px-2.5'
|
||||
data-tour='run-button'
|
||||
@@ -943,7 +954,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
</Modal>
|
||||
|
||||
{/* Floating Variables Modal */}
|
||||
<Variables />
|
||||
<Variables readOnly={workflowLocked} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
+5
-4
@@ -26,6 +26,8 @@ export interface SubflowNodeData {
|
||||
name?: string
|
||||
/** Execution status passed by preview/snapshot views */
|
||||
executionStatus?: 'success' | 'error' | 'not-executed'
|
||||
/** Whether the parent workflow is locked and should render as read-only */
|
||||
isWorkflowLocked?: boolean
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = {
|
||||
@@ -61,6 +63,7 @@ const getHandleClasses = (position: 'left' | 'right') => {
|
||||
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
@@ -164,9 +167,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
data-nesting-level={nestingLevel}
|
||||
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
|
||||
>
|
||||
{!isPreview && (
|
||||
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
{!isPreview && <ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
|
||||
|
||||
{/* Header Section */}
|
||||
<div
|
||||
@@ -209,7 +210,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
/>
|
||||
|
||||
{!isPreview && (
|
||||
{!isPreview && canEditWorkflow && (
|
||||
<div
|
||||
className='absolute right-[8px] bottom-2 z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
|
||||
+33
-16
@@ -92,7 +92,11 @@ const STRINGS = {
|
||||
* - Persists position and size
|
||||
* - Uses emcn Input/Code/Combobox components for a consistent UI
|
||||
*/
|
||||
export function Variables() {
|
||||
interface VariablesProps {
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function Variables({ readOnly = false }: VariablesProps) {
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
|
||||
@@ -216,27 +220,29 @@ export function Variables() {
|
||||
}
|
||||
|
||||
const handleAddVariable = useCallback(() => {
|
||||
if (!activeWorkflowId) return
|
||||
if (!activeWorkflowId || readOnly) return
|
||||
collaborativeAddVariable({
|
||||
name: '',
|
||||
type: 'plain',
|
||||
value: '',
|
||||
workflowId: activeWorkflowId,
|
||||
})
|
||||
}, [activeWorkflowId, collaborativeAddVariable])
|
||||
}, [activeWorkflowId, collaborativeAddVariable, readOnly])
|
||||
|
||||
const handleRemoveVariable = useCallback(
|
||||
(variableId: string) => {
|
||||
if (readOnly) return
|
||||
collaborativeDeleteVariable(variableId)
|
||||
},
|
||||
[collaborativeDeleteVariable]
|
||||
[collaborativeDeleteVariable, readOnly]
|
||||
)
|
||||
|
||||
const handleUpdateVariable = useCallback(
|
||||
(variableId: string, field: 'name' | 'value' | 'type', value: any) => {
|
||||
if (readOnly) return
|
||||
collaborativeUpdateVariable(variableId, field, value)
|
||||
},
|
||||
[collaborativeUpdateVariable]
|
||||
[collaborativeUpdateVariable, readOnly]
|
||||
)
|
||||
const isDuplicateName = useCallback(
|
||||
(variableId: string, name: string): boolean => {
|
||||
@@ -249,17 +255,22 @@ export function Variables() {
|
||||
[workflowVariables]
|
||||
)
|
||||
|
||||
const handleVariableNameChange = useCallback((variableId: string, newName: string) => {
|
||||
const validatedName = validateName(newName)
|
||||
setLocalNames((prev) => ({
|
||||
...prev,
|
||||
[variableId]: validatedName,
|
||||
}))
|
||||
clearVariableState(variableId, false)
|
||||
}, [])
|
||||
const handleVariableNameChange = useCallback(
|
||||
(variableId: string, newName: string) => {
|
||||
if (readOnly) return
|
||||
const validatedName = validateName(newName)
|
||||
setLocalNames((prev) => ({
|
||||
...prev,
|
||||
[variableId]: validatedName,
|
||||
}))
|
||||
clearVariableState(variableId, false)
|
||||
},
|
||||
[readOnly]
|
||||
)
|
||||
|
||||
const handleVariableNameBlur = useCallback(
|
||||
(variableId: string) => {
|
||||
if (readOnly) return
|
||||
const localName = localNames[variableId]
|
||||
if (localName === undefined) return
|
||||
|
||||
@@ -283,7 +294,7 @@ export function Variables() {
|
||||
collaborativeUpdateVariable(variableId, 'name', trimmedName)
|
||||
clearVariableState(variableId)
|
||||
},
|
||||
[localNames, isDuplicateName, collaborativeUpdateVariable]
|
||||
[localNames, isDuplicateName, collaborativeUpdateVariable, readOnly]
|
||||
)
|
||||
|
||||
const handleVariableNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -326,6 +337,7 @@ export function Variables() {
|
||||
handleRemoveVariable(variable.id)
|
||||
}}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover-hover:text-[var(--text-error)]'
|
||||
disabled={readOnly}
|
||||
aria-label={`Delete ${variable.name || `variable ${index + 1}`}`}
|
||||
>
|
||||
<Trash style={{ width: `${ICON_SIZE}px`, height: `${ICON_SIZE}px` }} />
|
||||
@@ -334,7 +346,7 @@ export function Variables() {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[collapsedById, toggleCollapsed, handleRemoveVariable]
|
||||
[collapsedById, toggleCollapsed, handleRemoveVariable, readOnly]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -380,6 +392,7 @@ export function Variables() {
|
||||
onValueChange={(newValue) => handleUpdateVariable(variable.id, 'value', newValue)}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
{...getCodeEditorProps()}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Code.Content>
|
||||
</Code.Container>
|
||||
@@ -392,6 +405,7 @@ export function Variables() {
|
||||
autoComplete='off'
|
||||
value={variableValue}
|
||||
onChange={(e) => handleUpdateVariable(variable.id, 'value', e.target.value)}
|
||||
disabled={readOnly}
|
||||
placeholder={
|
||||
variable.type === 'number'
|
||||
? STRINGS.placeholders.number
|
||||
@@ -402,7 +416,7 @@ export function Variables() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
[handleUpdateVariable]
|
||||
[handleUpdateVariable, readOnly]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -440,6 +454,7 @@ export function Variables() {
|
||||
e.stopPropagation()
|
||||
handleAddVariable()
|
||||
}}
|
||||
disabled={readOnly}
|
||||
aria-label='Add new variable'
|
||||
>
|
||||
<Plus style={{ width: `${HEADER_ICON_SIZE}px`, height: `${HEADER_ICON_SIZE}px` }} />
|
||||
@@ -489,6 +504,7 @@ export function Variables() {
|
||||
onBlur={() => handleVariableNameBlur(variable.id)}
|
||||
onKeyDown={handleVariableNameKeyDown}
|
||||
placeholder={STRINGS.placeholders.name}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{nameErrors[variable.id] && (
|
||||
<p className='text-[var(--text-error)] text-xs' role='alert'>
|
||||
@@ -503,6 +519,7 @@ export function Variables() {
|
||||
options={TYPE_OPTIONS}
|
||||
value={variable.type}
|
||||
onChange={(value) => handleUpdateVariable(variable.id, 'type', value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface WorkflowBlockProps {
|
||||
isEmbedded?: boolean
|
||||
/** Whether this block is rendered inside a sandbox (academy exercise) view with no workspace API calls */
|
||||
isSandbox?: boolean
|
||||
/** Whether the parent workflow is locked and should render as read-only */
|
||||
isWorkflowLocked?: boolean
|
||||
subBlockValues?: Record<string, any>
|
||||
blockState?: any
|
||||
}
|
||||
|
||||
+6
-11
@@ -924,6 +924,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployWorkflow()
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
|
||||
|
||||
const currentStoreBlock = currentWorkflow.getBlockById(id)
|
||||
|
||||
@@ -980,7 +981,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
{}
|
||||
)
|
||||
|
||||
const effectiveAdvanced = userPermissions.canEdit
|
||||
const effectiveAdvanced = canEditWorkflow
|
||||
? displayAdvancedMode
|
||||
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
|
||||
const effectiveTrigger = displayTriggerMode
|
||||
@@ -1055,7 +1056,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
currentWorkflow.isDiffMode,
|
||||
currentBlock,
|
||||
canonicalModeOverrides,
|
||||
userPermissions.canEdit,
|
||||
canEditWorkflow,
|
||||
canonicalIndex,
|
||||
hiddenByReactiveCondition,
|
||||
blockSubBlockValues,
|
||||
@@ -1075,16 +1076,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
},
|
||||
{}
|
||||
)
|
||||
return userPermissions.canEdit
|
||||
return canEditWorkflow
|
||||
? displayAdvancedMode
|
||||
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
|
||||
}, [
|
||||
subBlockState,
|
||||
displayAdvancedMode,
|
||||
config.subBlocks,
|
||||
canonicalIndex,
|
||||
userPermissions.canEdit,
|
||||
])
|
||||
}, [subBlockState, displayAdvancedMode, config.subBlocks, canonicalIndex, canEditWorkflow])
|
||||
|
||||
/**
|
||||
* Determine if block has content below the header (subblocks or error row).
|
||||
@@ -1197,7 +1192,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
)}
|
||||
|
||||
{!data.isPreview && !data.isEmbedded && (
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
|
||||
)}
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
|
||||
@@ -78,8 +78,13 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/constants'
|
||||
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
|
||||
import { useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
import {
|
||||
findLockedAncestorFolder,
|
||||
isFolderOrAncestorLocked,
|
||||
} from '@/hooks/queries/utils/folder-tree'
|
||||
import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
|
||||
@@ -264,6 +269,8 @@ const WorkflowContent = React.memo(
|
||||
isLoading: isWorkflowMapLoading,
|
||||
isPlaceholderData: isWorkflowMapPlaceholderData,
|
||||
} = useWorkflowMap(workspaceId)
|
||||
const { data: folders = {} } = useFolderMap(workspaceId)
|
||||
const updateWorkflowMutation = useUpdateWorkflow()
|
||||
|
||||
const {
|
||||
activeWorkflowId,
|
||||
@@ -330,11 +337,21 @@ const WorkflowContent = React.memo(
|
||||
)
|
||||
|
||||
const { blocks, edges, lastSaved } = currentWorkflow
|
||||
const workflowMetadata = workflows[workflowIdParam]
|
||||
const workflowRowLocked = !!workflowMetadata?.locked
|
||||
const workflowFolderLocked = isFolderOrAncestorLocked(workflowMetadata?.folderId, folders)
|
||||
|
||||
const allBlocksLocked = useMemo(() => {
|
||||
const blockList = Object.values(blocks)
|
||||
return blockList.length > 0 && blockList.every((b) => b.locked)
|
||||
}, [blocks])
|
||||
const workflowLocked = workflowRowLocked || workflowFolderLocked
|
||||
const workflowReadOnly = workflowLocked && !sandbox
|
||||
const canvasOpacityClass = isCanvasReady
|
||||
? workflowReadOnly
|
||||
? 'opacity-60'
|
||||
: 'opacity-100'
|
||||
: 'opacity-0'
|
||||
|
||||
const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks])
|
||||
|
||||
@@ -583,18 +600,18 @@ const WorkflowContent = React.memo(
|
||||
|
||||
const { userPermissions, workspacePermissions, permissionsError } =
|
||||
useWorkspacePermissionsContext()
|
||||
/** Returns read-only permissions when viewing snapshot, otherwise user permissions. */
|
||||
/** Returns read-only permissions when viewing snapshot or a locked workflow. */
|
||||
const effectivePermissions = useMemo(() => {
|
||||
if (currentWorkflow.isSnapshotView) {
|
||||
if (currentWorkflow.isSnapshotView || workflowReadOnly) {
|
||||
return {
|
||||
...userPermissions,
|
||||
canEdit: false,
|
||||
canAdmin: false,
|
||||
canAdmin: currentWorkflow.isSnapshotView ? false : userPermissions.canAdmin,
|
||||
canRead: userPermissions.canRead,
|
||||
}
|
||||
}
|
||||
return userPermissions
|
||||
}, [userPermissions, currentWorkflow.isSnapshotView])
|
||||
}, [userPermissions, currentWorkflow.isSnapshotView, workflowReadOnly])
|
||||
const {
|
||||
collaborativeBatchAddEdges,
|
||||
collaborativeBatchRemoveEdges,
|
||||
@@ -1199,7 +1216,6 @@ const WorkflowContent = React.memo(
|
||||
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
|
||||
}, [collaborativeBatchToggleLocked])
|
||||
|
||||
// Show notification when all blocks in the workflow are locked
|
||||
const lockNotificationIdRef = useRef<string | null>(null)
|
||||
|
||||
const clearLockNotification = useCallback(() => {
|
||||
@@ -1226,35 +1242,66 @@ const WorkflowContent = React.memo(
|
||||
}
|
||||
}, [activeWorkflowId, clearLockNotification])
|
||||
|
||||
/**
|
||||
* Locate the folder ancestor that supplies the inherited lock so the
|
||||
* notification can name it. Null when the lock is row/block-level instead.
|
||||
*/
|
||||
const inheritedLockFolderName = useMemo(() => {
|
||||
if (!workflowFolderLocked) return null
|
||||
return findLockedAncestorFolder(workflowMetadata?.folderId, folders)?.name ?? null
|
||||
}, [workflowFolderLocked, workflowMetadata?.folderId, folders])
|
||||
|
||||
const prevCanAdminRef = useRef(effectivePermissions.canAdmin)
|
||||
const prevLockSignatureRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!isWorkflowReady) return
|
||||
|
||||
const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin
|
||||
prevCanAdminRef.current = effectivePermissions.canAdmin
|
||||
|
||||
// Clear stale notification when admin status changes so it recreates with correct message
|
||||
if (canAdminChanged) {
|
||||
const lockSignature = workflowReadOnly
|
||||
? workflowRowLocked
|
||||
? 'row'
|
||||
: `folder:${inheritedLockFolderName ?? ''}`
|
||||
: null
|
||||
const lockSignatureChanged = prevLockSignatureRef.current !== lockSignature
|
||||
prevLockSignatureRef.current = lockSignature
|
||||
|
||||
if (canAdminChanged || lockSignatureChanged) {
|
||||
clearLockNotification()
|
||||
}
|
||||
|
||||
if (allBlocksLocked && !sandbox) {
|
||||
if (workflowReadOnly) {
|
||||
if (lockNotificationIdRef.current) return
|
||||
|
||||
const isAdmin = effectivePermissions.canAdmin
|
||||
const isFolderInherited = workflowFolderLocked && !workflowRowLocked
|
||||
const message = isFolderInherited
|
||||
? inheritedLockFolderName
|
||||
? `This workflow is locked by folder "${inheritedLockFolderName}"`
|
||||
: 'This workflow is locked by a parent folder'
|
||||
: isAdmin
|
||||
? 'This workflow is locked'
|
||||
: 'This workflow is locked. Ask an admin to unlock it.'
|
||||
|
||||
const showInlineUnlock = isAdmin && !isFolderInherited
|
||||
|
||||
lockNotificationIdRef.current = addNotification({
|
||||
level: 'info',
|
||||
message: isAdmin
|
||||
? 'This workflow is locked'
|
||||
: 'This workflow is locked. Ask an admin to unlock it.',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
...(isAdmin ? { action: { type: 'unlock-workflow' as const, message: '' } } : {}),
|
||||
...(showInlineUnlock
|
||||
? { action: { type: 'unlock-workflow' as const, message: '' } }
|
||||
: {}),
|
||||
})
|
||||
} else {
|
||||
clearLockNotification()
|
||||
}
|
||||
}, [
|
||||
allBlocksLocked,
|
||||
workflowReadOnly,
|
||||
workflowRowLocked,
|
||||
workflowFolderLocked,
|
||||
inheritedLockFolderName,
|
||||
isWorkflowReady,
|
||||
effectivePermissions.canAdmin,
|
||||
addNotification,
|
||||
@@ -1265,17 +1312,26 @@ const WorkflowContent = React.memo(
|
||||
// Clean up notification on unmount
|
||||
useEffect(() => clearLockNotification, [clearLockNotification])
|
||||
|
||||
// Listen for unlock-workflow events from notification action button
|
||||
/**
|
||||
* `mutate` is the only stable handle on a TanStack Query v5 mutation; the
|
||||
* mutation object itself rebuilds on every state change (e.g. `isPending`
|
||||
* flipping during the unlock call), so depend on `.mutate` directly.
|
||||
*/
|
||||
const updateWorkflowMutate = updateWorkflowMutation.mutate
|
||||
useEffect(() => {
|
||||
const handleUnlockWorkflow = () => {
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const ids = getWorkflowLockToggleIds(currentBlocks, false)
|
||||
if (ids.length > 0) collaborativeBatchToggleLocked(ids)
|
||||
if (workflowRowLocked && activeWorkflowId) {
|
||||
updateWorkflowMutate({
|
||||
workspaceId,
|
||||
workflowId: activeWorkflowId,
|
||||
metadata: { locked: false },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('unlock-workflow', handleUnlockWorkflow)
|
||||
return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow)
|
||||
}, [collaborativeBatchToggleLocked])
|
||||
}, [activeWorkflowId, updateWorkflowMutate, workflowRowLocked, workspaceId])
|
||||
|
||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||
const blocksToRemove = contextMenuBlocks.filter(
|
||||
@@ -2397,7 +2453,7 @@ const WorkflowContent = React.memo(
|
||||
parentId: block.data?.parentId,
|
||||
extent: block.data?.extent || undefined,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
draggable: !workflowReadOnly && !isBlockProtected(block.id, blocks),
|
||||
zIndex: depth,
|
||||
className: block.data?.parentId ? 'nested-subflow-node' : undefined,
|
||||
data: {
|
||||
@@ -2406,6 +2462,7 @@ const WorkflowContent = React.memo(
|
||||
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
||||
isWorkflowLocked: workflowReadOnly,
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -2441,7 +2498,7 @@ const WorkflowContent = React.memo(
|
||||
position,
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle,
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
draggable: !workflowReadOnly && !isBlockProtected(block.id, blocks),
|
||||
...(childZIndex !== undefined && { zIndex: childZIndex }),
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
@@ -2470,6 +2527,7 @@ const WorkflowContent = React.memo(
|
||||
isPending,
|
||||
...(embedded && { isEmbedded: true }),
|
||||
...(sandbox && { isSandbox: true }),
|
||||
isWorkflowLocked: workflowReadOnly,
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
@@ -2491,6 +2549,7 @@ const WorkflowContent = React.memo(
|
||||
getBlockConfig,
|
||||
sandbox,
|
||||
embedded,
|
||||
workflowReadOnly,
|
||||
])
|
||||
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
@@ -4053,7 +4112,7 @@ const WorkflowContent = React.memo(
|
||||
noWheelClassName='allow-scroll'
|
||||
edgesFocusable={!embedded}
|
||||
edgesUpdatable={!embedded && effectivePermissions.canEdit}
|
||||
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${canvasOpacityClass} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onSelectionDragStart={
|
||||
|
||||
+39
-10
@@ -32,8 +32,12 @@ import {
|
||||
useExportFolder,
|
||||
useExportSelection,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateFolder, useFolderMap, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import {
|
||||
isFolderEffectivelyLocked,
|
||||
isFolderOrAncestorLocked,
|
||||
} from '@/hooks/queries/utils/folder-tree'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
@@ -76,6 +80,10 @@ export function FolderItem({
|
||||
const selectedFolders = useFolderStore((state) => state.selectedFolders)
|
||||
const isSelected = selectedFolders.has(folder.id)
|
||||
|
||||
const { data: foldersById = {} } = useFolderMap(workspaceId)
|
||||
const inheritedFolderLocked = isFolderOrAncestorLocked(folder.parentId, foldersById)
|
||||
const effectiveLocked = isFolderEffectivelyLocked(folder, foldersById)
|
||||
|
||||
const { canDeleteFolder, canDeleteWorkflows } = useCanDelete({ workspaceId })
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
@@ -144,6 +152,7 @@ export function FolderItem({
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const handleCreateWorkflowInFolder = useCallback(() => {
|
||||
if (effectiveLocked) return
|
||||
const name = generateCreativeWorkflowName()
|
||||
const color = getNextWorkflowColor()
|
||||
const id = generateId()
|
||||
@@ -160,9 +169,10 @@ export function FolderItem({
|
||||
expandFolder()
|
||||
router.push(`/workspace/${workspaceId}/w/${id}`)
|
||||
window.dispatchEvent(new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: id } }))
|
||||
}, [createWorkflowMutation, workspaceId, folder.id, router, expandFolder])
|
||||
}, [createWorkflowMutation, workspaceId, folder.id, effectiveLocked, router, expandFolder])
|
||||
|
||||
const handleCreateFolderInFolder = useCallback(async () => {
|
||||
if (effectiveLocked) return
|
||||
try {
|
||||
const result = await createFolderMutation.mutateAsync({
|
||||
workspaceId,
|
||||
@@ -179,7 +189,16 @@ export function FolderItem({
|
||||
} catch (error) {
|
||||
logger.error('Failed to create folder:', error)
|
||||
}
|
||||
}, [createFolderMutation, workspaceId, folder.id, expandFolder])
|
||||
}, [createFolderMutation, workspaceId, folder.id, effectiveLocked, expandFolder])
|
||||
|
||||
const handleToggleLock = useCallback(() => {
|
||||
if (inheritedFolderLocked) return
|
||||
updateFolderMutation.mutate({
|
||||
workspaceId,
|
||||
id: folder.id,
|
||||
updates: { locked: !folder.locked },
|
||||
})
|
||||
}, [folder.id, folder.locked, inheritedFolderLocked, updateFolderMutation, workspaceId])
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -327,9 +346,11 @@ export function FolderItem({
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleStartEdit()
|
||||
if (!effectiveLocked) {
|
||||
handleStartEdit()
|
||||
}
|
||||
},
|
||||
[handleStartEdit]
|
||||
[effectiveLocked, handleStartEdit]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(
|
||||
@@ -479,7 +500,7 @@ export function FolderItem({
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={!isEditing && !dragDisabled}
|
||||
draggable={!isEditing && !dragDisabled && !effectiveLocked}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
{...hoverHandlers}
|
||||
@@ -562,14 +583,22 @@ export function FolderItem({
|
||||
showRename={!isMixedSelection && selectedFolders.size <= 1}
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
||||
disableRename={!userPermissions.canEdit || effectiveLocked}
|
||||
disableCreate={
|
||||
!userPermissions.canEdit || effectiveLocked || createWorkflowMutation.isPending
|
||||
}
|
||||
disableCreateFolder={
|
||||
!userPermissions.canEdit || effectiveLocked || createFolderMutation.isPending
|
||||
}
|
||||
disableDuplicate={
|
||||
!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent
|
||||
}
|
||||
disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent}
|
||||
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
|
||||
disableDelete={!userPermissions.canEdit || effectiveLocked || !canDeleteSelection}
|
||||
onToggleLock={handleToggleLock}
|
||||
showLock={!isMixedSelection && selectedFolders.size <= 1}
|
||||
disableLock={!userPermissions.canAdmin || inheritedFolderLocked}
|
||||
isLocked={effectiveLocked}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
|
||||
+27
-33
@@ -8,7 +8,6 @@ import { useParams } from 'next/navigation'
|
||||
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { workflowBorderColor } from '@/lib/workspaces/colors'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
|
||||
@@ -31,13 +30,16 @@ import {
|
||||
useExportSelection,
|
||||
useExportWorkflow,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import {
|
||||
isFolderOrAncestorLocked,
|
||||
isWorkflowEffectivelyLocked,
|
||||
} from '@/hooks/queries/utils/folder-tree'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
interface WorkflowItemProps {
|
||||
workflow: WorkflowMetadata
|
||||
@@ -73,6 +75,10 @@ export function WorkflowItem({
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isSelected = selectedWorkflows.has(workflow.id)
|
||||
|
||||
const { data: foldersById = {} } = useFolderMap(workspaceId)
|
||||
const inheritedFolderLocked = isFolderOrAncestorLocked(workflow.folderId, foldersById)
|
||||
const effectiveLocked = isWorkflowEffectivelyLocked(workflow, foldersById)
|
||||
|
||||
const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId })
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
@@ -175,33 +181,20 @@ export function WorkflowItem({
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
if (effectiveLocked) return
|
||||
updateWorkflowMutation.mutate({ workspaceId, workflowId: workflow.id, metadata: { color } })
|
||||
},
|
||||
[workflow.id, workspaceId]
|
||||
)
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
const isActiveWorkflow = workflow.id === activeWorkflowId
|
||||
|
||||
const isWorkflowLocked = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!isActiveWorkflow) return false
|
||||
const blockValues = Object.values(state.blocks)
|
||||
if (blockValues.length === 0) return false
|
||||
return blockValues.every((block) => block.locked)
|
||||
},
|
||||
[isActiveWorkflow]
|
||||
)
|
||||
[updateWorkflowMutation, workflow.id, effectiveLocked, workspaceId]
|
||||
)
|
||||
|
||||
const handleToggleLock = useCallback(() => {
|
||||
if (!isActiveWorkflow) return
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const blockIds = getWorkflowLockToggleIds(blocks, !isWorkflowLocked)
|
||||
if (blockIds.length === 0) return
|
||||
window.dispatchEvent(new CustomEvent('toggle-workflow-lock', { detail: { blockIds } }))
|
||||
}, [isActiveWorkflow, isWorkflowLocked])
|
||||
if (inheritedFolderLocked) return
|
||||
updateWorkflowMutation.mutate({
|
||||
workspaceId,
|
||||
workflowId: workflow.id,
|
||||
metadata: { locked: !workflow.locked },
|
||||
})
|
||||
}, [updateWorkflowMutation, workflow.id, workflow.locked, inheritedFolderLocked, workspaceId])
|
||||
|
||||
const isEditingRef = useRef(false)
|
||||
const dragGhostRef = useRef<HTMLElement | null>(null)
|
||||
@@ -387,9 +380,10 @@ export function WorkflowItem({
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (effectiveLocked) return
|
||||
handleStartEdit()
|
||||
},
|
||||
[handleStartEdit]
|
||||
[handleStartEdit, effectiveLocked]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(
|
||||
@@ -430,7 +424,7 @@ export function WorkflowItem({
|
||||
'hover-hover:bg-[var(--surface-hover)]',
|
||||
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
|
||||
)}
|
||||
draggable={!isEditing && !dragDisabled}
|
||||
draggable={!isEditing && !dragDisabled && !effectiveLocked}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={handleClick}
|
||||
@@ -512,15 +506,15 @@ export function WorkflowItem({
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
showColorChange={!isMixedSelection && selectedWorkflows.size <= 1}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableRename={!userPermissions.canEdit || effectiveLocked}
|
||||
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
|
||||
disableExport={!userPermissions.canEdit}
|
||||
disableColorChange={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
|
||||
disableColorChange={!userPermissions.canEdit || effectiveLocked}
|
||||
disableDelete={!userPermissions.canEdit || !canDeleteSelection || effectiveLocked}
|
||||
onToggleLock={handleToggleLock}
|
||||
showLock={isActiveWorkflow && !isMixedSelection && selectedWorkflows.size <= 1}
|
||||
disableLock={!userPermissions.canAdmin}
|
||||
isLocked={isWorkflowLocked}
|
||||
showLock={!isMixedSelection && selectedWorkflows.size <= 1}
|
||||
disableLock={!userPermissions.canAdmin || inheritedFolderLocked}
|
||||
isLocked={effectiveLocked}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
|
||||
-38
@@ -66,14 +66,6 @@ interface WorkspaceHeaderProps {
|
||||
onDeleteWorkspace: (workspaceId: string) => Promise<void>
|
||||
/** Whether workspace deletion is in progress */
|
||||
isDeletingWorkspace: boolean
|
||||
/** Callback to duplicate the workspace */
|
||||
onDuplicateWorkspace: (workspaceId: string, workspaceName: string) => Promise<void>
|
||||
/** Callback to export the workspace */
|
||||
onExportWorkspace: (workspaceId: string, workspaceName: string) => Promise<void>
|
||||
/** Callback to import workspace */
|
||||
onImportWorkspace: () => void
|
||||
/** Whether workspace import is in progress */
|
||||
isImportingWorkspace: boolean
|
||||
/** Callback to change the workspace color */
|
||||
onColorChange?: (workspaceId: string, color: string) => Promise<void>
|
||||
/** Callback to upload a workspace logo */
|
||||
@@ -107,10 +99,6 @@ function WorkspaceHeaderImpl({
|
||||
onRenameWorkspace,
|
||||
onDeleteWorkspace,
|
||||
isDeletingWorkspace,
|
||||
onDuplicateWorkspace,
|
||||
onExportWorkspace,
|
||||
onImportWorkspace,
|
||||
isImportingWorkspace,
|
||||
onColorChange,
|
||||
onUploadLogo,
|
||||
onRemoveLogo,
|
||||
@@ -285,25 +273,6 @@ function WorkspaceHeaderImpl({
|
||||
setIsWorkspaceMenuOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles duplicate action from context menu
|
||||
*/
|
||||
const handleDuplicateAction = async () => {
|
||||
if (!capturedWorkspaceRef.current) return
|
||||
|
||||
await onDuplicateWorkspace(capturedWorkspaceRef.current.id, capturedWorkspaceRef.current.name)
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles export action from context menu
|
||||
*/
|
||||
const handleExportAction = async () => {
|
||||
if (!capturedWorkspaceRef.current) return
|
||||
|
||||
await onExportWorkspace(capturedWorkspaceRef.current.id, capturedWorkspaceRef.current.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles delete action from context menu
|
||||
*/
|
||||
@@ -759,7 +728,6 @@ function WorkspaceHeaderImpl({
|
||||
{/* Context Menu */}
|
||||
{(() => {
|
||||
const capturedPermissions = capturedWorkspaceRef.current?.permissions
|
||||
const contextCanEdit = capturedPermissions === 'admin' || capturedPermissions === 'write'
|
||||
const contextCanAdmin = capturedPermissions === 'admin'
|
||||
const capturedWorkspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
|
||||
const isOwner = capturedWorkspace && sessionUserId === capturedWorkspace.ownerId
|
||||
@@ -771,8 +739,6 @@ function WorkspaceHeaderImpl({
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
onRename={handleRenameAction}
|
||||
onDuplicate={handleDuplicateAction}
|
||||
onExport={handleExportAction}
|
||||
onDelete={handleDeleteAction}
|
||||
onLeave={handleLeaveAction}
|
||||
onColorChange={onColorChange ? handleColorChangeAction : undefined}
|
||||
@@ -780,15 +746,11 @@ function WorkspaceHeaderImpl({
|
||||
onRemoveLogo={onRemoveLogo ? handleRemoveLogoAction : undefined}
|
||||
currentColor={capturedWorkspace?.color}
|
||||
showRename={true}
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
showColorChange={!!onColorChange}
|
||||
showUploadLogo={!!onUploadLogo}
|
||||
showRemoveLogo={!!onRemoveLogo && !!capturedWorkspace?.logoUrl}
|
||||
showLeave={!isOwner && !!onLeaveWorkspace}
|
||||
disableRename={!contextCanAdmin}
|
||||
disableDuplicate={!contextCanEdit}
|
||||
disableExport={!contextCanAdmin}
|
||||
disableDelete={!contextCanAdmin || workspaces.length <= 1}
|
||||
disableColorChange={!contextCanAdmin}
|
||||
disableUploadLogo={!contextCanAdmin}
|
||||
|
||||
@@ -79,12 +79,7 @@ import {
|
||||
createSidebarDragGhost,
|
||||
groupWorkflowsByFolder,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
|
||||
import {
|
||||
useDuplicateWorkspace,
|
||||
useExportWorkspace,
|
||||
useImportWorkflow,
|
||||
useImportWorkspace,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
|
||||
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
|
||||
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
|
||||
@@ -392,14 +387,9 @@ export const Sidebar = memo(function Sidebar() {
|
||||
setShowCollapsedTooltips(false)
|
||||
}, [isCollapsed])
|
||||
|
||||
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { isImporting, handleFileChange: handleImportFileChange } = useImportWorkflow({
|
||||
workspaceId,
|
||||
})
|
||||
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
|
||||
useImportWorkspace()
|
||||
const { handleExportWorkspace: exportWorkspace } = useExportWorkspace()
|
||||
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
|
||||
@@ -670,10 +660,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
]
|
||||
)
|
||||
|
||||
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const searchModalWorkflows = useMemo(
|
||||
() =>
|
||||
regularWorkflows.map((workflow) => {
|
||||
@@ -1109,29 +1095,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[workspaces, handleLeaveWorkspace]
|
||||
)
|
||||
|
||||
const handleDuplicateWorkspace = useCallback(
|
||||
async (_workspaceIdToDuplicate: string, workspaceName: string) => {
|
||||
await duplicateWorkspace(workspaceName)
|
||||
},
|
||||
[duplicateWorkspace]
|
||||
)
|
||||
|
||||
const handleImportWorkspace = useCallback(() => {
|
||||
workspaceFileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleWorkspaceFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const zipFile = files[0]
|
||||
await importWorkspace(zipFile)
|
||||
|
||||
if (event.target) {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const tasksCollapsedIcon = useMemo(
|
||||
() => <Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />,
|
||||
[]
|
||||
@@ -1392,10 +1355,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
onRenameWorkspace={handleRenameWorkspace}
|
||||
onDeleteWorkspace={handleDeleteWorkspace}
|
||||
isDeletingWorkspace={isDeletingWorkspace}
|
||||
onDuplicateWorkspace={handleDuplicateWorkspace}
|
||||
onExportWorkspace={exportWorkspace}
|
||||
onImportWorkspace={handleImportWorkspace}
|
||||
isImportingWorkspace={isImportingWorkspace}
|
||||
onColorChange={handleColorChangeWorkspace}
|
||||
onUploadLogo={handleUploadLogo}
|
||||
onRemoveLogo={handleRemoveLogo}
|
||||
@@ -1866,13 +1825,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
workflowId={workflowId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<input
|
||||
ref={workspaceFileInputRef}
|
||||
type='file'
|
||||
accept='.zip'
|
||||
style={HIDDEN_STYLE}
|
||||
onChange={handleWorkspaceFileChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,10 +5,7 @@ export { useDeleteWorkflow } from './use-delete-workflow'
|
||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||
export { useDuplicateSelection } from './use-duplicate-selection'
|
||||
export { useDuplicateWorkflow } from './use-duplicate-workflow'
|
||||
export { useDuplicateWorkspace } from './use-duplicate-workspace'
|
||||
export { useExportFolder } from './use-export-folder'
|
||||
export { useExportSelection } from './use-export-selection'
|
||||
export { useExportWorkflow } from './use-export-workflow'
|
||||
export { useExportWorkspace } from './use-export-workspace'
|
||||
export { useImportWorkflow } from './use-import-workflow'
|
||||
export { useImportWorkspace } from './use-import-workspace'
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { duplicateWorkspaceContract } from '@/lib/api/contracts'
|
||||
|
||||
const logger = createLogger('useDuplicateWorkspace')
|
||||
|
||||
interface UseDuplicateWorkspaceProps {
|
||||
/**
|
||||
* The workspace ID to duplicate
|
||||
*/
|
||||
workspaceId: string | null
|
||||
/**
|
||||
* Optional callback after successful duplication
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workspace duplication.
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Duplicate workspace handlers and state
|
||||
*/
|
||||
export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
|
||||
const router = useRouter()
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
|
||||
/**
|
||||
* Duplicate the workspace
|
||||
*/
|
||||
const handleDuplicateWorkspace = useCallback(
|
||||
async (workspaceName: string) => {
|
||||
if (isDuplicating || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDuplicating(true)
|
||||
try {
|
||||
const duplicatedWorkspace = await requestJson(duplicateWorkspaceContract, {
|
||||
params: { id: workspaceId },
|
||||
body: { name: `${workspaceName} (Copy)` },
|
||||
})
|
||||
|
||||
logger.info('Workspace duplicated successfully', {
|
||||
sourceWorkspaceId: workspaceId,
|
||||
newWorkspaceId: duplicatedWorkspace.id,
|
||||
workflowsCount: duplicatedWorkspace.workflowsCount,
|
||||
})
|
||||
|
||||
router.push(`/workspace/${duplicatedWorkspace.id}/home`)
|
||||
|
||||
onSuccess?.()
|
||||
|
||||
return duplicatedWorkspace.id
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating workspace:', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsDuplicating(false)
|
||||
}
|
||||
},
|
||||
[workspaceId, isDuplicating, router, onSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
isDuplicating,
|
||||
handleDuplicateWorkspace,
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getWorkspaceContract,
|
||||
listFoldersContract,
|
||||
listWorkflowsContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import {
|
||||
downloadFile,
|
||||
exportWorkspaceToZip,
|
||||
type FolderExportData,
|
||||
fetchWorkflowForExport,
|
||||
sanitizePathSegment,
|
||||
type WorkflowExportData,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
|
||||
const logger = createLogger('useExportWorkspace')
|
||||
|
||||
interface UseExportWorkspaceProps {
|
||||
/**
|
||||
* Optional callback after successful export
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workspace export to ZIP.
|
||||
*/
|
||||
export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExportWorkspace = useCallback(
|
||||
async (workspaceId: string, workspaceName: string) => {
|
||||
if (isExporting) return
|
||||
|
||||
setIsExporting(true)
|
||||
try {
|
||||
logger.info('Exporting workspace', { workspaceId })
|
||||
|
||||
const { data: workflows } = await requestJson(listWorkflowsContract, {
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
const foldersData = await requestJson(listFoldersContract, {
|
||||
query: { workspaceId },
|
||||
})
|
||||
|
||||
const workflowsToExport: WorkflowExportData[] = []
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const exportData = await fetchWorkflowForExport(workflow.id, {
|
||||
name: workflow.name,
|
||||
description: workflow.description ?? undefined,
|
||||
color: workflow.color,
|
||||
folderId: workflow.folderId,
|
||||
})
|
||||
|
||||
if (exportData) {
|
||||
workflowsToExport.push(exportData)
|
||||
}
|
||||
}
|
||||
|
||||
const foldersToExport: FolderExportData[] = (foldersData.folders || []).map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
}))
|
||||
|
||||
let workspaceColor: string | undefined
|
||||
try {
|
||||
const workspaceData = await requestJson(getWorkspaceContract, {
|
||||
params: { id: workspaceId },
|
||||
})
|
||||
workspaceColor = workspaceData.workspace?.color
|
||||
} catch {
|
||||
workspaceColor = undefined
|
||||
}
|
||||
|
||||
const zipBlob = await exportWorkspaceToZip(
|
||||
workspaceName,
|
||||
workflowsToExport,
|
||||
foldersToExport,
|
||||
workspaceColor
|
||||
)
|
||||
|
||||
const zipFilename = `${sanitizePathSegment(workspaceName)}-${Date.now()}.zip`
|
||||
downloadFile(zipBlob, zipFilename, 'application/zip')
|
||||
|
||||
logger.info('Workspace exported successfully', {
|
||||
workspaceId,
|
||||
workflowsCount: workflowsToExport.length,
|
||||
foldersCount: foldersToExport.length,
|
||||
})
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error exporting workspace:', error)
|
||||
throw error
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
},
|
||||
[isExporting, onSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
isExporting,
|
||||
handleExportWorkspace,
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
createWorkflowContract,
|
||||
createWorkspaceContract,
|
||||
putWorkflowNormalizedStateContract,
|
||||
type WorkflowStateContractInput,
|
||||
workflowVariablesContract,
|
||||
} from '@/lib/api/contracts'
|
||||
import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
|
||||
const logger = createLogger('useImportWorkspace')
|
||||
|
||||
interface UseImportWorkspaceProps {
|
||||
/**
|
||||
* Optional callback after successful import
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workspace import from ZIP files.
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Import workspace handlers and state
|
||||
*/
|
||||
export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) {
|
||||
const router = useRouter()
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
|
||||
|
||||
/**
|
||||
* Handle workspace import from ZIP file
|
||||
*/
|
||||
const handleImportWorkspace = useCallback(
|
||||
async (zipFile: File) => {
|
||||
if (isImporting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!zipFile.name.toLowerCase().endsWith('.zip')) {
|
||||
logger.error('Please select a ZIP file')
|
||||
return
|
||||
}
|
||||
|
||||
setIsImporting(true)
|
||||
try {
|
||||
logger.info('Importing workspace from ZIP')
|
||||
|
||||
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
|
||||
|
||||
if (extractedWorkflows.length === 0) {
|
||||
logger.warn('No workflows found in ZIP file')
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
|
||||
const { workspace: newWorkspace } = await requestJson(createWorkspaceContract, {
|
||||
body: {
|
||||
name: workspaceName,
|
||||
...(metadata?.workspaceColor && { color: metadata.workspaceColor }),
|
||||
skipDefaultWorkflow: true,
|
||||
},
|
||||
})
|
||||
logger.info('Created new workspace:', newWorkspace)
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
|
||||
if (metadata?.folders && metadata.folders.length > 0) {
|
||||
type ExportedFolder = {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
const foldersById = new Map<string, ExportedFolder>(
|
||||
metadata.folders.map((f) => [f.id, f])
|
||||
)
|
||||
const oldIdToNewId = new Map<string, string>()
|
||||
|
||||
const buildPath = (folderId: string): string => {
|
||||
const pathParts: string[] = []
|
||||
let currentId: string | null = folderId
|
||||
while (currentId && foldersById.has(currentId)) {
|
||||
const folder: ExportedFolder = foldersById.get(currentId)!
|
||||
pathParts.unshift(sanitizePathSegment(folder.name))
|
||||
currentId = folder.parentId
|
||||
}
|
||||
return pathParts.join('/')
|
||||
}
|
||||
|
||||
const createFolderRecursive = async (folder: ExportedFolder): Promise<string> => {
|
||||
if (oldIdToNewId.has(folder.id)) {
|
||||
return oldIdToNewId.get(folder.id)!
|
||||
}
|
||||
|
||||
let parentId: string | undefined
|
||||
if (folder.parentId && foldersById.has(folder.parentId)) {
|
||||
parentId = await createFolderRecursive(foldersById.get(folder.parentId)!)
|
||||
}
|
||||
|
||||
const newFolder = await createFolderMutation.mutateAsync({
|
||||
name: folder.name,
|
||||
workspaceId: newWorkspace.id,
|
||||
parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
oldIdToNewId.set(folder.id, newFolder.id)
|
||||
folderMap.set(buildPath(folder.id), newFolder.id)
|
||||
return newFolder.id
|
||||
}
|
||||
|
||||
for (const folder of metadata.folders) {
|
||||
await createFolderRecursive(folder)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of extractedWorkflows) {
|
||||
try {
|
||||
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
|
||||
|
||||
if (!workflowData || parseErrors.length > 0) {
|
||||
logger.warn(`Failed to parse ${workflow.name}:`, parseErrors)
|
||||
continue
|
||||
}
|
||||
|
||||
let targetFolderId: string | null = null
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
|
||||
if (folderMap.has(folderPathKey)) {
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
} else {
|
||||
let parentId: string | undefined
|
||||
for (let i = 0; i < workflow.folderPath.length; i++) {
|
||||
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
|
||||
|
||||
if (!folderMap.has(pathSegment)) {
|
||||
const subFolder = await createFolderMutation.mutateAsync({
|
||||
name: workflow.folderPath[i],
|
||||
workspaceId: newWorkspace.id,
|
||||
parentId,
|
||||
})
|
||||
folderMap.set(pathSegment, subFolder.id)
|
||||
parentId = subFolder.id
|
||||
} else {
|
||||
parentId = folderMap.get(pathSegment)!
|
||||
}
|
||||
}
|
||||
targetFolderId = folderMap.get(folderPathKey) || null
|
||||
}
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(workflow.content, workflow.name)
|
||||
clearDiff()
|
||||
|
||||
const workflowColor =
|
||||
(workflowData.metadata as { color?: string } | undefined)?.color || '#3972F6'
|
||||
|
||||
let newWorkflow: Awaited<ReturnType<typeof requestJson<typeof createWorkflowContract>>>
|
||||
try {
|
||||
newWorkflow = await requestJson(createWorkflowContract, {
|
||||
body: {
|
||||
name: workflowName,
|
||||
description:
|
||||
workflowData.metadata?.description || 'Imported from workspace export',
|
||||
color: workflowColor,
|
||||
workspaceId: newWorkspace.id,
|
||||
folderId: targetFolderId,
|
||||
deduplicate: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create workflow ${workflowName}`, { error })
|
||||
continue
|
||||
}
|
||||
|
||||
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
|
||||
|
||||
const sanitizedEdges: ContractEdgeInput[] = (workflowData.edges || []).map((edge) => {
|
||||
const { sourceHandle, targetHandle, ...rest } = edge
|
||||
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
|
||||
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
|
||||
sanitized.sourceHandle = sourceHandle
|
||||
}
|
||||
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
|
||||
sanitized.targetHandle = targetHandle
|
||||
}
|
||||
return sanitized
|
||||
})
|
||||
|
||||
const workflowStateBody: WorkflowStateContractInput = {
|
||||
...workflowData,
|
||||
loops: workflowData.loops || {},
|
||||
parallels: workflowData.parallels || {},
|
||||
edges: sanitizedEdges,
|
||||
}
|
||||
|
||||
try {
|
||||
await requestJson(putWorkflowNormalizedStateContract, {
|
||||
params: { id: newWorkflow.id },
|
||||
body: workflowStateBody,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save workflow state for ${newWorkflow.id}`, { error })
|
||||
continue
|
||||
}
|
||||
|
||||
if (workflowData.variables) {
|
||||
const variablesArray = Array.isArray(workflowData.variables)
|
||||
? workflowData.variables
|
||||
: Object.values(workflowData.variables)
|
||||
|
||||
if (variablesArray.length > 0) {
|
||||
type WorkflowVariablesBodyInput = NonNullable<
|
||||
Parameters<typeof requestJson<typeof workflowVariablesContract>>[1]['body']
|
||||
>
|
||||
|
||||
const variablesRecord: WorkflowVariablesBodyInput['variables'] = {}
|
||||
|
||||
for (const v of variablesArray) {
|
||||
const id = typeof v.id === 'string' && v.id.trim() ? v.id : generateId()
|
||||
variablesRecord[id] = {
|
||||
id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await requestJson(workflowVariablesContract, {
|
||||
params: { id: newWorkflow.id },
|
||||
body: { variables: variablesRecord },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save variables for ${newWorkflow.id}`, { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Imported workflow: ${workflowName}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to import ${workflow.name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Workspace import complete. Imported ${extractedWorkflows.length} workflows`)
|
||||
|
||||
router.push(`/workspace/${newWorkspace.id}/home`)
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error importing workspace:', error)
|
||||
throw error
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
},
|
||||
[isImporting, router, onSuccess, createFolderMutation, clearDiff]
|
||||
)
|
||||
|
||||
return {
|
||||
isImporting,
|
||||
handleImportWorkspace,
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ function mapFolder(folder: FolderApi): WorkflowFolder {
|
||||
parentId: folder.parentId,
|
||||
color: folder.color ?? '#6B7280',
|
||||
isExpanded: folder.isExpanded,
|
||||
locked: folder.locked,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: new Date(folder.createdAt),
|
||||
updatedAt: new Date(folder.updatedAt),
|
||||
@@ -90,7 +91,7 @@ interface CreateFolderVariables {
|
||||
interface UpdateFolderVariables {
|
||||
workspaceId: string
|
||||
id: string
|
||||
updates: Partial<Pick<WorkflowFolder, 'name' | 'parentId' | 'color' | 'sortOrder'>>
|
||||
updates: Partial<Pick<WorkflowFolder, 'name' | 'parentId' | 'color' | 'sortOrder' | 'locked'>>
|
||||
}
|
||||
|
||||
interface DeleteFolderVariables {
|
||||
@@ -165,6 +166,7 @@ export function useCreateFolder() {
|
||||
parentId: variables.parentId || null,
|
||||
color: variables.color || '#808080',
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
sortOrder:
|
||||
variables.sortOrder ??
|
||||
getTopInsertionSortOrder(
|
||||
@@ -266,6 +268,7 @@ export function useDuplicateFolderMutation() {
|
||||
parentId: targetParentId,
|
||||
color: variables.color || sourceFolder?.color || '#808080',
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
sortOrder: getTopInsertionSortOrder(
|
||||
currentWorkflows,
|
||||
previousFolders,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findLockedAncestorFolder,
|
||||
getFolderPath,
|
||||
isFolderEffectivelyLocked,
|
||||
isFolderOrAncestorLocked,
|
||||
isWorkflowEffectivelyLocked,
|
||||
} from '@/hooks/queries/utils/folder-tree'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
function makeFolder(overrides: Partial<WorkflowFolder> & { id: string }): WorkflowFolder {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.name ?? overrides.id,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentId: overrides.parentId ?? null,
|
||||
color: '#000000',
|
||||
isExpanded: false,
|
||||
locked: overrides.locked ?? false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
archivedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('isFolderOrAncestorLocked', () => {
|
||||
it('returns false for root', () => {
|
||||
expect(isFolderOrAncestorLocked(null, {})).toBe(false)
|
||||
expect(isFolderOrAncestorLocked(undefined, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the folder itself is locked', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', locked: true }) }
|
||||
expect(isFolderOrAncestorLocked('f1', folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', locked: true }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
f3: makeFolder({ id: 'f3', parentId: 'f2' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f3', folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nothing in the chain is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1' }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f2', folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', parentId: 'f1' }),
|
||||
}
|
||||
expect(isFolderOrAncestorLocked('f1', folders)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFolderPath', () => {
|
||||
it('returns null for root or unknown folders', () => {
|
||||
expect(getFolderPath(null, {})).toBeNull()
|
||||
expect(getFolderPath(undefined, {})).toBeNull()
|
||||
expect(getFolderPath('missing', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the leaf folder name when at top level', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', name: 'Engineering' }) }
|
||||
expect(getFolderPath('f1', folders)).toBe('Engineering')
|
||||
})
|
||||
|
||||
it('joins ancestor names from root to leaf', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', name: 'Engineering' }),
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'eng' }),
|
||||
api: makeFolder({ id: 'api', name: 'API', parentId: 'be' }),
|
||||
}
|
||||
expect(getFolderPath('api', folders)).toBe('Engineering / Backend / API')
|
||||
})
|
||||
|
||||
it('respects custom separators', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', name: 'Engineering' }),
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'eng' }),
|
||||
}
|
||||
expect(getFolderPath('be', folders, ' > ')).toBe('Engineering > Backend')
|
||||
})
|
||||
|
||||
it('returns the partial path resolved before a missing ancestor', () => {
|
||||
const folders = {
|
||||
be: makeFolder({ id: 'be', name: 'Backend', parentId: 'missing' }),
|
||||
}
|
||||
expect(getFolderPath('be', folders)).toBe('Backend')
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(getFolderPath('f1', folders)).toBe('B / A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findLockedAncestorFolder', () => {
|
||||
it('returns null for root or unknown folders', () => {
|
||||
expect(findLockedAncestorFolder(null, {})).toBeNull()
|
||||
expect(findLockedAncestorFolder(undefined, {})).toBeNull()
|
||||
expect(findLockedAncestorFolder('missing', {})).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the folder itself when it is the locked one', () => {
|
||||
const folders = { f1: makeFolder({ id: 'f1', name: 'Engineering', locked: true }) }
|
||||
expect(findLockedAncestorFolder('f1', folders)?.name).toBe('Engineering')
|
||||
})
|
||||
|
||||
it('returns the closest locked ancestor, not the root', () => {
|
||||
const folders = {
|
||||
root: makeFolder({ id: 'root', name: 'Root', locked: true }),
|
||||
mid: makeFolder({ id: 'mid', name: 'Mid', parentId: 'root', locked: true }),
|
||||
leaf: makeFolder({ id: 'leaf', name: 'Leaf', parentId: 'mid' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('leaf', folders)?.id).toBe('mid')
|
||||
})
|
||||
|
||||
it('returns null when no folder in the chain is locked', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('f2', folders)).toBeNull()
|
||||
})
|
||||
|
||||
it('short-circuits on cycles instead of looping forever', () => {
|
||||
const folders = {
|
||||
f1: makeFolder({ id: 'f1', name: 'A', parentId: 'f2' }),
|
||||
f2: makeFolder({ id: 'f2', name: 'B', parentId: 'f1' }),
|
||||
}
|
||||
expect(findLockedAncestorFolder('f1', folders)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWorkflowEffectivelyLocked', () => {
|
||||
it('treats undefined or null workflows as unlocked', () => {
|
||||
expect(isWorkflowEffectivelyLocked(undefined, {})).toBe(false)
|
||||
expect(isWorkflowEffectivelyLocked(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the workflow row itself is locked', () => {
|
||||
expect(isWorkflowEffectivelyLocked({ locked: true, folderId: null }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor folder is locked', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', locked: true }),
|
||||
be: makeFolder({ id: 'be', parentId: 'eng' }),
|
||||
}
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'be' }, folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither row nor any ancestor is locked', () => {
|
||||
const folders = { eng: makeFolder({ id: 'eng' }) }
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: 'eng' }, folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a workflow at workspace root with no row lock', () => {
|
||||
expect(isWorkflowEffectivelyLocked({ locked: false, folderId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFolderEffectivelyLocked', () => {
|
||||
it('treats undefined or null folders as unlocked', () => {
|
||||
expect(isFolderEffectivelyLocked(undefined, {})).toBe(false)
|
||||
expect(isFolderEffectivelyLocked(null, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the folder itself is locked', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: true, parentId: null }, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when an ancestor folder is locked', () => {
|
||||
const folders = {
|
||||
eng: makeFolder({ id: 'eng', locked: true }),
|
||||
be: makeFolder({ id: 'be', parentId: 'eng' }),
|
||||
}
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: 'eng' }, folders)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither the folder nor any ancestor is locked', () => {
|
||||
const folders = { eng: makeFolder({ id: 'eng' }) }
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: 'eng' }, folders)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a root-level folder with no own lock', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
/**
|
||||
* Returns true when the folder or one of its ancestors is locked. Used to
|
||||
* mirror server-side cascading folder lock policy on the client without an
|
||||
* extra round-trip.
|
||||
*/
|
||||
export function isFolderOrAncestorLocked(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId = folderId ?? null
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) return false
|
||||
visited.add(currentFolderId)
|
||||
|
||||
const folder = folders[currentFolderId]
|
||||
if (!folder) return false
|
||||
if (folder.locked) return true
|
||||
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable path for a folder, e.g. `'Engineering / Backend'`.
|
||||
* Returns `null` when the folder is at workspace root or unknown. Cycles or
|
||||
* missing ancestors short-circuit by returning the segments resolved so far.
|
||||
*/
|
||||
export function getFolderPath(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
separator = ' / '
|
||||
): string | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const segments: string[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) break
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) break
|
||||
segments.unshift(folder.name)
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments.join(separator) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the closest locked ancestor folder for the given folderId, or `null`
|
||||
* when neither the folder nor any of its ancestors are locked. Cycles or
|
||||
* missing ancestors short-circuit and return `null` rather than looping.
|
||||
*/
|
||||
export function findLockedAncestorFolder(
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): WorkflowFolder | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) return null
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) return null
|
||||
if (folder.locked) return folder
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective lock state for a workflow as visible to the client. Mirrors
|
||||
* the server's `getWorkflowLockStatus(workflowId)` (in `@sim/workflow-authz`)
|
||||
* but reads from cached folder data instead of issuing DB walks. Treats an
|
||||
* undefined workflow as unlocked so callers don't need to early-return.
|
||||
*/
|
||||
export function isWorkflowEffectivelyLocked(
|
||||
workflow: { locked?: boolean | null; folderId?: string | null } | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
if (!workflow) return false
|
||||
if (workflow.locked) return true
|
||||
return isFolderOrAncestorLocked(workflow.folderId, folders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective lock state for a folder as visible to the client. Mirrors the
|
||||
* server's `getFolderLockStatus(folderId)` (in `@sim/workflow-authz`) but
|
||||
* reads from cached folder data instead of issuing DB walks. Treats an
|
||||
* undefined folder as unlocked so callers don't need to early-return.
|
||||
*/
|
||||
export function isFolderEffectivelyLocked(
|
||||
folder: { locked?: boolean | null; parentId?: string | null } | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): boolean {
|
||||
if (!folder) return false
|
||||
if (folder.locked) return true
|
||||
return isFolderOrAncestorLocked(folder.parentId, folders)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata {
|
||||
createdAt: new Date(workflow.createdAt),
|
||||
lastModified: new Date(workflow.updatedAt),
|
||||
archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null,
|
||||
locked: workflow.locked,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ export function useCreateWorkflow() {
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
sortOrder,
|
||||
locked: false,
|
||||
}
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
@@ -297,6 +298,7 @@ interface DuplicateWorkflowMutationData {
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
locked: boolean
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
@@ -339,6 +341,7 @@ export function useDuplicateWorkflowMutation() {
|
||||
workspaceId,
|
||||
folderId: duplicatedWorkflow.folderId ?? folderId,
|
||||
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
|
||||
locked: duplicatedWorkflow.locked,
|
||||
blocksCount: duplicatedWorkflow.blocksCount || 0,
|
||||
edgesCount: duplicatedWorkflow.edgesCount || 0,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
|
||||
@@ -374,6 +377,7 @@ export function useDuplicateWorkflowMutation() {
|
||||
variables.workspaceId,
|
||||
targetFolderId
|
||||
),
|
||||
locked: false,
|
||||
}
|
||||
|
||||
queryClient.setQueryData<WorkflowMetadata[]>(
|
||||
@@ -403,6 +407,7 @@ export function useDuplicateWorkflowMutation() {
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
sortOrder: data.sortOrder,
|
||||
locked: data.locked,
|
||||
}
|
||||
: w
|
||||
)
|
||||
@@ -419,18 +424,6 @@ export function useDuplicateWorkflowMutation() {
|
||||
})
|
||||
}
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (variables.sourceId === activeWorkflowId) {
|
||||
const sourceSubblockValues =
|
||||
useSubBlockStore.getState().workflowValues[variables.sourceId] || {}
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[data.id]: { ...sourceSubblockValues },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
logger.info(`[DuplicateWorkflow] Success, replaced temp entry ${tempId}`)
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import { getFolderPath } from '@/hooks/queries/utils/folder-tree'
|
||||
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
|
||||
import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
@@ -9,6 +11,38 @@ import type {
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
/**
|
||||
* Builds a label for a workflow option, appending the folder path when another
|
||||
* workflow in the workspace shares the same display name. Avoids suffixing
|
||||
* every workflow so the dropdown stays readable for the common case.
|
||||
*/
|
||||
function buildDisambiguatedLabel(
|
||||
workflow: WorkflowMetadata,
|
||||
duplicateNames: Set<string>,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): string {
|
||||
const baseLabel = workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
|
||||
if (!duplicateNames.has(baseLabel)) return baseLabel
|
||||
|
||||
const folderPath = getFolderPath(workflow.folderId, folders)
|
||||
return folderPath ? `${baseLabel} (${folderPath})` : `${baseLabel} (Root)`
|
||||
}
|
||||
|
||||
function collectDuplicateNames(workflows: WorkflowMetadata[]): Set<string> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const workflow of workflows) {
|
||||
const label = workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1)
|
||||
}
|
||||
const duplicates = new Set<string>()
|
||||
for (const [label, count] of counts) {
|
||||
if (count > 1) duplicates.add(label)
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
export const simSelectors = {
|
||||
'sim.workflows': {
|
||||
@@ -23,11 +57,13 @@ export const simSelectors = {
|
||||
if (!context.workspaceId) return []
|
||||
await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId))
|
||||
const workflows = getWorkflows(context.workspaceId)
|
||||
const folders = getFolderMap(context.workspaceId)
|
||||
const duplicateNames = collectDuplicateNames(workflows)
|
||||
return workflows
|
||||
.filter((w) => w.id !== context.excludeWorkflowId)
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
label: w.name || `Workflow ${w.id.slice(0, 8)}`,
|
||||
label: buildDisambiguatedLabel(w, duplicateNames, folders),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
@@ -40,9 +76,12 @@ export const simSelectors = {
|
||||
await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId))
|
||||
const workflow = getWorkflowById(context.workspaceId, detailId)
|
||||
if (!workflow) return null
|
||||
const workflows = getWorkflows(context.workspaceId)
|
||||
const folders = getFolderMap(context.workspaceId)
|
||||
const duplicateNames = collectDuplicateNames(workflows)
|
||||
return {
|
||||
id: detailId,
|
||||
label: workflow.name || `Workflow ${detailId.slice(0, 8)}`,
|
||||
label: buildDisambiguatedLabel(workflow, duplicateNames, folders),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockEnsureQueryData, mockGetWorkflows } = vi.hoisted(() => ({
|
||||
const { mockEnsureQueryData, mockGetWorkflows, mockGetFolderMap } = vi.hoisted(() => ({
|
||||
mockEnsureQueryData: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetWorkflows: vi.fn(),
|
||||
mockGetFolderMap: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
||||
@@ -21,6 +22,10 @@ vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
|
||||
getFolderMap: mockGetFolderMap,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/queries/utils/workflow-list-query', () => ({
|
||||
getWorkflowListQueryOptions: vi.fn((workspaceId: string) => ({
|
||||
queryKey: ['workflows', 'list', workspaceId, 'active'],
|
||||
@@ -32,10 +37,12 @@ import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
describe('sim.workflows selector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnsureQueryData.mockResolvedValue(undefined)
|
||||
mockGetWorkflows.mockReturnValue([
|
||||
{ id: 'wf-1', name: 'Alpha Workflow' },
|
||||
{ id: 'wf-2', name: 'Bravo Workflow' },
|
||||
{ id: 'wf-1', name: 'Alpha Workflow', folderId: null },
|
||||
{ id: 'wf-2', name: 'Bravo Workflow', folderId: null },
|
||||
])
|
||||
mockGetFolderMap.mockReturnValue({})
|
||||
})
|
||||
|
||||
it('requires an explicit workspaceId in selector context', () => {
|
||||
@@ -81,4 +88,63 @@ describe('sim.workflows selector', () => {
|
||||
expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1')
|
||||
expect(option).toEqual({ id: 'wf-2', label: 'Bravo Workflow' })
|
||||
})
|
||||
|
||||
it('disambiguates duplicate workflow names with their folder path', async () => {
|
||||
mockGetWorkflows.mockReturnValue([
|
||||
{ id: 'wf-root', name: 'Pipeline', folderId: null },
|
||||
{ id: 'wf-eng', name: 'Pipeline', folderId: 'folder-eng' },
|
||||
{ id: 'wf-eng-backend', name: 'Pipeline', folderId: 'folder-backend' },
|
||||
{ id: 'wf-unique', name: 'Solo Workflow', folderId: 'folder-eng' },
|
||||
])
|
||||
mockGetFolderMap.mockReturnValue({
|
||||
'folder-eng': {
|
||||
id: 'folder-eng',
|
||||
name: 'Engineering',
|
||||
parentId: null,
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
'folder-backend': {
|
||||
id: 'folder-backend',
|
||||
name: 'Backend',
|
||||
parentId: 'folder-eng',
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
})
|
||||
|
||||
const definition = getSelectorDefinition('sim.workflows')
|
||||
const options = await definition.fetchList({
|
||||
key: 'sim.workflows',
|
||||
context: { workspaceId: 'ws-1' },
|
||||
})
|
||||
|
||||
const labelById = Object.fromEntries(options.map((o) => [o.id, o.label]))
|
||||
expect(labelById['wf-root']).toBe('Pipeline (Root)')
|
||||
expect(labelById['wf-eng']).toBe('Pipeline (Engineering)')
|
||||
expect(labelById['wf-eng-backend']).toBe('Pipeline (Engineering / Backend)')
|
||||
expect(labelById['wf-unique']).toBe('Solo Workflow')
|
||||
})
|
||||
|
||||
it('disambiguates a single workflow lookup when its name has duplicates', async () => {
|
||||
mockGetWorkflows.mockReturnValue([
|
||||
{ id: 'wf-1', name: 'Pipeline', folderId: 'folder-a' },
|
||||
{ id: 'wf-2', name: 'Pipeline', folderId: null },
|
||||
])
|
||||
mockGetFolderMap.mockReturnValue({
|
||||
'folder-a': {
|
||||
id: 'folder-a',
|
||||
name: 'Alpha',
|
||||
parentId: null,
|
||||
workspaceId: 'ws-1',
|
||||
},
|
||||
})
|
||||
|
||||
const definition = getSelectorDefinition('sim.workflows')
|
||||
const option = await definition.fetchById?.({
|
||||
key: 'sim.workflows',
|
||||
context: { workspaceId: 'ws-1' },
|
||||
detailId: 'wf-1',
|
||||
})
|
||||
|
||||
expect(option).toEqual({ id: 'wf-1', label: 'Pipeline (Alpha)' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export const folderSchema = z.object({
|
||||
parentId: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
isExpanded: z.boolean(),
|
||||
locked: z.boolean(),
|
||||
sortOrder: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
@@ -42,6 +43,7 @@ export const updateFolderBodySchema = z.object({
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
isExpanded: z.boolean().optional(),
|
||||
locked: z.boolean().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
@@ -213,6 +213,7 @@ export const workflowListItemSchema = z.object({
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
locked: z.boolean(),
|
||||
})
|
||||
|
||||
export const createWorkflowBodySchema = z.object({
|
||||
@@ -263,6 +264,7 @@ export const duplicateWorkflowResponseSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
folderId: z.string().nullable(),
|
||||
sortOrder: z.number(),
|
||||
locked: z.boolean(),
|
||||
blocksCount: z.number(),
|
||||
edgesCount: z.number(),
|
||||
subflowsCount: z.number(),
|
||||
@@ -277,6 +279,7 @@ export const updateWorkflowBodySchema = z.object({
|
||||
color: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
locked: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type UpdateWorkflowBody = z.input<typeof updateWorkflowBodySchema>
|
||||
@@ -574,6 +577,7 @@ export const getWorkflowResponseDataSchema = z.object({
|
||||
isDeployed: z.boolean(),
|
||||
deployedAt: z.coerce.date().nullable(),
|
||||
isPublicApi: z.boolean(),
|
||||
locked: z.boolean(),
|
||||
runCount: z.number(),
|
||||
lastRunAt: z.coerce.date().nullable(),
|
||||
archivedAt: z.coerce.date().nullable(),
|
||||
|
||||
@@ -122,10 +122,6 @@ export const workspaceMemberSchema = z.object({
|
||||
|
||||
export type WorkspaceMember = z.output<typeof workspaceMemberSchema>
|
||||
|
||||
export const duplicateWorkspaceBodySchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
})
|
||||
|
||||
export const workspacePreviewBodySchema = z
|
||||
.object({
|
||||
code: z
|
||||
@@ -270,21 +266,4 @@ export const getWorkspaceMembersContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const duplicateWorkspaceContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/duplicate',
|
||||
params: workspaceParamsSchema,
|
||||
body: duplicateWorkspaceBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
ownerId: z.string(),
|
||||
workflowsCount: z.number(),
|
||||
foldersCount: z.number(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export type WorkspacesResponse = ContractJsonResponse<typeof listWorkspacesContract>
|
||||
|
||||
@@ -37,7 +37,8 @@ import { duplicateWorkflow } from './duplicate'
|
||||
|
||||
function createMockTx(
|
||||
selectResults: unknown[],
|
||||
onWorkflowInsert?: (values: Record<string, unknown>) => void
|
||||
onWorkflowInsert?: (values: Record<string, unknown>) => void,
|
||||
onInsert?: (values: unknown) => void
|
||||
) {
|
||||
let selectCallCount = 0
|
||||
|
||||
@@ -58,6 +59,7 @@ function createMockTx(
|
||||
const insert = vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
onWorkflowInsert?.(values)
|
||||
onInsert?.(values)
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
})
|
||||
@@ -173,4 +175,530 @@ describe('duplicateWorkflow ordering', () => {
|
||||
expect(result.sortOrder).toBe(0)
|
||||
expect(insertedWorkflowValues?.sortOrder).toBe(0)
|
||||
})
|
||||
|
||||
it('strips copied webhook runtime subblocks and remaps variable assignments', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
color: '#000000',
|
||||
variables: {
|
||||
'old-var-id': {
|
||||
id: 'old-var-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'source-block-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'generic_webhook',
|
||||
name: 'Webhook',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' },
|
||||
webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' },
|
||||
webhookUrlDisplay: {
|
||||
id: 'webhookUrlDisplay',
|
||||
type: 'short-input',
|
||||
value: 'https://example.test/api/webhooks/trigger/old-webhook-path',
|
||||
},
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: JSON.stringify([
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'old-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: true,
|
||||
locked: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-3',
|
||||
})
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(copiedSubBlocks.triggerPath).toBeUndefined()
|
||||
expect(copiedSubBlocks.webhookId).toBeUndefined()
|
||||
expect(copiedSubBlocks.webhookUrlDisplay).toBeUndefined()
|
||||
expect(copiedSubBlocks.variables.value[0].variableId).not.toBe('old-var-id')
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
expect(insertedBlocks?.[0].locked).toBe(false)
|
||||
})
|
||||
|
||||
it('remaps variable assignments when duplicating an already-duplicated source (array value)', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'first-copy-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'first copy',
|
||||
color: '#000000',
|
||||
variables: {
|
||||
'first-copy-var-id': {
|
||||
id: 'first-copy-var-id',
|
||||
workflowId: 'first-copy-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'first-copy-block-id',
|
||||
workflowId: 'first-copy-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: [
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'first-copy-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
if (!insertedWorkflowValues && !Array.isArray(values)) {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
},
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: 'first-copy-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated Again',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-second-copy',
|
||||
})
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(Array.isArray(copiedSubBlocks.variables.value)).toBe(true)
|
||||
expect(copiedSubBlocks.variables.value).toHaveLength(1)
|
||||
|
||||
const newVarIds = Object.keys(
|
||||
(insertedWorkflowValues?.variables as Record<string, unknown>) || {}
|
||||
)
|
||||
expect(newVarIds).toHaveLength(1)
|
||||
const remappedVarId = copiedSubBlocks.variables.value[0].variableId
|
||||
expect(remappedVarId).not.toBe('first-copy-var-id')
|
||||
expect(remappedVarId).toBe(newVarIds[0])
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
})
|
||||
|
||||
it('preserves remap when an edge references an unknown source block (drops the edge with a warning)', async () => {
|
||||
let insertedEdges: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
color: '#000000',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'block-a',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent A',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'block-b',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent B',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'edge-valid',
|
||||
workflowId: 'source-workflow-id',
|
||||
sourceBlockId: 'block-a',
|
||||
targetBlockId: 'block-b',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'edge-orphan',
|
||||
workflowId: 'source-workflow-id',
|
||||
sourceBlockId: 'unknown-source-block',
|
||||
targetBlockId: 'block-b',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (
|
||||
Array.isArray(values) &&
|
||||
values.length > 0 &&
|
||||
(values[0] as Record<string, unknown>)?.sourceBlockId !== undefined
|
||||
) {
|
||||
insertedEdges = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-orphan-edge',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedEdges).toHaveLength(1)
|
||||
const onlyEdge = insertedEdges?.[0]
|
||||
expect(onlyEdge?.sourceBlockId).not.toBe('unknown-source-block')
|
||||
expect(onlyEdge?.sourceBlockId).toEqual(expect.any(String))
|
||||
expect(onlyEdge?.targetBlockId).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('preserves remap when a subflow references an unknown node (drops the node with a warning)', async () => {
|
||||
let insertedSubflows: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
color: '#000000',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'loop-block',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'loop',
|
||||
name: 'Loop',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'known-node',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'loop-block',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'loop',
|
||||
config: {
|
||||
id: 'loop-block',
|
||||
nodes: ['known-node', 'unknown-node'],
|
||||
iterations: 1,
|
||||
loopType: 'for',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (
|
||||
Array.isArray(values) &&
|
||||
values.length > 0 &&
|
||||
(values[0] as Record<string, unknown>)?.config !== undefined
|
||||
) {
|
||||
insertedSubflows = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-orphan-subflow',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedSubflows).toHaveLength(1)
|
||||
const remappedConfig = insertedSubflows?.[0].config as { nodes: string[] }
|
||||
expect(Array.isArray(remappedConfig.nodes)).toBe(true)
|
||||
expect(remappedConfig.nodes).toHaveLength(1)
|
||||
expect(remappedConfig.nodes[0]).not.toBe('unknown-node')
|
||||
expect(remappedConfig.nodes[0]).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('preserves stale variable references instead of failing the duplicate', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
color: '#000000',
|
||||
variables: {
|
||||
'live-var-id': {
|
||||
id: 'live-var-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'source-block-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: [
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'deleted-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-stale',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(copiedSubBlocks.variables.value[0].variableId).toBe('deleted-var-id')
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
|
||||
import { authorizeWorkflowByWorkspacePermission, FolderLockedError } from '@sim/workflow-authz'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateHelper')
|
||||
|
||||
@@ -28,6 +30,8 @@ interface DuplicateWorkflowOptions {
|
||||
folderId?: string | null
|
||||
requestId?: string
|
||||
newWorkflowId?: string
|
||||
tx?: DbOrTx
|
||||
workflowIdMap?: Map<string, string>
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowResult {
|
||||
@@ -38,10 +42,137 @@ interface DuplicateWorkflowResult {
|
||||
workspaceId: string
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
locked: boolean
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
}
|
||||
/**
|
||||
* Untrusted shape of a persisted block subBlocks JSON column. We narrow `type`/`value`
|
||||
* with runtime checks before mutating; the index signature exists because callers pass
|
||||
* the raw record back to drizzle without knowing which subBlock keys it contains.
|
||||
*/
|
||||
type SubBlockRecord = Record<string, { type?: unknown; value?: unknown; [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* Untrusted shape of a single entry inside a `variables-input` value array. The
|
||||
* `variableId` slot is widened to `unknown` so we are forced to type-narrow before
|
||||
* trusting it as a remap key — persisted JSON may legitimately predate the field.
|
||||
*/
|
||||
type VariableAssignment = Record<string, unknown> & { variableId?: unknown }
|
||||
const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set(
|
||||
SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials')
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function isSystemSubBlockKey(key: string, ids: Set<string> | string[]): boolean {
|
||||
const idList = Array.isArray(ids) ? ids : Array.from(ids)
|
||||
return idList.some((id) => key === id || key.startsWith(`${id}_`))
|
||||
}
|
||||
|
||||
function sanitizeSubBlocksForDuplicate(subBlocks: SubBlockRecord): SubBlockRecord {
|
||||
const sanitized: SubBlockRecord = {}
|
||||
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (isSystemSubBlockKey(key, TRIGGER_RUNTIME_SUBBLOCK_IDS)) continue
|
||||
if (isSystemSubBlockKey(key, DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS)) continue
|
||||
sanitized[key] = subBlock
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
async function assertTargetFolderMutable(
|
||||
tx: DbOrTx,
|
||||
folderId: string | null,
|
||||
targetWorkspaceId: string
|
||||
): Promise<void> {
|
||||
let currentFolderId = folderId
|
||||
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 folder not found')
|
||||
}
|
||||
if (folder.locked) {
|
||||
throw new FolderLockedError()
|
||||
}
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
}
|
||||
|
||||
function remapVariableAssignment(value: unknown, varIdMap: Map<string, string>): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => remapVariableAssignment(item, varIdMap))
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const assignment = value as VariableAssignment
|
||||
const next: Record<string, unknown> = {}
|
||||
for (const [key, nestedValue] of Object.entries(assignment)) {
|
||||
next[key] = remapVariableAssignment(nestedValue, varIdMap)
|
||||
}
|
||||
|
||||
if (typeof assignment.variableId === 'string') {
|
||||
const newVarId = varIdMap.get(assignment.variableId)
|
||||
if (newVarId) {
|
||||
next.variableId = newVarId
|
||||
} else {
|
||||
logger.warn('Skipping unknown variable reference during duplication', {
|
||||
variableId: assignment.variableId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function remapVariableInputValue(value: unknown, varIdMap: Map<string, string>): unknown {
|
||||
if (value == null) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return remapVariableAssignment(value, varIdMap)
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return value
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
throw new Error('Variables input assignments could not be parsed for duplication')
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return remapVariableAssignment(parsed, varIdMap)
|
||||
}
|
||||
throw new Error('Variables input assignments must be an array')
|
||||
}
|
||||
|
||||
throw new Error('Variables input assignments must be an array')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps old variable IDs to new variable IDs inside block subBlocks.
|
||||
@@ -49,29 +180,16 @@ interface DuplicateWorkflowResult {
|
||||
* of variable assignments containing a `variableId` field.
|
||||
*/
|
||||
function remapVariableIdsInSubBlocks(
|
||||
subBlocks: Record<string, any>,
|
||||
subBlocks: SubBlockRecord,
|
||||
varIdMap: Map<string, string>
|
||||
): Record<string, any> {
|
||||
const updated: Record<string, any> = {}
|
||||
): SubBlockRecord {
|
||||
const updated: SubBlockRecord = {}
|
||||
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
subBlock.type === 'variables-input' &&
|
||||
Array.isArray(subBlock.value)
|
||||
) {
|
||||
if (subBlock && typeof subBlock === 'object' && subBlock.type === 'variables-input') {
|
||||
updated[key] = {
|
||||
...subBlock,
|
||||
value: subBlock.value.map((assignment: any) => {
|
||||
if (assignment && typeof assignment === 'object' && assignment.variableId) {
|
||||
const newVarId = varIdMap.get(assignment.variableId)
|
||||
if (newVarId) {
|
||||
return { ...assignment, variableId: newVarId }
|
||||
}
|
||||
}
|
||||
return assignment
|
||||
}),
|
||||
value: remapVariableInputValue(subBlock.value, varIdMap),
|
||||
}
|
||||
} else {
|
||||
updated[key] = subBlock
|
||||
@@ -81,6 +199,33 @@ function remapVariableIdsInSubBlocks(
|
||||
return updated
|
||||
}
|
||||
|
||||
function remapWorkflowReferencesInSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
workflowIdMap: Map<string, string> | undefined
|
||||
): SubBlockRecord {
|
||||
if (!workflowIdMap?.size) return subBlocks
|
||||
|
||||
const updated: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
subBlock.type === 'workflow-selector' &&
|
||||
typeof subBlock.value === 'string'
|
||||
) {
|
||||
updated[key] = {
|
||||
...subBlock,
|
||||
value: workflowIdMap.get(subBlock.value) ?? subBlock.value,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
updated[key] = subBlock
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
|
||||
* Returns a new object without mutating the input.
|
||||
@@ -132,13 +277,14 @@ export async function duplicateWorkflow(
|
||||
folderId,
|
||||
requestId = 'unknown',
|
||||
newWorkflowId: clientNewWorkflowId,
|
||||
tx: providedTx,
|
||||
workflowIdMap,
|
||||
} = options
|
||||
|
||||
const newWorkflowId = clientNewWorkflowId || generateId()
|
||||
const newWorkflowId = clientNewWorkflowId || workflowIdMap?.get(sourceWorkflowId) || generateId()
|
||||
const now = new Date()
|
||||
|
||||
// Duplicate workflow and all related data in a transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const duplicateWithinTransaction = async (tx: DbOrTx) => {
|
||||
// First verify the source workflow exists
|
||||
const sourceWorkflowRow = await tx
|
||||
.select()
|
||||
@@ -167,6 +313,10 @@ export async function duplicateWorkflow(
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || source.workspaceId
|
||||
if (targetWorkspaceId !== source.workspaceId) {
|
||||
throw new Error('Cross-workspace workflow duplication is not supported')
|
||||
}
|
||||
|
||||
const targetWorkspacePermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
@@ -176,6 +326,8 @@ export async function duplicateWorkflow(
|
||||
throw new Error('Write or admin access required for target workspace')
|
||||
}
|
||||
const targetFolderId = folderId !== undefined ? folderId : source.folderId
|
||||
await assertTargetFolderMutable(tx, targetFolderId, targetWorkspaceId)
|
||||
|
||||
const workflowParentCondition = targetFolderId
|
||||
? eq(workflow.folderId, targetFolderId)
|
||||
: isNull(workflow.folderId)
|
||||
@@ -221,6 +373,7 @@ export async function duplicateWorkflow(
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
locked: false,
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
@@ -286,17 +439,34 @@ export async function duplicateWorkflow(
|
||||
|
||||
// Update variable references in subBlocks (e.g. variables-input assignments)
|
||||
let updatedSubBlocks = block.subBlocks
|
||||
if (
|
||||
updatedSubBlocks &&
|
||||
typeof updatedSubBlocks === 'object' &&
|
||||
!Array.isArray(updatedSubBlocks)
|
||||
) {
|
||||
updatedSubBlocks = sanitizeSubBlocksForDuplicate(updatedSubBlocks as SubBlockRecord)
|
||||
}
|
||||
if (
|
||||
varIdMapping.size > 0 &&
|
||||
block.subBlocks &&
|
||||
typeof block.subBlocks === 'object' &&
|
||||
!Array.isArray(block.subBlocks)
|
||||
updatedSubBlocks &&
|
||||
typeof updatedSubBlocks === 'object' &&
|
||||
!Array.isArray(updatedSubBlocks)
|
||||
) {
|
||||
updatedSubBlocks = remapVariableIdsInSubBlocks(
|
||||
block.subBlocks as Record<string, any>,
|
||||
updatedSubBlocks as SubBlockRecord,
|
||||
varIdMapping
|
||||
)
|
||||
}
|
||||
if (
|
||||
updatedSubBlocks &&
|
||||
typeof updatedSubBlocks === 'object' &&
|
||||
!Array.isArray(updatedSubBlocks)
|
||||
) {
|
||||
updatedSubBlocks = remapWorkflowReferencesInSubBlocks(
|
||||
updatedSubBlocks as SubBlockRecord,
|
||||
workflowIdMap
|
||||
)
|
||||
}
|
||||
|
||||
// Remap condition/router IDs to use the new block ID
|
||||
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
|
||||
@@ -334,27 +504,52 @@ export async function duplicateWorkflow(
|
||||
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceEdges.length > 0) {
|
||||
const newEdges = sourceEdges.map((edge) => {
|
||||
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId
|
||||
/**
|
||||
* Edge remap is best-effort: when an edge points at a source/target block
|
||||
* that isn't in `blockIdMapping`, we drop the edge with a `logger.warn`
|
||||
* instead of throwing. This matches the pre-PR leniency (which silently
|
||||
* kept stale references) and avoids rolling back an entire folder-tree
|
||||
* duplicate transaction over a single orphaned reference. Inserting an
|
||||
* edge with a stale block id would create a dangling DB row, so we skip
|
||||
* the edge entirely rather than carry forward the unmapped id.
|
||||
*/
|
||||
const newEdges = sourceEdges.flatMap((edge) => {
|
||||
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId)
|
||||
const newTargetBlockId = blockIdMapping.get(edge.targetBlockId)
|
||||
if (!newSourceBlockId || !newTargetBlockId) {
|
||||
logger.warn('Skipping edge with unmapped block reference during duplication', {
|
||||
requestId,
|
||||
edgeId: edge.id,
|
||||
sourceBlockId: edge.sourceBlockId,
|
||||
targetBlockId: edge.targetBlockId,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: generateId(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: newSourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
sourceHandle: newSourceHandle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
return [
|
||||
{
|
||||
...edge,
|
||||
id: generateId(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: newSourceBlockId,
|
||||
targetBlockId: newTargetBlockId,
|
||||
sourceHandle: newSourceHandle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)
|
||||
if (newEdges.length > 0) {
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
}
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${newEdges.length}/${sourceEdges.length} edges with updated block references`
|
||||
)
|
||||
}
|
||||
|
||||
// Copy all subflows from source workflow with new IDs and updated block references
|
||||
@@ -393,11 +588,29 @@ export async function duplicateWorkflow(
|
||||
|
||||
;(updatedConfig as any).id = newSubflowId
|
||||
|
||||
// Update node references in config if they exist
|
||||
/**
|
||||
* Subflow node remap is best-effort: when `config.nodes` lists a
|
||||
* block id that isn't in `blockIdMapping`, we drop the entry with
|
||||
* a `logger.warn` instead of throwing. This matches the pre-PR
|
||||
* leniency (which silently carried the stale id forward) and
|
||||
* avoids rolling back an entire folder-tree duplicate transaction
|
||||
* over a single orphaned reference. Downstream consumers and
|
||||
* cleanup tolerate missing membership entries the same way they
|
||||
* tolerate other persisted drift.
|
||||
*/
|
||||
if ('nodes' in updatedConfig && Array.isArray(updatedConfig.nodes)) {
|
||||
updatedConfig.nodes = updatedConfig.nodes.map(
|
||||
(nodeId: string) => blockIdMapping.get(nodeId) || nodeId
|
||||
)
|
||||
updatedConfig.nodes = updatedConfig.nodes.flatMap((nodeId: string) => {
|
||||
const newNodeId = blockIdMapping.get(nodeId)
|
||||
if (!newNodeId) {
|
||||
logger.warn('Skipping unmapped subflow node reference during duplication', {
|
||||
requestId,
|
||||
subflowId: subflow.id,
|
||||
nodeId,
|
||||
})
|
||||
return []
|
||||
}
|
||||
return [newNodeId]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,11 +655,16 @@ export async function duplicateWorkflow(
|
||||
workspaceId: finalWorkspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder,
|
||||
locked: false,
|
||||
blocksCount: sourceBlocks.length,
|
||||
edgesCount: sourceEdges.length,
|
||||
subflowsCount: sourceSubflows.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const result = providedTx
|
||||
? await duplicateWithinTransaction(providedTx)
|
||||
: await db.transaction(duplicateWithinTransaction)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowFolder, workspace as workspaceTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceDuplicate')
|
||||
|
||||
interface DuplicateWorkspaceOptions {
|
||||
sourceWorkspaceId: string
|
||||
userId: string
|
||||
name: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
interface DuplicateWorkspaceResult {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
workflowsCount: number
|
||||
foldersCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workspace with all its workflows
|
||||
* This creates a new workspace and duplicates all workflows from the source workspace
|
||||
*/
|
||||
export async function duplicateWorkspace(
|
||||
options: DuplicateWorkspaceOptions
|
||||
): Promise<DuplicateWorkspaceResult> {
|
||||
const { sourceWorkspaceId, userId, name, requestId = 'unknown' } = options
|
||||
|
||||
// Generate new workspace ID
|
||||
const newWorkspaceId = generateId()
|
||||
const now = new Date()
|
||||
|
||||
// Verify the source workspace exists and user has permission
|
||||
const sourceWorkspace = await db
|
||||
.select()
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.id, sourceWorkspaceId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sourceWorkspace) {
|
||||
throw new Error('Source workspace not found')
|
||||
}
|
||||
|
||||
// Check if user has permission to access the source workspace
|
||||
const userPermission = await getUserEntityPermissions(userId, 'workspace', sourceWorkspaceId)
|
||||
if (!userPermission) {
|
||||
throw new Error('Source workspace not found or access denied')
|
||||
}
|
||||
|
||||
// Create new workspace with admin permission in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Create the new workspace
|
||||
await tx.insert(workspaceTable).values({
|
||||
id: newWorkspaceId,
|
||||
name,
|
||||
color: sourceWorkspace.color,
|
||||
ownerId: userId,
|
||||
billedAccountUserId: userId,
|
||||
allowPersonalApiKeys: sourceWorkspace.allowPersonalApiKeys,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Grant admin permission to the user on the new workspace
|
||||
await tx.insert(permissions).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
entityType: 'workspace',
|
||||
entityId: newWorkspaceId,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
|
||||
// Get all folders from the source workspace
|
||||
const sourceFolders = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, sourceWorkspaceId))
|
||||
|
||||
// Create folder ID mapping
|
||||
const folderIdMap = new Map<string, string>()
|
||||
|
||||
// Duplicate folders (need to maintain hierarchy)
|
||||
const foldersByParent = new Map<string | null, typeof sourceFolders>()
|
||||
for (const folder of sourceFolders) {
|
||||
const parentKey = folder.parentId
|
||||
if (!foldersByParent.has(parentKey)) {
|
||||
foldersByParent.set(parentKey, [])
|
||||
}
|
||||
foldersByParent.get(parentKey)!.push(folder)
|
||||
}
|
||||
|
||||
// Recursive function to duplicate folders in correct order
|
||||
const duplicateFolderHierarchy = async (parentId: string | null) => {
|
||||
const foldersAtLevel = foldersByParent.get(parentId) || []
|
||||
|
||||
for (const sourceFolder of foldersAtLevel) {
|
||||
const newFolderId = generateId()
|
||||
folderIdMap.set(sourceFolder.id, newFolderId)
|
||||
|
||||
await db.insert(workflowFolder).values({
|
||||
id: newFolderId,
|
||||
userId,
|
||||
workspaceId: newWorkspaceId,
|
||||
name: sourceFolder.name,
|
||||
color: sourceFolder.color,
|
||||
parentId: parentId ? folderIdMap.get(parentId) || null : null,
|
||||
sortOrder: sourceFolder.sortOrder,
|
||||
isExpanded: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Recursively duplicate child folders
|
||||
await duplicateFolderHierarchy(sourceFolder.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Start duplication from root level (parentId = null)
|
||||
await duplicateFolderHierarchy(null)
|
||||
|
||||
// Get all workflows from the source workspace
|
||||
const sourceWorkflows = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, sourceWorkspaceId))
|
||||
|
||||
// Duplicate each workflow with mapped folder IDs
|
||||
let workflowsCount = 0
|
||||
for (const sourceWorkflow of sourceWorkflows) {
|
||||
try {
|
||||
const newFolderId = sourceWorkflow.folderId
|
||||
? folderIdMap.get(sourceWorkflow.folderId) || null
|
||||
: null
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: sourceWorkflow.id,
|
||||
userId,
|
||||
name: sourceWorkflow.name,
|
||||
description: sourceWorkflow.description || undefined,
|
||||
color: sourceWorkflow.color || undefined,
|
||||
workspaceId: newWorkspaceId,
|
||||
folderId: newFolderId,
|
||||
requestId,
|
||||
})
|
||||
workflowsCount++
|
||||
} catch (error) {
|
||||
logger.error(`Failed to duplicate workflow ${sourceWorkflow.id}:`, error)
|
||||
// Continue with other workflows even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: newWorkspaceId,
|
||||
name,
|
||||
ownerId: userId,
|
||||
workflowsCount,
|
||||
foldersCount: folderIdMap.size,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface WorkflowFolder {
|
||||
parentId: string | null
|
||||
color: string
|
||||
isExpanded: boolean
|
||||
locked: boolean
|
||||
sortOrder: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface WorkflowMetadata {
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
archivedAt?: Date | null
|
||||
locked?: boolean
|
||||
/** True for sandbox exercises (Sim Academy). Skips real API calls. */
|
||||
isSandbox?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "workflow" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "workflow_folder" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1394,6 +1394,13 @@
|
||||
"when": 1777325415453,
|
||||
"tag": "0199_invitation_membership_intent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 200,
|
||||
"version": "7",
|
||||
"when": 1777664689974,
|
||||
"tag": "0200_workflow_folder_locks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export const workflowFolder = pgTable(
|
||||
parentId: text('parent_id'), // Self-reference will be handled by foreign key constraint
|
||||
color: text('color').default('#6B7280'),
|
||||
isExpanded: boolean('is_expanded').notNull().default(true),
|
||||
locked: boolean('locked').notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
@@ -165,6 +166,7 @@ export const workflow = pgTable(
|
||||
isDeployed: boolean('is_deployed').notNull().default(false),
|
||||
deployedAt: timestamp('deployed_at'),
|
||||
isPublicApi: boolean('is_public_api').notNull().default(false),
|
||||
locked: boolean('locked').notNull().default(false),
|
||||
runCount: integer('run_count').notNull().default(0),
|
||||
lastRunAt: timestamp('last_run_at'),
|
||||
variables: json('variables').default('{}'),
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Real `WorkflowLockedError` subclass used by tests so `instanceof` checks in
|
||||
* route handlers behave the same as in production. Mirrors the shape exported
|
||||
* by `@sim/workflow-authz`.
|
||||
*/
|
||||
export class MockWorkflowLockedError extends Error {
|
||||
readonly status = 423
|
||||
|
||||
constructor(message = 'Workflow is locked') {
|
||||
super(message)
|
||||
this.name = 'WorkflowLockedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real `FolderLockedError` subclass used by tests so `instanceof` checks in
|
||||
* route handlers behave the same as in production. Mirrors the shape exported
|
||||
* by `@sim/workflow-authz`.
|
||||
*/
|
||||
export class MockFolderLockedError extends Error {
|
||||
readonly status = 423
|
||||
|
||||
constructor(message = 'Folder is locked') {
|
||||
super(message)
|
||||
this.name = 'FolderLockedError'
|
||||
}
|
||||
}
|
||||
|
||||
const unlockedStatus = {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null as 'workflow' | 'folder' | null,
|
||||
lockedFolderId: null as string | null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Controllable mocks for the `@sim/workflow-authz` package.
|
||||
*
|
||||
* Defaults assume permissive access (no lock, write allowed). Override with
|
||||
* `mockResolvedValue` per test when exercising the lock/permission paths.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { workflowAuthzMockFns } from '@sim/testing'
|
||||
@@ -20,6 +59,10 @@ export const workflowAuthzMockFns = {
|
||||
mockGetActiveWorkflowContext: vi.fn(),
|
||||
mockGetActiveWorkflowRecord: vi.fn(),
|
||||
mockAssertActiveWorkflowContext: vi.fn(),
|
||||
mockGetFolderLockStatus: vi.fn().mockResolvedValue(unlockedStatus),
|
||||
mockGetWorkflowLockStatus: vi.fn().mockResolvedValue(unlockedStatus),
|
||||
mockAssertWorkflowMutable: vi.fn().mockResolvedValue(undefined),
|
||||
mockAssertFolderMutable: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,4 +79,10 @@ export const workflowAuthzMock = {
|
||||
getActiveWorkflowContext: workflowAuthzMockFns.mockGetActiveWorkflowContext,
|
||||
getActiveWorkflowRecord: workflowAuthzMockFns.mockGetActiveWorkflowRecord,
|
||||
assertActiveWorkflowContext: workflowAuthzMockFns.mockAssertActiveWorkflowContext,
|
||||
getFolderLockStatus: workflowAuthzMockFns.mockGetFolderLockStatus,
|
||||
getWorkflowLockStatus: workflowAuthzMockFns.mockGetWorkflowLockStatus,
|
||||
assertWorkflowMutable: workflowAuthzMockFns.mockAssertWorkflowMutable,
|
||||
assertFolderMutable: workflowAuthzMockFns.mockAssertFolderMutable,
|
||||
WorkflowLockedError: MockWorkflowLockedError,
|
||||
FolderLockedError: MockFolderLockedError,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { db, permissions, type permissionTypeEnum, workflow, workspace } from '@sim/db'
|
||||
import {
|
||||
db,
|
||||
permissions,
|
||||
type permissionTypeEnum,
|
||||
workflow,
|
||||
workflowFolder,
|
||||
workspace,
|
||||
} from '@sim/db'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
|
||||
export type ActiveWorkflowRecord = typeof workflow.$inferSelect
|
||||
@@ -54,6 +61,136 @@ export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
type WorkflowRecord = typeof workflow.$inferSelect
|
||||
|
||||
export class WorkflowLockedError extends Error {
|
||||
readonly status = 423
|
||||
|
||||
constructor(message = 'Workflow is locked') {
|
||||
super(message)
|
||||
this.name = 'WorkflowLockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class FolderLockedError extends Error {
|
||||
readonly status = 423
|
||||
|
||||
constructor(message = 'Folder is locked') {
|
||||
super(message)
|
||||
this.name = 'FolderLockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LockStatus {
|
||||
locked: boolean
|
||||
directLocked: boolean
|
||||
inheritedLocked: boolean
|
||||
lockedBy: 'workflow' | 'folder' | null
|
||||
lockedFolderId: string | null
|
||||
}
|
||||
|
||||
export async function getFolderLockStatus(folderId: string | null): Promise<LockStatus> {
|
||||
if (!folderId) {
|
||||
return {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null,
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
let currentFolderId: string | null = folderId
|
||||
let isDirect = true
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentFolderId && !visited.has(currentFolderId)) {
|
||||
visited.add(currentFolderId)
|
||||
const [folder] = await db
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
parentId: workflowFolder.parentId,
|
||||
locked: workflowFolder.locked,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.id, currentFolderId), isNull(workflowFolder.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!folder) break
|
||||
if (folder.locked) {
|
||||
return {
|
||||
locked: true,
|
||||
directLocked: isDirect,
|
||||
inheritedLocked: !isDirect,
|
||||
lockedBy: 'folder',
|
||||
lockedFolderId: folder.id,
|
||||
}
|
||||
}
|
||||
|
||||
currentFolderId = folder.parentId
|
||||
isDirect = false
|
||||
}
|
||||
|
||||
return {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null,
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkflowLockStatus(workflowId: string): Promise<LockStatus> {
|
||||
const [wf] = await db
|
||||
.select({
|
||||
locked: workflow.locked,
|
||||
folderId: workflow.folderId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf) {
|
||||
return {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null,
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (wf.locked) {
|
||||
return {
|
||||
locked: true,
|
||||
directLocked: true,
|
||||
inheritedLocked: false,
|
||||
lockedBy: 'workflow',
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
return getFolderLockStatus(wf.folderId)
|
||||
}
|
||||
|
||||
export async function assertWorkflowMutable(workflowId: string): Promise<void> {
|
||||
const status = await getWorkflowLockStatus(workflowId)
|
||||
if (status.locked) {
|
||||
throw new WorkflowLockedError(
|
||||
status.lockedBy === 'folder'
|
||||
? 'Workflow is locked by its containing folder'
|
||||
: 'Workflow is locked'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertFolderMutable(folderId: string | null): Promise<void> {
|
||||
const status = await getFolderLockStatus(folderId)
|
||||
if (status.locked) {
|
||||
throw new FolderLockedError(
|
||||
status.inheritedLocked ? 'Folder is locked by an ancestor folder' : 'Folder is locked'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowWorkspaceAuthorizationResult {
|
||||
allowed: boolean
|
||||
status: number
|
||||
|
||||
Reference in New Issue
Block a user