feat(folders): soft-delete folders and show in Recently Deleted (#4001)

* feat(folders): soft-delete folders and show in Recently Deleted

Folders are now soft-deleted (archived) instead of permanently removed,
matching the existing pattern for workflows, tables, and knowledge bases.
Users can restore folders from Settings > Recently Deleted.

- Add `archivedAt` column to `workflowFolder` schema with index
- Change folder deletion to set `archivedAt` instead of hard-delete
- Add folder restore endpoint (POST /api/folders/[id]/restore)
- Batch-restore all workflows inside restored folders in one transaction
- Add scope filter to GET /api/folders (active/archived)
- Add Folders tab to Recently Deleted settings page
- Update delete modal messaging for restorable items
- Change "This action cannot be undone" styling to muted text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(testing): add FOLDER_RESTORED to audit mock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(folders): atomic restore transaction and scope to folder-deleted workflows

Address two review findings:
- Wrap entire folder restore in a single DB transaction to prevent
  partial state if any step fails
- Only restore workflows archived within 5s of the folder's archivedAt,
  so individually-deleted workflows are not silently un-deleted
- Add folder_restored to PostHog event map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(folders): simplify restore to remove hacky 5s time window

The 5-second time window for scoping which workflows to restore was
a fragile heuristic (magic number, race-prone, non-deterministic).
Restoring a folder now restores all archived workflows in it, matching
standard trash/recycle-bin behavior. Users can re-delete any workflow
they don't want after restore.

The single-transaction wrapping from the prior commit is kept — that
was a legitimate atomicity fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): regenerate folder soft-delete migration with drizzle-kit

Replace manually created migration with proper drizzle-kit generated
one that includes the snapshot file, fixing CI schema sync check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(db): fix migration metadata formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(folders): scope restore to folder-deleted workflows via shared timestamp

Use a single timestamp across the entire folder deletion — folders,
workflows, schedules, webhooks, etc. all get the exact same archivedAt.
On restore, match workflows by exact archivedAt equality with the
folder's timestamp, so individually-deleted workflows are not
silently un-deleted.

- Add optional archivedAt to ArchiveWorkflowOptions (backwards-compatible)
- Pass shared timestamp through deleteFolderRecursively → archiveWorkflowsByIdsInWorkspace
- Filter restore with eq(workflow.archivedAt, folderArchivedAt) instead of isNotNull

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(workflows): clear folderId on restore when folder is archived or missing

When individually restoring a workflow from Recently Deleted, check if
its folder still exists and is active. If the folder is archived or
missing, clear folderId so the workflow appears at root instead of
being orphaned (invisible in sidebar).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(folders): format restoreFolderRecursively call to satisfy biome

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(folders): close remaining restore edge cases

Three issues caught by audit:

1. Child folder restore used isNotNull instead of timestamp matching,
   so individually-deleted child folders would be incorrectly restored.
   Now uses eq(archivedAt, folderArchivedAt) for both workflows AND
   child folders — consistent and deterministic.

2. No workspace archived check — could restore a folder into an
   archived workspace. Now checks getWorkspaceWithOwner, matching
   the existing restoreWorkflow pattern.

3. Re-restoring an already-restored folder returned an error. Now
   returns success with zero counts (idempotent).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(folders): add archivedAt to optimistic folder creation objects

Ensures optimistic folder objects include archivedAt: null for
consistency with the database schema shape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(folders): handle missing parent folder during restore reparenting

If the parent folder row no longer exists (not just archived), the
restored folder now correctly gets reparented to root instead of
retaining a dangling parentId reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-06 20:06:04 -07:00
committed by GitHub
parent 609ba619bc
commit 89ae738745
19 changed files with 15032 additions and 41 deletions
@@ -0,0 +1,58 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { captureServerEvent } from '@/lib/posthog/server'
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreFolderAPI')
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id: folderId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json().catch(() => ({}))
const workspaceId = body.workspaceId as string | undefined
if (!workspaceId) {
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const result = await performRestoreFolder({
folderId,
workspaceId,
userId: session.user.id,
})
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
captureServerEvent(
session.user.id,
'folder_restored',
{ folder_id: folderId, workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, restoredItems: result.restoredItems })
} catch (error) {
logger.error(`Error restoring folder ${folderId}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
)
}
}
+8 -4
View File
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, isNull, min } from 'drizzle-orm'
import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
@@ -47,12 +47,16 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
}
// If user has workspace permissions, fetch ALL folders in the workspace
// This allows shared workspace members to see folders created by other users
const scope = searchParams.get('scope') ?? 'active'
const archivedFilter =
scope === 'archived'
? isNotNull(workflowFolder.archivedAt)
: isNull(workflowFolder.archivedAt)
const folders = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter))
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
return NextResponse.json({ folders })
@@ -1,7 +1,7 @@
'use client'
import { useMemo, useState } from 'react'
import { Search } from 'lucide-react'
import { Folder, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button, Combobox, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
import { Input } from '@/components/ui'
@@ -9,6 +9,7 @@ import { formatDate } from '@/lib/core/utils/formatting'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { DeletedItemSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/deleted-item-skeleton'
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows'
@@ -29,10 +30,12 @@ function getResourceHref(
return `${base}/knowledge/${id}`
case 'file':
return `${base}/files`
case 'folder':
return `${base}/w`
}
}
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file'
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file' | 'folder'
type SortColumn = 'deleted' | 'name' | 'type'
@@ -51,7 +54,9 @@ const SORT_OPTIONS: { column: SortColumn; direction: 'asc' | 'desc'; label: stri
const ICON_CLASS = 'h-[14px] w-[14px]'
const RESOURCE_TYPE_TO_MOTHERSHIP: Record<Exclude<ResourceType, 'all'>, MothershipResourceType> = {
const RESOURCE_TYPE_TO_MOTHERSHIP: Partial<
Record<Exclude<ResourceType, 'all'>, MothershipResourceType>
> = {
workflow: 'workflow',
table: 'table',
knowledge: 'knowledgebase',
@@ -70,6 +75,7 @@ interface DeletedResource {
const TABS: { id: ResourceType; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'workflow', label: 'Workflows' },
{ id: 'folder', label: 'Folders' },
{ id: 'table', label: 'Tables' },
{ id: 'knowledge', label: 'Knowledge Bases' },
{ id: 'file', label: 'Files' },
@@ -77,6 +83,7 @@ const TABS: { id: ResourceType; label: string }[] = [
const TYPE_LABEL: Record<Exclude<ResourceType, 'all'>, string> = {
workflow: 'Workflow',
folder: 'Folder',
table: 'Table',
knowledge: 'Knowledge Base',
file: 'File',
@@ -97,7 +104,13 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
)
}
if (resource.type === 'folder') {
const color = resource.color ?? '#6B7280'
return <Folder className={ICON_CLASS} style={{ color }} />
}
const mothershipType = RESOURCE_TYPE_TO_MOTHERSHIP[resource.type]
if (!mothershipType) return null
const config = RESOURCE_REGISTRY[mothershipType]
return (
<>
@@ -120,23 +133,30 @@ export function RecentlyDeleted() {
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' })
const foldersQuery = useFolders(workspaceId, { scope: 'archived' })
const tablesQuery = useTablesList(workspaceId, 'archived')
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
const restoreWorkflow = useRestoreWorkflow()
const restoreFolder = useRestoreFolder()
const restoreTable = useRestoreTable()
const restoreKnowledgeBase = useRestoreKnowledgeBase()
const restoreWorkspaceFile = useRestoreWorkspaceFile()
const isLoading =
workflowsQuery.isLoading ||
foldersQuery.isLoading ||
tablesQuery.isLoading ||
knowledgeQuery.isLoading ||
filesQuery.isLoading
const error =
workflowsQuery.error || tablesQuery.error || knowledgeQuery.error || filesQuery.error
workflowsQuery.error ||
foldersQuery.error ||
tablesQuery.error ||
knowledgeQuery.error ||
filesQuery.error
const resources = useMemo<DeletedResource[]>(() => {
const items: DeletedResource[] = []
@@ -152,6 +172,17 @@ export function RecentlyDeleted() {
})
}
for (const folder of foldersQuery.data ?? []) {
items.push({
id: folder.id,
name: folder.name,
type: 'folder',
deletedAt: folder.archivedAt ? new Date(folder.archivedAt) : new Date(folder.updatedAt),
workspaceId: folder.workspaceId,
color: folder.color,
})
}
for (const t of tablesQuery.data ?? []) {
items.push({
id: t.id,
@@ -193,6 +224,7 @@ export function RecentlyDeleted() {
return items
}, [
workflowsQuery.data,
foldersQuery.data,
tablesQuery.data,
knowledgeQuery.data,
filesQuery.data,
@@ -250,6 +282,12 @@ export function RecentlyDeleted() {
{ onSettled, onSuccess }
)
break
case 'folder':
restoreFolder.mutate(
{ folderId: resource.id, workspaceId: resource.workspaceId },
{ onSettled, onSuccess }
)
break
case 'table':
restoreTable.mutate(resource.id, { onSettled, onSuccess })
break
@@ -64,7 +64,7 @@ export function DeleteModal({
title = 'Delete Workspace'
}
const restorableTypes = new Set<string>(['workflow'])
const restorableTypes = new Set<string>(['workflow', 'folder', 'mixed'])
const renderDescription = () => {
if (itemType === 'workflow') {
@@ -113,8 +113,7 @@ export function DeleteModal({
</span>
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all workflows, logs, and knowledge bases within these
folders.
All workflows and contents within these folders will be archived.
</span>
</>
)
@@ -125,7 +124,7 @@ export function DeleteModal({
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, logs, and knowledge bases.
All associated workflows and contents will be archived.
</span>
</>
)
@@ -134,7 +133,7 @@ export function DeleteModal({
<>
Are you sure you want to delete this folder?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, logs, and knowledge bases.
All associated workflows and contents will be archived.
</span>
</>
)
@@ -186,8 +185,7 @@ export function DeleteModal({
</span>
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all selected workflows and folders, including their
contents.
All selected workflows and folders, including their contents, will be archived.
</span>
</>
)
@@ -196,8 +194,7 @@ export function DeleteModal({
<>
Are you sure you want to delete the selected items?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all selected workflows and folders, including their
contents.
All selected workflows and folders, including their contents, will be archived.
</span>
</>
)
@@ -238,7 +235,7 @@ export function DeleteModal({
You can restore it from Recently Deleted in Settings.
</span>
) : (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
)}
</p>
</ModalBody>
@@ -614,7 +614,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
{memberToRemove?.email}
</span>{' '}
from this workspace?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
@@ -646,7 +646,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
<span className='font-medium text-[var(--text-primary)]'>
{invitationToRemove?.email}
</span>
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
? <span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
@@ -668,7 +668,7 @@ export function WorkspaceHeader({
Are you sure you want to leave{' '}
<span className='font-base text-[var(--text-primary)]'>{leaveTarget?.name}</span>? You
will lose access to all workflows and data in this workspace.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
@@ -1290,7 +1290,7 @@ export function AccessControl() {
<span className='text-[var(--text-error)]'>
All members will be removed from this group.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
+46 -8
View File
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { generateId } from '@/lib/core/utils/uuid'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { type FolderQueryScope, folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import {
createOptimisticMutationHandlers,
@@ -26,11 +26,16 @@ function mapFolder(folder: any): WorkflowFolder {
sortOrder: folder.sortOrder,
createdAt: new Date(folder.createdAt),
updatedAt: new Date(folder.updatedAt),
archivedAt: folder.archivedAt ? new Date(folder.archivedAt) : null,
}
}
async function fetchFolders(workspaceId: string, signal?: AbortSignal): Promise<WorkflowFolder[]> {
const response = await fetch(`/api/folders?workspaceId=${workspaceId}`, { signal })
async function fetchFolders(
workspaceId: string,
scope: FolderQueryScope = 'active',
signal?: AbortSignal
): Promise<WorkflowFolder[]> {
const response = await fetch(`/api/folders?workspaceId=${workspaceId}&scope=${scope}`, { signal })
if (!response.ok) {
throw new Error('Failed to fetch folders')
@@ -40,10 +45,11 @@ async function fetchFolders(workspaceId: string, signal?: AbortSignal): Promise<
return folders.map(mapFolder)
}
export function useFolders(workspaceId?: string) {
export function useFolders(workspaceId?: string, options?: { scope?: FolderQueryScope }) {
const scope = options?.scope ?? 'active'
return useQuery({
queryKey: folderKeys.list(workspaceId),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, signal),
queryKey: folderKeys.list(workspaceId, scope),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, signal),
enabled: Boolean(workspaceId),
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
@@ -53,7 +59,7 @@ export function useFolders(workspaceId?: string) {
export function useFolderMap(workspaceId?: string) {
return useQuery({
queryKey: folderKeys.list(workspaceId),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, signal),
queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', signal),
enabled: Boolean(workspaceId),
placeholderData: keepPreviousData,
staleTime: 60 * 1000,
@@ -158,6 +164,7 @@ export function useCreateFolder() {
),
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
}
},
(variables) => variables.id ?? generateId()
@@ -223,7 +230,37 @@ export function useDeleteFolderMutation() {
return response.json()
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({ queryKey: folderKeys.lists() })
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
}
interface RestoreFolderVariables {
workspaceId: string
folderId: string
}
export function useRestoreFolder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, folderId }: RestoreFolderVariables) => {
const response = await fetch(`/api/folders/${folderId}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new Error(error.error || 'Failed to restore folder')
}
return response.json()
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: folderKeys.lists() })
return invalidateWorkflowLists(queryClient, variables.workspaceId, ['active', 'archived'])
},
})
@@ -258,6 +295,7 @@ export function useDuplicateFolderMutation() {
),
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
}
},
(variables) => variables.newId ?? generateId()
+4 -1
View File
@@ -1,5 +1,8 @@
export type FolderQueryScope = 'active' | 'archived'
export const folderKeys = {
all: ['folders'] as const,
lists: () => [...folderKeys.all, 'list'] as const,
list: (workspaceId: string | undefined) => [...folderKeys.lists(), workspaceId ?? ''] as const,
list: (workspaceId: string | undefined, scope: FolderQueryScope = 'active') =>
[...folderKeys.lists(), workspaceId ?? '', scope] as const,
}
+1
View File
@@ -67,6 +67,7 @@ export const AuditAction = {
FOLDER_CREATED: 'folder.created',
FOLDER_DELETED: 'folder.deleted',
FOLDER_DUPLICATED: 'folder.duplicated',
FOLDER_RESTORED: 'folder.restored',
// Forms
FORM_CREATED: 'form.created',
+5
View File
@@ -441,6 +441,11 @@ export interface PostHogEventMap {
workspace_id: string
}
folder_restored: {
folder_id: string
workspace_id: string
}
logs_filter_applied: {
filter_type: 'status' | 'workflow' | 'folder' | 'trigger' | 'time'
workspace_id: string
+20 -2
View File
@@ -6,6 +6,7 @@ import {
webhook,
workflow,
workflowDeploymentVersion,
workflowFolder,
workflowMcpTool,
workflowSchedule,
} from '@sim/db/schema'
@@ -22,6 +23,7 @@ const logger = createLogger('WorkflowLifecycle')
interface ArchiveWorkflowOptions {
requestId: string
notifySocket?: boolean
archivedAt?: Date
}
async function notifyWorkflowArchived(workflowId: string, requestId: string): Promise<void> {
@@ -120,7 +122,7 @@ export async function archiveWorkflow(
return { archived: false, workflow: existingWorkflow }
}
const now = new Date()
const now = options.archivedAt ?? new Date()
const affectedWorkflowMcpServers = await db
.select({ serverId: workflowMcpTool.serverId })
.from(workflowMcpTool)
@@ -257,12 +259,28 @@ 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))
if (!folder || folder.archivedAt) {
clearFolderId = true
}
}
const now = new Date()
await db.transaction(async (tx) => {
await tx
.update(workflow)
.set({ archivedAt: null, updatedAt: now })
.set({
archivedAt: null,
updatedAt: now,
...(clearFolderId && { folderId: null }),
})
.where(eq(workflow.id, workflowId))
await tx
@@ -1,7 +1,16 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import {
a2aAgent,
chat,
form,
webhook,
workflow,
workflowFolder,
workflowMcpTool,
workflowSchedule,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
@@ -15,17 +24,25 @@ const logger = createLogger('FolderLifecycle')
*/
export async function deleteFolderRecursively(
folderId: string,
workspaceId: string
workspaceId: string,
archivedAt?: Date
): Promise<{ folders: number; workflows: number }> {
const timestamp = archivedAt ?? new Date()
const stats = { folders: 0, workflows: 0 }
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
.where(
and(
eq(workflowFolder.parentId, folderId),
eq(workflowFolder.workspaceId, workspaceId),
isNull(workflowFolder.archivedAt)
)
)
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId, timestamp)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
@@ -45,12 +62,15 @@ export async function deleteFolderRecursively(
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}` }
{ requestId: `folder-${folderId}`, archivedAt: timestamp }
)
stats.workflows += workflowsInFolder.length
}
await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
await db
.update(workflowFolder)
.set({ archivedAt: timestamp })
.where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
@@ -81,7 +101,13 @@ export async function countWorkflowsInFolderRecursively(
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
.where(
and(
eq(workflowFolder.parentId, folderId),
eq(workflowFolder.workspaceId, workspaceId),
isNull(workflowFolder.archivedAt)
)
)
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
@@ -153,3 +179,154 @@ export async function performDeleteFolder(
return { success: true, deletedItems: deletionStats }
}
/**
* Recursively restores a folder and its children/workflows within a transaction.
* Only restores workflows whose `archivedAt` matches the folder's — workflows
* individually deleted before the folder are left archived.
*/
async function restoreFolderRecursively(
folderId: string,
workspaceId: string,
folderArchivedAt: Date,
tx: Parameters<Parameters<typeof db.transaction>[0]>[0]
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId))
stats.folders += 1
const archivedWorkflows = await tx
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
eq(workflow.archivedAt, folderArchivedAt)
)
)
if (archivedWorkflows.length > 0) {
const workflowIds = archivedWorkflows.map((wf) => wf.id)
const now = new Date()
const restoreSet = { archivedAt: null, updatedAt: now }
await tx.update(workflow).set(restoreSet).where(inArray(workflow.id, workflowIds))
await tx
.update(workflowSchedule)
.set(restoreSet)
.where(inArray(workflowSchedule.workflowId, workflowIds))
await tx.update(webhook).set(restoreSet).where(inArray(webhook.workflowId, workflowIds))
await tx.update(chat).set(restoreSet).where(inArray(chat.workflowId, workflowIds))
await tx.update(form).set(restoreSet).where(inArray(form.workflowId, workflowIds))
await tx
.update(workflowMcpTool)
.set(restoreSet)
.where(inArray(workflowMcpTool.workflowId, workflowIds))
await tx.update(a2aAgent).set(restoreSet).where(inArray(a2aAgent.workflowId, workflowIds))
stats.workflows += archivedWorkflows.length
}
const archivedChildren = await tx
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.parentId, folderId),
eq(workflowFolder.workspaceId, workspaceId),
eq(workflowFolder.archivedAt, folderArchivedAt)
)
)
for (const child of archivedChildren) {
const childStats = await restoreFolderRecursively(child.id, workspaceId, folderArchivedAt, tx)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
return stats
}
/** Parameters for {@link performRestoreFolder}. */
export interface PerformRestoreFolderParams {
folderId: string
workspaceId: string
userId: string
folderName?: string
}
/** Outcome of {@link performRestoreFolder}. */
export interface PerformRestoreFolderResult {
success: boolean
error?: string
restoredItems?: { folders: number; workflows: number }
}
/**
* Restores an archived folder and all its archived children and workflows.
* If the folder's parent is still archived, moves it to the root level.
*/
export async function performRestoreFolder(
params: PerformRestoreFolderParams
): Promise<PerformRestoreFolderResult> {
const { folderId, workspaceId, userId, folderName } = params
const [folder] = await db
.select()
.from(workflowFolder)
.where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId)))
if (!folder) {
return { success: false, error: 'Folder not found' }
}
if (!folder.archivedAt) {
return { success: true, restoredItems: { folders: 0, workflows: 0 } }
}
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws || ws.archivedAt) {
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))
}
}
return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx)
})
logger.info('Restored folder and all contents:', { folderId, restoredStats })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_RESTORED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName ?? folder.name,
description: `Restored folder "${folderName ?? folder.name}"`,
metadata: {
affected: {
workflows: restoredStats.workflows,
subfolders: restoredStats.folders - 1,
},
},
})
return { success: true, restoredItems: restoredStats }
}
+1
View File
@@ -19,6 +19,7 @@ export interface WorkflowFolder {
sortOrder: number
createdAt: Date
updatedAt: Date
archivedAt?: Date | null
}
export interface FolderTreeNode extends WorkflowFolder {
@@ -0,0 +1,2 @@
ALTER TABLE "workflow_folder" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint
CREATE INDEX "workflow_folder_archived_at_idx" ON "workflow_folder" USING btree ("archived_at");
File diff suppressed because it is too large Load Diff
@@ -1296,6 +1296,13 @@
"when": 1775247973312,
"tag": "0185_new_gravity",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1775525922688,
"tag": "0186_greedy_jocasta",
"breakpoints": true
}
]
}
+2
View File
@@ -130,6 +130,7 @@ export const workflowFolder = pgTable(
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
archivedAt: timestamp('archived_at'),
},
(table) => ({
userIdx: index('workflow_folder_user_idx').on(table.userId),
@@ -138,6 +139,7 @@ export const workflowFolder = pgTable(
table.parentId
),
parentSortIdx: index('workflow_folder_parent_sort_idx').on(table.parentId, table.sortOrder),
archivedAtIdx: index('workflow_folder_archived_at_idx').on(table.archivedAt),
})
)
+1
View File
@@ -50,6 +50,7 @@ export const auditMock = {
FOLDER_CREATED: 'folder.created',
FOLDER_DELETED: 'folder.deleted',
FOLDER_DUPLICATED: 'folder.duplicated',
FOLDER_RESTORED: 'folder.restored',
FORM_CREATED: 'form.created',
FORM_UPDATED: 'form.updated',
FORM_DELETED: 'form.deleted',