chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing'
|
||||
import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline-materialization.server'
|
||||
import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
export type WorkflowScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export async function getWorkflowById(id: string, options?: { includeArchived?: boolean }) {
|
||||
const { includeArchived = false } = options ?? {}
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
includeArchived
|
||||
? eq(workflowTable.id, id)
|
||||
: and(eq(workflowTable.id, id), isNull(workflowTable.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function listWorkflows(workspaceId: string, options?: { scope?: WorkflowScope }) {
|
||||
const { scope = 'active' } = options ?? {}
|
||||
return db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
scope === 'all'
|
||||
? eq(workflowTable.workspaceId, workspaceId)
|
||||
: scope === 'archived'
|
||||
? and(
|
||||
eq(workflowTable.workspaceId, workspaceId),
|
||||
sql`${workflowTable.archivedAt} IS NOT NULL`
|
||||
)
|
||||
: and(eq(workflowTable.workspaceId, workspaceId), isNull(workflowTable.archivedAt))
|
||||
)
|
||||
.orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique workflow name within a workspace+folder scope.
|
||||
* If the name already exists among active workflows, appends (2), (3), etc.
|
||||
*
|
||||
* Pass a transaction as `executor` when running inside an open tx so the
|
||||
* lookup observes workflows inserted earlier in the same transaction.
|
||||
*/
|
||||
export async function deduplicateWorkflowName(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
folderId: string | null | undefined,
|
||||
executor: Pick<typeof db, 'select'> = db
|
||||
): Promise<string> {
|
||||
const folderCondition = folderId
|
||||
? eq(workflowTable.folderId, folderId)
|
||||
: isNull(workflowTable.folderId)
|
||||
|
||||
const [existing] = await executor
|
||||
.select({ id: workflowTable.id })
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowTable.workspaceId, workspaceId),
|
||||
folderCondition,
|
||||
eq(workflowTable.name, name),
|
||||
isNull(workflowTable.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return name
|
||||
}
|
||||
|
||||
for (let i = 2; i < 100; i++) {
|
||||
const candidate = `${name} (${i})`
|
||||
const [dup] = await executor
|
||||
.select({ id: workflowTable.id })
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowTable.workspaceId, workspaceId),
|
||||
folderCondition,
|
||||
eq(workflowTable.name, candidate),
|
||||
isNull(workflowTable.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!dup) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return `${name} (${generateId().slice(0, 6)})`
|
||||
}
|
||||
|
||||
export type WorkflowResolutionResult =
|
||||
| {
|
||||
status: 'resolved'
|
||||
workflowId: string
|
||||
workspaceId: string
|
||||
workflowName?: string
|
||||
}
|
||||
| {
|
||||
status: 'not_found'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'ambiguous'
|
||||
message: string
|
||||
candidates: Array<{
|
||||
workflowId: string
|
||||
workflowName?: string
|
||||
folderId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export async function resolveWorkflowIdForUser(
|
||||
userId: string,
|
||||
workflowId?: string,
|
||||
workflowName?: string,
|
||||
workspaceId?: string
|
||||
): Promise<WorkflowResolutionResult> {
|
||||
if (workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
const wf = await getWorkflowById(workflowId)
|
||||
if (!wf?.workspaceId) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'resolved',
|
||||
workflowId,
|
||||
workspaceId: wf.workspaceId,
|
||||
workflowName: wf.name || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId, 'all')
|
||||
const workspaceIdList = accessibleRows.map((row) => row.workspace.id)
|
||||
const allowedWorkspaceIds = workspaceId
|
||||
? workspaceIdList.filter((candidateWorkspaceId) => candidateWorkspaceId === workspaceId)
|
||||
: workspaceIdList
|
||||
if (allowedWorkspaceIds.length === 0) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
|
||||
const workflowRows = await db
|
||||
.select()
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
and(inArray(workflowTable.workspaceId, allowedWorkspaceIds), isNull(workflowTable.archivedAt))
|
||||
)
|
||||
.orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt), asc(workflowTable.id))
|
||||
|
||||
const workflows = workflowRows.filter(
|
||||
(workflow): workflow is (typeof workflowRows)[number] & { workspaceId: string } =>
|
||||
workflow.workspaceId !== null
|
||||
)
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowName) {
|
||||
const matches = workflows.filter(
|
||||
(w) =>
|
||||
String(w.name || '')
|
||||
.trim()
|
||||
.toLowerCase() === workflowName.toLowerCase()
|
||||
)
|
||||
if (matches.length === 1) {
|
||||
const [match] = matches
|
||||
return {
|
||||
status: 'resolved',
|
||||
workflowId: match.id,
|
||||
workspaceId: match.workspaceId,
|
||||
workflowName: match.name || undefined,
|
||||
}
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
message: `Multiple workflows named "${workflowName}" were found. Provide workflowId to disambiguate.`,
|
||||
candidates: matches.map((match) => ({
|
||||
workflowId: match.id,
|
||||
workflowName: match.name || undefined,
|
||||
folderId: match.folderId,
|
||||
})),
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'not_found',
|
||||
message: `No workflow named "${workflowName}" was found.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (workflows.length === 1) {
|
||||
return {
|
||||
status: 'resolved',
|
||||
workflowId: workflows[0].id,
|
||||
workspaceId: workflows[0].workspaceId,
|
||||
workflowName: workflows[0].name || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
message:
|
||||
'Multiple workflows are available. Provide workflowId or workflowName to disambiguate.',
|
||||
candidates: workflows.slice(0, 20).map((workflow) => ({
|
||||
workflowId: workflow.id,
|
||||
workflowName: workflow.name || undefined,
|
||||
folderId: workflow.folderId,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkflowRunCounts(workflowId: string, runs = 1) {
|
||||
try {
|
||||
const workflow = await getWorkflowById(workflowId)
|
||||
if (!workflow) {
|
||||
logger.error(`Workflow ${workflowId} not found`)
|
||||
throw new Error(`Workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
runCount: workflow.runCount + runs,
|
||||
lastRunAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
runsAdded: runs,
|
||||
newTotal: workflow.runCount + runs,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error updating workflow stats for ${workflowId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const workflowHasResponseBlock = (
|
||||
executionResult: Pick<ExecutionResult, 'success' | 'logs'>
|
||||
): boolean => {
|
||||
if (!executionResult?.logs || !Array.isArray(executionResult.logs) || !executionResult.success) {
|
||||
return false
|
||||
}
|
||||
|
||||
const responseBlock = executionResult.logs.find(
|
||||
(log) => log?.blockType === 'response' && log?.success
|
||||
)
|
||||
|
||||
return responseBlock !== undefined
|
||||
}
|
||||
|
||||
export const createHttpResponseFromBlock = async (
|
||||
executionResult: Pick<ExecutionResult, 'output'>,
|
||||
context?: ExecutionMaterializationContext
|
||||
): Promise<NextResponse> => {
|
||||
const { data = {}, status = 200, headers = {} } = executionResult.output
|
||||
const responseData = await materializeInlineExecutionValue(data, context)
|
||||
|
||||
const responseHeaders = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
})
|
||||
|
||||
return NextResponse.json(responseData, {
|
||||
status: status,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the current user has permission to access/modify a workflow
|
||||
* Returns session and workflow info if authorized, or error response if not
|
||||
*/
|
||||
export async function validateWorkflowPermissions(
|
||||
workflowId: string,
|
||||
requestId: string,
|
||||
action: 'read' | 'write' | 'admin' = 'read'
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] No authenticated user session for workflow ${action}`)
|
||||
return {
|
||||
error: { message: 'Unauthorized', status: 401 },
|
||||
session: null,
|
||||
workflow: null,
|
||||
}
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: session.user.id,
|
||||
action,
|
||||
})
|
||||
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return {
|
||||
error: { message: 'Workflow not found', status: 404 },
|
||||
session: null,
|
||||
workflow: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!authorization.allowed) {
|
||||
const message =
|
||||
authorization.message || `Unauthorized: Access denied to ${action} this workflow`
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId}`,
|
||||
{
|
||||
action,
|
||||
workflowId,
|
||||
}
|
||||
)
|
||||
return {
|
||||
error: { message, status: authorization.status },
|
||||
session: null,
|
||||
workflow: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
session,
|
||||
workflow: authorization.workflow,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow CRUD ──
|
||||
|
||||
export interface CreateWorkflowInput {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
name: string
|
||||
description?: string | null
|
||||
folderId?: string | null
|
||||
}
|
||||
|
||||
export async function createWorkflowRecord(params: CreateWorkflowInput) {
|
||||
const { userId, workspaceId, name, description = null, folderId = null } = params
|
||||
const workflowId = generateId()
|
||||
const now = new Date()
|
||||
|
||||
const duplicateConditions = [
|
||||
eq(workflowTable.workspaceId, workspaceId),
|
||||
isNull(workflowTable.archivedAt),
|
||||
eq(workflowTable.name, name),
|
||||
...(folderId ? [eq(workflowTable.folderId, folderId)] : [isNull(workflowTable.folderId)]),
|
||||
]
|
||||
const [duplicateWorkflow] = await db
|
||||
.select({ id: workflowTable.id })
|
||||
.from(workflowTable)
|
||||
.where(and(...duplicateConditions))
|
||||
.limit(1)
|
||||
if (duplicateWorkflow) {
|
||||
throw new Error(
|
||||
`A workflow named "${name}" already exists in this folder. Use a different name.`
|
||||
)
|
||||
}
|
||||
|
||||
const workflowParentCondition = folderId
|
||||
? eq(workflowTable.folderId, folderId)
|
||||
: isNull(workflowTable.folderId)
|
||||
const folderParentCondition = folderId
|
||||
? eq(workflowFolder.parentId, folderId)
|
||||
: isNull(workflowFolder.parentId)
|
||||
|
||||
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
|
||||
db
|
||||
.select({ minOrder: min(workflowTable.sortOrder) })
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowTable.workspaceId, workspaceId),
|
||||
workflowParentCondition,
|
||||
isNull(workflowTable.archivedAt)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({ minOrder: min(workflowFolder.sortOrder) })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
|
||||
])
|
||||
|
||||
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
|
||||
number | null
|
||||
>((currentMin, candidate) => {
|
||||
if (candidate == null) return currentMin
|
||||
if (currentMin == null) return candidate
|
||||
return Math.min(currentMin, candidate)
|
||||
}, null)
|
||||
|
||||
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||
|
||||
await db.insert(workflowTable).values({
|
||||
id: workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder,
|
||||
name,
|
||||
description,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
|
||||
if (!saveResult.success) {
|
||||
throw new Error(saveResult.error || 'Failed to save workflow state')
|
||||
}
|
||||
|
||||
await ensureWorkflowAliasBacking({ workspaceId, userId, workflowId, workflowName: name })
|
||||
|
||||
return { workflowId, name, workspaceId, folderId, sortOrder, createdAt: now, updatedAt: now }
|
||||
}
|
||||
|
||||
export async function updateWorkflowRecord(
|
||||
workflowId: string,
|
||||
updates: { name?: string; description?: string; folderId?: string | null }
|
||||
) {
|
||||
const setData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (updates.name !== undefined) setData.name = updates.name
|
||||
if (updates.description !== undefined) setData.description = updates.description
|
||||
if (updates.folderId !== undefined) setData.folderId = updates.folderId
|
||||
await db.update(workflowTable).set(setData).where(eq(workflowTable.id, workflowId))
|
||||
}
|
||||
|
||||
export async function deleteWorkflowRecord(workflowId: string) {
|
||||
const { archiveWorkflow } = await import('@/lib/workflows/lifecycle')
|
||||
await archiveWorkflow(workflowId, {
|
||||
requestId: `workflow-record-${workflowId}`,
|
||||
notifySocket: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function setWorkflowVariables(workflowId: string, variables: Record<string, unknown>) {
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({ variables, updatedAt: new Date() })
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
}
|
||||
|
||||
// ── Folder CRUD ──
|
||||
|
||||
export interface CreateFolderInput {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
name: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export async function createFolderRecord(params: CreateFolderInput) {
|
||||
const { userId, workspaceId, name, parentId = null } = params
|
||||
|
||||
const [maxResult] = await db
|
||||
.select({ maxOrder: max(workflowFolder.sortOrder) })
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.workspaceId, workspaceId),
|
||||
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
|
||||
)
|
||||
)
|
||||
const sortOrder = (maxResult?.maxOrder ?? 0) + 1
|
||||
|
||||
const folderId = generateId()
|
||||
await db.insert(workflowFolder).values({
|
||||
id: folderId,
|
||||
userId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
name,
|
||||
sortOrder,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
return { folderId, name, workspaceId, parentId }
|
||||
}
|
||||
|
||||
export async function updateFolderRecord(
|
||||
folderId: string,
|
||||
updates: { name?: string; parentId?: string | null }
|
||||
) {
|
||||
const setData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
if (updates.name !== undefined) setData.name = updates.name
|
||||
if (updates.parentId !== undefined) setData.parentId = updates.parentId
|
||||
await db.update(workflowFolder).set(setData).where(eq(workflowFolder.id, folderId))
|
||||
}
|
||||
|
||||
export async function verifyFolderWorkspace(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: workflowFolder.id })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
export async function deleteFolderRecord(folderId: string): Promise<boolean> {
|
||||
const [folder] = await db
|
||||
.select({ parentId: workflowFolder.parentId })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, folderId))
|
||||
.limit(1)
|
||||
|
||||
if (!folder) return false
|
||||
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({ folderId: folder.parentId, updatedAt: new Date() })
|
||||
.where(eq(workflowTable.folderId, folderId))
|
||||
|
||||
await db
|
||||
.update(workflowFolder)
|
||||
.set({ parentId: folder.parentId, updatedAt: new Date() })
|
||||
.where(eq(workflowFolder.parentId, folderId))
|
||||
|
||||
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether setting `parentId` as the parent of `folderId` would
|
||||
* create a circular reference in the folder tree.
|
||||
*/
|
||||
export async function checkForCircularReference(
|
||||
folderId: string,
|
||||
parentId: string
|
||||
): Promise<boolean> {
|
||||
let currentParentId: string | null = parentId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentParentId) {
|
||||
if (visited.has(currentParentId) || currentParentId === folderId) {
|
||||
return true
|
||||
}
|
||||
|
||||
visited.add(currentParentId)
|
||||
|
||||
const [parent] = await db
|
||||
.select({ parentId: workflowFolder.parentId })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, currentParentId))
|
||||
.limit(1)
|
||||
|
||||
currentParentId = parent?.parentId || null
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function listFolders(workspaceId: string) {
|
||||
return db
|
||||
.select({
|
||||
folderId: workflowFolder.id,
|
||||
folderName: workflowFolder.name,
|
||||
parentId: workflowFolder.parentId,
|
||||
sortOrder: workflowFolder.sortOrder,
|
||||
locked: workflowFolder.locked,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt)))
|
||||
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
|
||||
}
|
||||
Reference in New Issue
Block a user