mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
+42
-4
@@ -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
|
||||
|
||||
+7
-10
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user