diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 66941dacb76..d846ebdf3f8 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { FolderLockedError } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' @@ -12,6 +12,7 @@ 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 { deduplicateFolderName } from '@/lib/folders/naming' import { toFolderApi } from '@/lib/folders/queries' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -40,8 +41,14 @@ export const POST = withRouteHandler( const sourceFolder = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt))) + .from(folderTable) + .where( + and( + eq(folderTable.id, sourceFolderId), + isNull(folderTable.deletedAt), + eq(folderTable.resourceType, 'workflow') + ) + ) .then((rows) => rows[0]) if (!sourceFolder) { @@ -70,17 +77,23 @@ export const POST = withRouteHandler( await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId) const folderParentCondition = targetParentId - ? eq(workflowFolder.parentId, targetParentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, targetParentId) + : isNull(folderTable.parentId) const workflowParentCondition = targetParentId ? eq(workflow.folderId, targetParentId) : isNull(workflow.folderId) const [[folderResult], [workflowResult]] = await Promise.all([ tx - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + folderParentCondition + ) + ), tx .select({ minSortOrder: min(workflow.sortOrder) }) .from(workflow) @@ -102,15 +115,14 @@ export const POST = withRouteHandler( name ) - await tx.insert(workflowFolder).values({ + await tx.insert(folderTable).values({ id: newFolderId, + resourceType: 'workflow', userId: session.user.id, workspaceId: targetWorkspaceId, name: deduplicatedName, - color: sourceFolder.color, parentId: targetParentId, sortOrder, - isExpanded: false, locked: false, createdAt: now, updatedAt: now, @@ -169,8 +181,8 @@ export const POST = withRouteHandler( const duplicatedFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, newFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 }) @@ -230,14 +242,14 @@ async function assertTargetParentFolderMutable( visited.add(currentFolderId) const [folder] = await tx .select({ - id: workflowFolder.id, - parentId: workflowFolder.parentId, - workspaceId: workflowFolder.workspaceId, - locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + id: folderTable.id, + parentId: folderTable.parentId, + workspaceId: folderTable.workspaceId, + locked: folderTable.locked, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { @@ -254,37 +266,6 @@ async function assertTargetParentFolderMutable( } } -async function deduplicateFolderName( - tx: DbOrTx, - workspaceId: string, - parentId: string | null, - requestedName: string -): Promise { - const parentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) - const siblingRows = await tx - .select({ name: workflowFolder.name }) - .from(workflowFolder) - .where( - and( - eq(workflowFolder.workspaceId, workspaceId), - parentCondition, - isNull(workflowFolder.archivedAt) - ) - ) - const siblingNames = new Set(siblingRows.map((row) => row.name)) - if (!siblingNames.has(requestedName)) return requestedName - - let suffix = 1 - let candidate = `${requestedName} (${suffix})` - while (siblingNames.has(candidate)) { - suffix += 1 - candidate = `${requestedName} (${suffix})` - } - return candidate -} - async function duplicateFolderStructure( tx: DbOrTx, sourceFolderId: string, @@ -297,12 +278,13 @@ async function duplicateFolderStructure( ): Promise { const childFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, sourceFolderId), - eq(workflowFolder.workspaceId, sourceWorkspaceId), - isNull(workflowFolder.archivedAt) + eq(folderTable.parentId, sourceFolderId), + eq(folderTable.workspaceId, sourceWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -310,15 +292,14 @@ async function duplicateFolderStructure( const newChildFolderId = generateId() folderMapping.set(childFolder.id, newChildFolderId) - await tx.insert(workflowFolder).values({ + await tx.insert(folderTable).values({ id: newChildFolderId, + resourceType: 'workflow', userId, workspaceId: targetWorkspaceId, name: childFolder.name, - color: childFolder.color, parentId: newParentFolderId, sortOrder: childFolder.sortOrder, - isExpanded: false, locked: false, createdAt: timestamp, updatedAt: timestamp, diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index d242609fba0..d636429248b 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -73,7 +73,7 @@ const mockFolder = { /** Queues the folder-existence lookup the route runs before authorizing. */ function queueFolderLookup(folder: Record = mockFolder) { - queueTableRows(schemaMock.workflowFolder, [folder]) + queueTableRows(schemaMock.folder, [folder]) } /** Makes the next folder lookup throw, exercising the route's 500 path. */ diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index dde474af258..60753cb0685 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -13,6 +13,14 @@ import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** Maps an orchestration errorCode to its HTTP status; mirrors the POST /api/folders route. */ +function folderMutationStatus(errorCode: string | undefined): number { + if (errorCode === 'validation') return 400 + if (errorCode === 'conflict') return 409 + if (errorCode === 'not_found') return 404 + return 500 +} + const logger = createLogger('FoldersIDAPI') // PUT - Update a folder @@ -44,8 +52,8 @@ export const PUT = withRouteHandler( // Verify the folder exists const existingFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) + .from(folderTable) + .where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) if (!existingFolder) { @@ -92,8 +100,7 @@ export const PUT = withRouteHandler( }) if (!result.success || !result.folder) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + const status = folderMutationStatus(result.errorCode) return NextResponse.json({ error: result.error }, { status }) } @@ -125,8 +132,8 @@ export const DELETE = withRouteHandler( // Verify the folder exists const existingFolder = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) + .from(folderTable) + .where(and(eq(folderTable.id, id), eq(folderTable.resourceType, 'workflow'))) .then((rows) => rows[0]) if (!existingFolder) { diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index b361abf6df1..d734cea4047 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' @@ -37,9 +37,9 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { const folderIds = updates.map((u) => u.id) const existingFolders = await db - .select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId }) - .from(workflowFolder) - .where(inArray(workflowFolder.id, folderIds)) + .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) + .from(folderTable) + .where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, 'workflow'))) const validIds = new Set( existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) @@ -58,12 +58,14 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { if (targetParentIds.length > 0) { const parentFolders = await db .select({ - id: workflowFolder.id, - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, + id: folderTable.id, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(inArray(workflowFolder.id, targetParentIds)) + .from(folderTable) + .where( + and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, 'workflow')) + ) const validParentIds = new Set( parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) @@ -81,9 +83,11 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { } const workspaceFolders = await db - .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) + ) const parentById = new Map() for (const folder of workspaceFolders) { @@ -126,7 +130,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { if (update.parentId !== undefined) { updateData.parentId = update.parentId || null } - await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id)) + await tx + .update(folderTable) + .set(updateData) + .where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, 'workflow'))) } }) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 8c0210e12b9..35dd03c15c0 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -579,35 +579,5 @@ describe('Folders API Route', () => { expect(capturedValues).not.toBeNull() expect(capturedValues!.name).toBe('Test Folder With Spaces') }) - - it('should use default color when not provided', async () => { - mockAuthenticatedUser() - - let capturedValues: CapturedFolderValues | null = null - - mockTransaction.mockImplementationOnce( - createMockTransaction({ - selectResults: [[], []], - insertResult: [mockFolders[0]], - onInsertValues: (values) => { - capturedValues = values - }, - }) - ) - mockValues.mockImplementationOnce((values: CapturedFolderValues) => { - capturedValues = values - return { returning: mockReturning } - }) - - const req = createMockRequest('POST', { - name: 'Test Folder', - workspaceId: 'workspace-123', - }) - - await POST(req) - - expect(capturedValues).not.toBeNull() - expect(capturedValues!.color).toBe('#6B7280') - }) }) }) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index ddec95e57d3..498a77d0a38 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -12,9 +12,9 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' @@ -106,12 +106,12 @@ export const GET = withRouteHandler( try { const [folderData] = await db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - workspaceId: workflowFolder.workspaceId, + id: folderTable.id, + name: folderTable.name, + workspaceId: folderTable.workspaceId, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) + .from(folderTable) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!folderData) { @@ -125,12 +125,17 @@ export const GET = withRouteHandler( const allFolders = await db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - parentId: workflowFolder.parentId, + id: folderTable.id, + name: folderTable.name, + parentId: folderTable.parentId, }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, folderData.workspaceId)) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, folderData.workspaceId), + eq(folderTable.resourceType, 'workflow') + ) + ) const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders) const subfolders = collectSubfolders(folderId, allFolders) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 77e5d7f6366..4256076d457 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -7,13 +7,13 @@ import type { auditLog, + folder as folderTable, member, organization, subscription, user, userStats, workflow, - workflowFolder, workspace, } from '@sim/db/schema' import type { InferSelectModel } from 'drizzle-orm' @@ -27,7 +27,7 @@ import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/typ export type DbUser = InferSelectModel export type DbWorkspace = InferSelectModel export type DbWorkflow = InferSelectModel -export type DbWorkflowFolder = InferSelectModel +export type DbWorkflowFolder = InferSelectModel export type DbOrganization = InferSelectModel export type DbSubscription = InferSelectModel export type DbMember = InferSelectModel @@ -156,6 +156,11 @@ export interface AdminFolder { id: string name: string parentId: string | null + /** + * Always `null` since folders moved to the generic `folder` table, which has no `color` + * column (it had no consumer). Retained so the v1 admin response shape stays stable for + * existing API clients rather than silently dropping a documented field. + */ color: string | null sortOrder: number createdAt: string @@ -167,7 +172,7 @@ export function toAdminFolder(dbFolder: DbWorkflowFolder): AdminFolder { id: dbFolder.id, name: dbFolder.name, parentId: dbFolder.parentId, - color: dbFolder.color, + color: null, sortOrder: dbFolder.sortOrder, createdAt: dbFolder.createdAt.toISOString(), updatedAt: dbFolder.updatedAt.toISOString(), diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index 06204ab2898..c3c80b9d790 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -13,9 +13,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { folder as folderTable, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' @@ -68,8 +68,10 @@ export const GET = withRouteHandler( const folders = await db .select() - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) + ) const workflowExports: Array<{ workflow: WorkspaceExportPayload['workflows'][number]['workflow'] diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts index 1b6269efeec..f2786433ce3 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -11,9 +11,9 @@ */ import { db } from '@sim/db' -import { workflowFolder, workspace } from '@sim/db/schema' +import { folder as folderTable, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -49,13 +49,17 @@ export const GET = withRouteHandler( const [countResult, folders] = await Promise.all([ db .select({ total: count() }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) + ), db .select() - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) - .orderBy(workflowFolder.sortOrder, workflowFolder.name) + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) + ) + .orderBy(folderTable.sortOrder, folderTable.name) .limit(limit) .offset(offset), ]) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index 967e432ef39..378aa8acedc 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -24,11 +24,11 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { adminV1ImportWorkspaceContract, @@ -76,6 +76,51 @@ interface ParsedWorkflow { folderPath: string[] } +/** + * Returns the id of the active workflow folder named `name` under `parentId`, creating it if + * absent — `mkdir -p` semantics. + * + * The generic `folder` table enforces active sibling-name uniqueness, which + * `workflow_folder` did not, so blindly inserting an import path segment that already exists + * now fails the whole import on a unique violation. Reusing the existing folder is also the + * behaviour an import of a folder *path* should have: importing into "Reports/2026" twice + * should land in one tree, not two. + */ +async function ensureImportFolder( + workspaceId: string, + userId: string, + name: string, + parentId: string | null +): Promise { + const [existing] = await db + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + eq(folderTable.name, name), + parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId), + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (existing) return existing.id + + const folderId = generateId() + await db.insert(folderTable).values({ + id: folderId, + resourceType: 'workflow', + name, + userId, + workspaceId, + parentId, + createdAt: new Date(), + updatedAt: new Date(), + }) + return folderId +} + export const POST = withRouteHandler( withAdminAuthParams(async (request, context) => { const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context) @@ -151,16 +196,12 @@ export const POST = withRouteHandler( let rootFolderId: string | undefined if (rootFolderName && createFolders) { - rootFolderId = generateId() - await db.insert(workflowFolder).values({ - id: rootFolderId, - name: rootFolderName, - userId: workspaceData.ownerId, + rootFolderId = await ensureImportFolder( workspaceId, - parentId: null, - createdAt: new Date(), - updatedAt: new Date(), - }) + workspaceData.ownerId, + rootFolderName, + null + ) } const folderMap = new Map() @@ -229,16 +270,12 @@ async function importSingleWorkflow( const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment if (!folderMap.has(fullPath)) { - const folderId = generateId() - await db.insert(workflowFolder).values({ - id: folderId, - name: wf.folderPath[i], - userId: ownerId, + const folderId = await ensureImportFolder( workspaceId, - parentId, - createdAt: new Date(), - updatedAt: new Date(), - }) + ownerId, + wf.folderPath[i], + parentId + ) folderMap.set(fullPath, folderId) parentId = folderId } else { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts index b2ebdd5a82f..8e822ca5459 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -7,9 +7,9 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { folder as folderTable, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq } from 'drizzle-orm' +import { and, count, eq } from 'drizzle-orm' import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -49,8 +49,10 @@ export const GET = withRouteHandler( db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)), db .select({ count: count() }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow')) + ), ]) const data: AdminWorkspaceDetail = { diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 106b6bf8faf..fa7c349267d 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -95,7 +95,7 @@ describe('Workflows API Route - POST ordering', () => { it('uses top insertion against mixed siblings (folders + workflows)', async () => { queueTableRows(schemaMock.workflow, []) queueTableRows(schemaMock.workflow, [{ minOrder: 5 }]) - queueTableRows(schemaMock.workflowFolder, [{ minOrder: 2 }]) + queueTableRows(schemaMock.folder, [{ minOrder: 2 }]) const req = createMockRequest('POST', { name: 'New Workflow', diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 7db8b2cc8f0..4ec2b4e02dc 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -2,12 +2,12 @@ import { db, dbFor } from '@sim/db' import { copilotChats, document, + folder as folderTable, knowledgeBase, mcpServers, memory, userTableDefinitions, workflow, - workflowFolder, workflowMcpServer, workspaceFile, workspaceFiles, @@ -413,10 +413,14 @@ async function cleanupExpiredKnowledgeBases( */ const CLEANUP_TARGETS = [ { - table: workflowFolder, - softDeleteCol: workflowFolder.archivedAt, - wsCol: workflowFolder.workspaceId, - name: 'workflowFolder', + table: folderTable, + softDeleteCol: folderTable.deletedAt, + wsCol: folderTable.workspaceId, + // `folder` is shared by all four resource types. Only workflow folders are cut over to + // it; file/knowledge_base/table rows are still owned elsewhere, so this pass must not + // hard-delete them. + additionalPredicate: eq(folderTable.resourceType, 'workflow'), + name: 'folder', }, { table: userTableDefinitions, @@ -675,6 +679,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise retentionDate, tableName: `${label}/${target.name}`, requireTimestampNotNull: true, + additionalPredicate: 'additionalPredicate' in target ? target.additionalPredicate : undefined, dbClient: cleanupDb, }) totalDeleted += result.deleted diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 62ee9de38c2..514954d74d0 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -1,4 +1,4 @@ -import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -73,9 +73,13 @@ export async function resolveForkFolderMapping({ const sourceFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( - and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(folderTable.workspaceId, sourceWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) ) if (sourceFolders.length === 0) return map @@ -96,9 +100,13 @@ export async function resolveForkFolderMapping({ const targetFolders = await tx .select() - .from(workflowFolder) + .from(folderTable) .where( - and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) ) const targetByKey = new Map() @@ -149,7 +157,7 @@ export async function resolveForkFolderMapping({ } if (newFolders.length > 0) { - await tx.insert(workflowFolder).values(newFolders) + await tx.insert(folderTable).values(newFolders) } return map diff --git a/apps/sim/lib/api/contracts/folders.ts b/apps/sim/lib/api/contracts/folders.ts index f82847070bf..a75b4bd3a7e 100644 --- a/apps/sim/lib/api/contracts/folders.ts +++ b/apps/sim/lib/api/contracts/folders.ts @@ -29,7 +29,13 @@ export type FolderApi = z.output export const listFoldersQuerySchema = z.object({ workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), - resourceType: folderResourceTypeSchema.default('workflow'), + /** + * Only workflow folders are served today — file folders are still written to + * `workspace_file_folders` and kb/table have no writer yet. Narrowed rather than + * accepting the full enum and silently answering "you have none", matching the same + * choice made on the create and reorder bodies. Widens as each type's writers land. + */ + resourceType: z.literal('workflow').default('workflow'), scope: folderScopeSchema.default('active'), }) diff --git a/apps/sim/lib/cleanup/batch-delete.ts b/apps/sim/lib/cleanup/batch-delete.ts index 7b0ede50fd9..6e51c6ceb77 100644 --- a/apps/sim/lib/cleanup/batch-delete.ts +++ b/apps/sim/lib/cleanup/batch-delete.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, inArray, isNotNull, lt, sql } from 'drizzle-orm' +import { and, inArray, isNotNull, lt, type SQL, sql } from 'drizzle-orm' import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' const logger = createLogger('BatchDelete') @@ -196,6 +196,12 @@ export interface BatchDeleteOptions { tableName: string /** When true, also requires `timestampCol IS NOT NULL` (soft-delete semantics). */ requireTimestampNotNull?: boolean + /** + * Extra predicate ANDed into the row selection. Needed for tables shared by several + * resource kinds (e.g. `folder`, which holds workflow/file/knowledge_base/table rows) + * so a cleanup pass only ever removes the kind it owns. + */ + additionalPredicate?: SQL batchSize?: number maxBatches?: number workspaceChunkSize?: number @@ -216,6 +222,7 @@ export async function batchDeleteByWorkspaceAndTimestamp({ retentionDate, tableName, requireTimestampNotNull = false, + additionalPredicate, dbClient = db, ...rest }: BatchDeleteOptions): Promise { @@ -227,6 +234,7 @@ export async function batchDeleteByWorkspaceAndTimestamp({ selectChunk: (chunkIds, limit) => { const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)] if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol)) + if (additionalPredicate) predicates.push(additionalPredicate) return dbClient .select({ id: sql`id` }) .from(tableDef) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 5276e3257f6..f06cc1ec17a 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -1,11 +1,11 @@ import { db } from '@sim/db' import { + folder as folderTable, knowledgeBase, knowledgeConnector, mcpServers, userTableDefinitions, workflow, - workflowFolder, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -379,12 +379,18 @@ async function buildWorkspaceMdData( db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - parentId: workflowFolder.parentId, + id: folderTable.id, + name: folderTable.name, + parentId: folderTable.parentId, }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))), + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) + ), db .select({ diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts index 449b201d6ff..7fa6fa52f7d 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { and, asc, eq, isNull } from 'drizzle-orm' import { buildWorkflowAliasWorkflowEntries, @@ -40,15 +40,19 @@ export async function resolveWorkflowAliasForWorkspace(args: { .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), db .select({ - folderId: workflowFolder.id, - folderName: workflowFolder.name, - parentId: workflowFolder.parentId, + folderId: folderTable.id, + folderName: folderTable.name, + parentId: folderTable.parentId, }) - .from(workflowFolder) + .from(folderTable) .where( - and(eq(workflowFolder.workspaceId, args.workspaceId), isNull(workflowFolder.archivedAt)) + and( + eq(folderTable.workspaceId, args.workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) ) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)), + .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)), ]) return resolveWorkflowAliasPath( canonicalPath, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index c21b8361827..24d788280b6 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -5,6 +5,7 @@ import { copilotChats, customTools as customToolsTable, document, + folder as folderTable, jobExecutionLogs, knowledgeBaseTagDefinitions, knowledgeConnector, @@ -12,7 +13,6 @@ import { skill as skillTable, workflowDeploymentVersion, workflowExecutionLogs, - workflowFolder, workflowMcpServer, workflowMcpTool, workflowSchedule, @@ -2411,13 +2411,17 @@ export class WorkspaceVFS { listWorkflows(workspaceId, { scope: 'archived' }), db .select({ - id: workflowFolder.id, - name: workflowFolder.name, - archivedAt: workflowFolder.archivedAt, + id: folderTable.id, + name: folderTable.name, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) + .from(folderTable) .where( - and(eq(workflowFolder.workspaceId, workspaceId), isNotNull(workflowFolder.archivedAt)) + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNotNull(folderTable.deletedAt) + ) ), listTables(workspaceId, { scope: 'archived' }), listWorkspaceFiles(workspaceId, { scope: 'archived' }), diff --git a/apps/sim/lib/folders/naming.ts b/apps/sim/lib/folders/naming.ts new file mode 100644 index 00000000000..f6b5f2a4de0 --- /dev/null +++ b/apps/sim/lib/folders/naming.ts @@ -0,0 +1,46 @@ +import { type db, folder as folderTable } from '@sim/db' +import { and, eq, isNull } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' + +type DbOrTx = Pick + +/** + * Returns `requestedName`, or the first `" (N)"` variant not already taken by an + * active sibling under `parentId`. + * + * The generic `folder` table has a partial unique index on active + * `(workspaceId, resourceType, parentId, name)`, so any path that makes a row active with a + * caller-supplied name has to either dedup here or handle a 23505. Use this where the user + * has no opportunity to choose a different name (duplicate, restore); return a conflict + * instead where they do (create, rename). + * + * The `" (N)"` shape deliberately matches both the client-side dedup in + * `useFolderCreateWithDedup` and the backfill in migration 0272, so a deduped name reads the + * same however it was produced. + */ +export async function deduplicateFolderName( + tx: DbOrTx, + workspaceId: string, + parentId: string | null, + requestedName: string, + resourceType: FolderResourceType = 'workflow' +): Promise { + const siblingRows = await tx + .select({ name: folderTable.name }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType), + parentId ? eq(folderTable.parentId, parentId) : isNull(folderTable.parentId), + isNull(folderTable.deletedAt) + ) + ) + + const siblingNames = new Set(siblingRows.map((row) => row.name)) + if (!siblingNames.has(requestedName)) return requestedName + + let suffix = 1 + while (siblingNames.has(`${requestedName} (${suffix})`)) suffix += 1 + return `${requestedName} (${suffix})` +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index d6691bd872f..dfc6062150b 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,37 +1,22 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder } from '@sim/db/schema' import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' /** - * Adapts a legacy `workflow_folder` row to the generic `FolderApi` wire shape. - * - * The wire contract has already moved to the generic vocabulary (`resourceType`, - * `deletedAt`) while the read path still targets `workflow_folder` — the writers have not - * been cut over to the `folder` table yet. Keeping the adapter here means the cutover is a - * one-file change to the query below, with no contract or consumer churn. - * - * `color`/`isExpanded` are intentionally not surfaced: neither had a consumer, and - * expansion state is client-only (see `stores/folders/types.ts`). + * Normalizes a `folder` row to the `FolderApi` wire shape (timestamps as ISO strings). * * Exported because every folder route — list AND mutations — must emit the same shape. - * `requestJson` validates responses against the contract, so a mutation returning a raw - * row fails client-side parse after the write has already succeeded. + * `requestJson` validates responses against the contract, so a mutation returning a raw row + * fails client-side parse after the write has already succeeded. */ -export function toFolderApi(row: typeof workflowFolder.$inferSelect): FolderApi { +export function toFolderApi(row: typeof folder.$inferSelect): FolderApi { return { - id: row.id, - resourceType: 'workflow', - name: row.name, - userId: row.userId, - workspaceId: row.workspaceId, - parentId: row.parentId, - locked: row.locked, - sortOrder: row.sortOrder, + ...row, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - deletedAt: row.archivedAt ? row.archivedAt.toISOString() : null, + deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null, } } @@ -41,18 +26,15 @@ export async function listFoldersForWorkspace( scope: FolderQueryScope, resourceType: FolderResourceType = 'workflow' ): Promise { - // Only workflow folders exist in the legacy table; the other resource types have no rows - // until the `folder` cutover lands, so they correctly return empty rather than erroring. - if (resourceType !== 'workflow') return [] - - const scopeFilter = - scope === 'archived' ? isNotNull(workflowFolder.archivedAt) : isNull(workflowFolder.archivedAt) + const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) const rows = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), scopeFilter)) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + .from(folder) + .where( + and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), scopeFilter) + ) + .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/logs/folder-expansion.ts b/apps/sim/lib/logs/folder-expansion.ts index 1ac5c599a70..4fdbbf5f1b9 100644 --- a/apps/sim/lib/logs/folder-expansion.ts +++ b/apps/sim/lib/logs/folder-expansion.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' +import { folder as folderTable } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' /** @@ -23,9 +23,15 @@ export async function expandFolderIdsWithDescendants( if (seedIds.length === 0) return folderIdsCsv const rows = await db - .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))) + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) + ) const childrenByParent = new Map() for (const row of rows) { diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index d1b08caed71..a59db6ae5d4 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -2,10 +2,10 @@ import { db } from '@sim/db' import { apiKey, chat, + folder as folderTable, webhook, workflow, workflowDeploymentVersion, - workflowFolder, workflowMcpTool, workflowSchedule, workspace, @@ -240,9 +240,11 @@ export async function restoreWorkflow( let clearFolderId = false if (existingWorkflow.folderId) { const [folder] = await db - .select({ archivedAt: workflowFolder.archivedAt }) - .from(workflowFolder) - .where(eq(workflowFolder.id, existingWorkflow.folderId)) + .select({ archivedAt: folderTable.deletedAt }) + .from(folderTable) + .where( + and(eq(folderTable.id, existingWorkflow.folderId), eq(folderTable.resourceType, 'workflow')) + ) if (!folder || folder.archivedAt) { clearFolderId = true diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index c32ffbc58c3..4159442b056 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -2,15 +2,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat, + folder as folderTable, webhook, workflow, - workflowFolder, workflowMcpTool, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, min } from 'drizzle-orm' +import { deduplicateFolderName } from '@/lib/folders/naming' import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { checkForCircularReference } from '@/lib/workflows/utils' @@ -31,7 +33,7 @@ export interface PerformCreateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect + folder?: typeof folderTable.$inferSelect } export interface PerformUpdateFolderParams { @@ -39,8 +41,6 @@ export interface PerformUpdateFolderParams { workspaceId: string userId: string name?: string - color?: string - isExpanded?: boolean locked?: boolean parentId?: string | null sortOrder?: number @@ -50,7 +50,7 @@ export interface PerformUpdateFolderResult { success: boolean error?: string errorCode?: OrchestrationErrorCode - folder?: typeof workflowFolder.$inferSelect + folder?: typeof folderTable.$inferSelect } /** @@ -66,11 +66,11 @@ async function assertParentFolderInWorkspace( ): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { const [parent] = await db .select({ - workspaceId: workflowFolder.workspaceId, - archivedAt: workflowFolder.archivedAt, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, parentId)) + .from(folderTable) + .where(and(eq(folderTable.id, parentId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) { @@ -85,17 +85,23 @@ async function nextFolderSortOrder( parentId: string | null | undefined ): Promise { const folderParentCondition = parentId - ? eq(workflowFolder.parentId, parentId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, parentId) + : isNull(folderTable.parentId) const workflowParentCondition = parentId ? eq(workflow.folderId, parentId) : isNull(workflow.folderId) const [[folderResult], [workflowResult]] = await Promise.all([ db - .select({ minSortOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + folderParentCondition + ) + ), db .select({ minSortOrder: min(workflow.sortOrder) }) .from(workflow) @@ -138,14 +144,14 @@ export async function performCreateFolder( : await nextFolderSortOrder(params.workspaceId, parentId) const [folder] = await db - .insert(workflowFolder) + .insert(folderTable) .values({ id: folderId, + resourceType: 'workflow', name: params.name.trim(), userId: params.userId, workspaceId: params.workspaceId, parentId, - color: params.color || '#6B7280', sortOrder, }) .returning() @@ -164,13 +170,24 @@ export async function performCreateFolder( name: folder.name, workspaceId: params.workspaceId, parentId: parentId || undefined, - color: folder.color, sortOrder: folder.sortOrder, }, }) return { success: true, folder } } catch (error) { + // `folder` carries a unique index on (workspaceId, resourceType, parent, name) for active + // rows that `workflow_folder` never had, so a duplicate sibling name is newly rejectable + // here. Map it to a 409 rather than letting it surface as a 500 — the client-side dedup + // in useFolderCreateWithDedup is best-effort and races, and the copilot/import paths + // create folders by name. + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } logger.error('Failed to create workflow folder', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } @@ -198,21 +215,22 @@ export async function performUpdateFolder( } } - const updates: Record = { updatedAt: new Date() } + // Typed against the table rather than `Record`: the loose type is what + // let `color`/`isExpanded` survive the cutover here after the create path dropped them. + const updates: Partial = { updatedAt: new Date() } if (params.name !== undefined) updates.name = params.name.trim() - if (params.color !== undefined) updates.color = params.color - if (params.isExpanded !== undefined) updates.isExpanded = params.isExpanded if (params.locked !== undefined) updates.locked = params.locked if (params.parentId !== undefined) updates.parentId = params.parentId || null if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder const [folder] = await db - .update(workflowFolder) + .update(folderTable) .set(updates) .where( and( - eq(workflowFolder.id, params.folderId), - eq(workflowFolder.workspaceId, params.workspaceId) + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, 'workflow') ) ) .returning() @@ -225,6 +243,13 @@ export async function performUpdateFolder( return { success: true, folder } } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + } + } logger.error('Failed to update workflow folder', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } @@ -244,13 +269,14 @@ async function deleteFolderRecursively( const stats = { folders: 0, workflows: 0 } const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - isNull(workflowFolder.archivedAt) + eq(folderTable.parentId, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -281,9 +307,9 @@ async function deleteFolderRecursively( } await db - .update(workflowFolder) - .set({ archivedAt: timestamp }) - .where(eq(workflowFolder.id, folderId)) + .update(folderTable) + .set({ deletedAt: timestamp }) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) stats.folders += 1 return stats @@ -312,13 +338,14 @@ async function countWorkflowsInFolderRecursively( count += workflowsInFolder.length const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - isNull(workflowFolder.archivedAt) + eq(folderTable.parentId, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) ) ) @@ -406,7 +433,10 @@ async function restoreFolderRecursively( ): Promise<{ folders: number; workflows: number }> { const stats = { folders: 0, workflows: 0 } - await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId)) + await tx + .update(folderTable) + .set({ deletedAt: null }) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) stats.folders += 1 const archivedWorkflows = await tx @@ -441,13 +471,14 @@ async function restoreFolderRecursively( } const archivedChildren = await tx - .select({ id: workflowFolder.id }) - .from(workflowFolder) + .select({ id: folderTable.id }) + .from(folderTable) .where( and( - eq(workflowFolder.parentId, folderId), - eq(workflowFolder.workspaceId, workspaceId), - eq(workflowFolder.archivedAt, folderArchivedAt) + eq(folderTable.parentId, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + eq(folderTable.deletedAt, folderArchivedAt) ) ) @@ -486,14 +517,20 @@ export async function performRestoreFolder( const [folder] = await db .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow') + ) + ) if (!folder) { return { success: false, error: 'Folder not found' } } - if (!folder.archivedAt) { + if (!folder.deletedAt) { return { success: true, restoredItems: { folders: 0, workflows: 0 } } } @@ -503,23 +540,63 @@ export async function performRestoreFolder( return { success: false, error: 'Cannot restore folder into an archived workspace' } } - const restoredStats = await db.transaction(async (tx) => { - if (folder.parentId) { - const [parentFolder] = await tx - .select({ archivedAt: workflowFolder.archivedAt }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folder.parentId)) - - if (!parentFolder || parentFolder.archivedAt) { - await tx - .update(workflowFolder) - .set({ parentId: null }) - .where(eq(workflowFolder.id, folderId)) + let restoredStats: { folders: number; workflows: number } + try { + restoredStats = await db.transaction(async (tx) => { + // A folder whose parent is still archived is re-rooted, so the name it has to be + // unique against is its *resolved* parent's sibling set, not its original one. + let resolvedParentId = folder.parentId + if (folder.parentId) { + const [parentFolder] = await tx + .select({ archivedAt: folderTable.deletedAt }) + .from(folderTable) + .where(and(eq(folderTable.id, folder.parentId), eq(folderTable.resourceType, 'workflow'))) + + if (!parentFolder || parentFolder.archivedAt) { + resolvedParentId = null + await tx + .update(folderTable) + .set({ parentId: null }) + .where(and(eq(folderTable.id, folderId), eq(folderTable.resourceType, 'workflow'))) + } } - } - return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx) - }) + // Restore is a recovery action — the caller cannot rename an archived folder, so a + // name already taken by an active sibling would leave them permanently unable to + // restore. Dedup instead of failing. Safe to rename while the row is still archived: + // the unique index only covers active rows, so this cannot collide before the + // recursive restore below clears `deletedAt`. Only the restore root can conflict — + // descendants come back alongside the siblings they were archived with. + const restoredName = await deduplicateFolderName( + tx, + workspaceId, + resolvedParentId, + folder.name + ) + if (restoredName !== folder.name) { + logger.info('Renamed folder on restore to avoid a sibling name conflict', { + folderId, + from: folder.name, + to: restoredName, + }) + await tx.update(folderTable).set({ name: restoredName }).where(eq(folderTable.id, folderId)) + } + + return restoreFolderRecursively(folderId, workspaceId, folder.deletedAt!, tx) + }) + } catch (error) { + // Restoring clears `deletedAt`, which brings the row back under the generic table's + // partial unique index on active (workspaceId, resourceType, parent, name) — a + // constraint `workflow_folder` never had. If a sibling has since taken the name, report + // it as a conflict rather than a 500, so the caller can rename and retry. + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'A folder with this name already exists in this location', + } + } + throw error + } logger.info('Restored folder and all contents:', { folderId, restoredStats }) diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 4b07cd492fe..9ccee7d5171 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { workflow, workflowFolder } from '@sim/db/schema' +import { folder as folderTable, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' @@ -121,8 +121,8 @@ async function nextWorkflowSortOrder( ? eq(workflow.folderId, folderId) : isNull(workflow.folderId) const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, folderId) + : isNull(folderTable.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ db @@ -136,9 +136,15 @@ async function nextWorkflowSortOrder( ) ), db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .select({ minOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index b8328c900e2..c2a09c242ae 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -37,7 +37,7 @@ function queueDuplicateFixtures(options: { }) { queueTableRows(schemaMock.workflow, [options.sourceWorkflow]) queueTableRows(schemaMock.workflow, options.workflowMin ?? []) - queueTableRows(schemaMock.workflowFolder, options.folderMin ?? []) + queueTableRows(schemaMock.folder, options.folderMin ?? []) queueTableRows(schemaMock.workflowBlocks, options.blocks ?? []) queueTableRows(schemaMock.workflowEdges, options.edges ?? []) queueTableRows(schemaMock.workflowSubflows, options.subflows ?? []) diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 9895a90fb7f..3d4600e9811 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' import { + folder as folderTable, workflow, workflowBlocks, workflowEdges, - workflowFolder, workflowSubflows, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -73,14 +73,14 @@ async function assertTargetFolderMutable( visited.add(currentFolderId) const [folder] = await tx .select({ - id: workflowFolder.id, - parentId: workflowFolder.parentId, - workspaceId: workflowFolder.workspaceId, - locked: workflowFolder.locked, - archivedAt: workflowFolder.archivedAt, + id: folderTable.id, + parentId: folderTable.parentId, + workspaceId: folderTable.workspaceId, + locked: folderTable.locked, + archivedAt: folderTable.deletedAt, }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentFolderId)) + .from(folderTable) + .where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow'))) .limit(1) if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { @@ -182,8 +182,8 @@ export async function duplicateWorkflow( ? eq(workflow.folderId, targetFolderId) : isNull(workflow.folderId) const folderParentCondition = targetFolderId - ? eq(workflowFolder.parentId, targetFolderId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, targetFolderId) + : isNull(folderTable.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ tx @@ -191,9 +191,15 @@ export async function duplicateWorkflow( .from(workflow) .where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)), tx - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)), + .select({ minOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, targetWorkspaceId), + eq(folderTable.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< number | null diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index ced3f838e93..682a736d951 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -1,9 +1,9 @@ import { db } from '@sim/db' -import { workflowFolder, workflow as workflowTable } from '@sim/db/schema' +import { folder as folderTable, 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 { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' @@ -401,8 +401,8 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { ? eq(workflowTable.folderId, folderId) : isNull(workflowTable.folderId) const folderParentCondition = folderId - ? eq(workflowFolder.parentId, folderId) - : isNull(workflowFolder.parentId) + ? eq(folderTable.parentId, folderId) + : isNull(folderTable.parentId) const [[workflowMinResult], [folderMinResult]] = await Promise.all([ db @@ -416,9 +416,15 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { ) ), db - .select({ minOrder: min(workflowFolder.sortOrder) }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)), + .select({ minOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + folderParentCondition + ) + ), ]) const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce< @@ -486,91 +492,27 @@ export async function setWorkflowVariables(workflowId: string, variables: Record // ── 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 = { 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 { const [row] = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow') + ) + ) .limit(1) return Boolean(row) } -export async function deleteFolderRecord(folderId: string): Promise { - 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. + * Walks the parent chain upward from `parentId` to check whether re-parenting `folderId` + * under it would form a cycle. Returns true when a cycle would be created. */ export async function checkForCircularReference( folderId: string, @@ -587,9 +529,9 @@ export async function checkForCircularReference( visited.add(currentParentId) const [parent] = await db - .select({ parentId: workflowFolder.parentId }) - .from(workflowFolder) - .where(eq(workflowFolder.id, currentParentId)) + .select({ parentId: folderTable.parentId }) + .from(folderTable) + .where(and(eq(folderTable.id, currentParentId), eq(folderTable.resourceType, 'workflow'))) .limit(1) currentParentId = parent?.parentId || null @@ -601,13 +543,19 @@ export async function checkForCircularReference( export async function listFolders(workspaceId: string) { return db .select({ - folderId: workflowFolder.id, - folderName: workflowFolder.name, - parentId: workflowFolder.parentId, - sortOrder: workflowFolder.sortOrder, - locked: workflowFolder.locked, + folderId: folderTable.id, + folderName: folderTable.name, + parentId: folderTable.parentId, + sortOrder: folderTable.sortOrder, + locked: folderTable.locked, }) - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) + ) + .orderBy(asc(folderTable.sortOrder), asc(folderTable.createdAt)) } diff --git a/packages/platform-authz/src/workflow.ts b/packages/platform-authz/src/workflow.ts index c1d643a5850..7e50f880a2c 100644 --- a/packages/platform-authz/src/workflow.ts +++ b/packages/platform-authz/src/workflow.ts @@ -1,4 +1,4 @@ -import { db, workflow, workflowFolder, workspace } from '@sim/db' +import { db, folder as folderTable, workflow, workspace } from '@sim/db' import { and, eq, isNull } from 'drizzle-orm' import { type PermissionType, @@ -107,12 +107,18 @@ export async function getFolderLockStatus(folderId: string | null): Promise