Skip to content

Commit 89ae738

Browse files
waleedlatif1claude
andauthored
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>
1 parent 609ba61 commit 89ae738

File tree

19 files changed

+15032
-41
lines changed

19 files changed

+15032
-41
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { captureServerEvent } from '@/lib/posthog/server'
5+
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
6+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
7+
8+
const logger = createLogger('RestoreFolderAPI')
9+
10+
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
11+
const { id: folderId } = await params
12+
13+
try {
14+
const session = await getSession()
15+
if (!session?.user?.id) {
16+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
17+
}
18+
19+
const body = await request.json().catch(() => ({}))
20+
const workspaceId = body.workspaceId as string | undefined
21+
22+
if (!workspaceId) {
23+
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
24+
}
25+
26+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
27+
if (permission !== 'admin' && permission !== 'write') {
28+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
29+
}
30+
31+
const result = await performRestoreFolder({
32+
folderId,
33+
workspaceId,
34+
userId: session.user.id,
35+
})
36+
37+
if (!result.success) {
38+
return NextResponse.json({ error: result.error }, { status: 400 })
39+
}
40+
41+
logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
42+
43+
captureServerEvent(
44+
session.user.id,
45+
'folder_restored',
46+
{ folder_id: folderId, workspace_id: workspaceId },
47+
{ groups: { workspace: workspaceId } }
48+
)
49+
50+
return NextResponse.json({ success: true, restoredItems: result.restoredItems })
51+
} catch (error) {
52+
logger.error(`Error restoring folder ${folderId}`, error)
53+
return NextResponse.json(
54+
{ error: error instanceof Error ? error.message : 'Internal server error' },
55+
{ status: 500 }
56+
)
57+
}
58+
}

apps/sim/app/api/folders/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { workflow, workflowFolder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, asc, eq, isNull, min } from 'drizzle-orm'
4+
import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
@@ -47,12 +47,16 @@ export async function GET(request: NextRequest) {
4747
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
4848
}
4949

50-
// If user has workspace permissions, fetch ALL folders in the workspace
51-
// This allows shared workspace members to see folders created by other users
50+
const scope = searchParams.get('scope') ?? 'active'
51+
const archivedFilter =
52+
scope === 'archived'
53+
? isNotNull(workflowFolder.archivedAt)
54+
: isNull(workflowFolder.archivedAt)
55+
5256
const folders = await db
5357
.select()
5458
.from(workflowFolder)
55-
.where(eq(workflowFolder.workspaceId, workspaceId))
59+
.where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter))
5660
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
5761

5862
return NextResponse.json({ folders })

apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
'use client'
22

33
import { useMemo, useState } from 'react'
4-
import { Search } from 'lucide-react'
4+
import { Folder, Search } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
66
import { Button, Combobox, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
77
import { Input } from '@/components/ui'
88
import { formatDate } from '@/lib/core/utils/formatting'
99
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
1010
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
1111
import { DeletedItemSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/deleted-item-skeleton'
12+
import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
1213
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
1314
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
1415
import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows'
@@ -29,10 +30,12 @@ function getResourceHref(
2930
return `${base}/knowledge/${id}`
3031
case 'file':
3132
return `${base}/files`
33+
case 'folder':
34+
return `${base}/w`
3235
}
3336
}
3437

35-
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file'
38+
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file' | 'folder'
3639

3740
type SortColumn = 'deleted' | 'name' | 'type'
3841

@@ -51,7 +54,9 @@ const SORT_OPTIONS: { column: SortColumn; direction: 'asc' | 'desc'; label: stri
5154

5255
const ICON_CLASS = 'h-[14px] w-[14px]'
5356

54-
const RESOURCE_TYPE_TO_MOTHERSHIP: Record<Exclude<ResourceType, 'all'>, MothershipResourceType> = {
57+
const RESOURCE_TYPE_TO_MOTHERSHIP: Partial<
58+
Record<Exclude<ResourceType, 'all'>, MothershipResourceType>
59+
> = {
5560
workflow: 'workflow',
5661
table: 'table',
5762
knowledge: 'knowledgebase',
@@ -70,13 +75,15 @@ interface DeletedResource {
7075
const TABS: { id: ResourceType; label: string }[] = [
7176
{ id: 'all', label: 'All' },
7277
{ id: 'workflow', label: 'Workflows' },
78+
{ id: 'folder', label: 'Folders' },
7379
{ id: 'table', label: 'Tables' },
7480
{ id: 'knowledge', label: 'Knowledge Bases' },
7581
{ id: 'file', label: 'Files' },
7682
]
7783

7884
const TYPE_LABEL: Record<Exclude<ResourceType, 'all'>, string> = {
7985
workflow: 'Workflow',
86+
folder: 'Folder',
8087
table: 'Table',
8188
knowledge: 'Knowledge Base',
8289
file: 'File',
@@ -97,7 +104,13 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
97104
)
98105
}
99106

107+
if (resource.type === 'folder') {
108+
const color = resource.color ?? '#6B7280'
109+
return <Folder className={ICON_CLASS} style={{ color }} />
110+
}
111+
100112
const mothershipType = RESOURCE_TYPE_TO_MOTHERSHIP[resource.type]
113+
if (!mothershipType) return null
101114
const config = RESOURCE_REGISTRY[mothershipType]
102115
return (
103116
<>
@@ -120,23 +133,30 @@ export function RecentlyDeleted() {
120133
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
121134

122135
const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' })
136+
const foldersQuery = useFolders(workspaceId, { scope: 'archived' })
123137
const tablesQuery = useTablesList(workspaceId, 'archived')
124138
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
125139
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
126140

127141
const restoreWorkflow = useRestoreWorkflow()
142+
const restoreFolder = useRestoreFolder()
128143
const restoreTable = useRestoreTable()
129144
const restoreKnowledgeBase = useRestoreKnowledgeBase()
130145
const restoreWorkspaceFile = useRestoreWorkspaceFile()
131146

132147
const isLoading =
133148
workflowsQuery.isLoading ||
149+
foldersQuery.isLoading ||
134150
tablesQuery.isLoading ||
135151
knowledgeQuery.isLoading ||
136152
filesQuery.isLoading
137153

138154
const error =
139-
workflowsQuery.error || tablesQuery.error || knowledgeQuery.error || filesQuery.error
155+
workflowsQuery.error ||
156+
foldersQuery.error ||
157+
tablesQuery.error ||
158+
knowledgeQuery.error ||
159+
filesQuery.error
140160

141161
const resources = useMemo<DeletedResource[]>(() => {
142162
const items: DeletedResource[] = []
@@ -152,6 +172,17 @@ export function RecentlyDeleted() {
152172
})
153173
}
154174

175+
for (const folder of foldersQuery.data ?? []) {
176+
items.push({
177+
id: folder.id,
178+
name: folder.name,
179+
type: 'folder',
180+
deletedAt: folder.archivedAt ? new Date(folder.archivedAt) : new Date(folder.updatedAt),
181+
workspaceId: folder.workspaceId,
182+
color: folder.color,
183+
})
184+
}
185+
155186
for (const t of tablesQuery.data ?? []) {
156187
items.push({
157188
id: t.id,
@@ -193,6 +224,7 @@ export function RecentlyDeleted() {
193224
return items
194225
}, [
195226
workflowsQuery.data,
227+
foldersQuery.data,
196228
tablesQuery.data,
197229
knowledgeQuery.data,
198230
filesQuery.data,
@@ -250,6 +282,12 @@ export function RecentlyDeleted() {
250282
{ onSettled, onSuccess }
251283
)
252284
break
285+
case 'folder':
286+
restoreFolder.mutate(
287+
{ folderId: resource.id, workspaceId: resource.workspaceId },
288+
{ onSettled, onSuccess }
289+
)
290+
break
253291
case 'table':
254292
restoreTable.mutate(resource.id, { onSettled, onSuccess })
255293
break

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function DeleteModal({
6464
title = 'Delete Workspace'
6565
}
6666

67-
const restorableTypes = new Set<string>(['workflow'])
67+
const restorableTypes = new Set<string>(['workflow', 'folder', 'mixed'])
6868

6969
const renderDescription = () => {
7070
if (itemType === 'workflow') {
@@ -113,8 +113,7 @@ export function DeleteModal({
113113
</span>
114114
?{' '}
115115
<span className='text-[var(--text-error)]'>
116-
This will permanently remove all workflows, logs, and knowledge bases within these
117-
folders.
116+
All workflows and contents within these folders will be archived.
118117
</span>
119118
</>
120119
)
@@ -125,7 +124,7 @@ export function DeleteModal({
125124
Are you sure you want to delete{' '}
126125
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
127126
<span className='text-[var(--text-error)]'>
128-
This will permanently remove all associated workflows, logs, and knowledge bases.
127+
All associated workflows and contents will be archived.
129128
</span>
130129
</>
131130
)
@@ -134,7 +133,7 @@ export function DeleteModal({
134133
<>
135134
Are you sure you want to delete this folder?{' '}
136135
<span className='text-[var(--text-error)]'>
137-
This will permanently remove all associated workflows, logs, and knowledge bases.
136+
All associated workflows and contents will be archived.
138137
</span>
139138
</>
140139
)
@@ -186,8 +185,7 @@ export function DeleteModal({
186185
</span>
187186
?{' '}
188187
<span className='text-[var(--text-error)]'>
189-
This will permanently remove all selected workflows and folders, including their
190-
contents.
188+
All selected workflows and folders, including their contents, will be archived.
191189
</span>
192190
</>
193191
)
@@ -196,8 +194,7 @@ export function DeleteModal({
196194
<>
197195
Are you sure you want to delete the selected items?{' '}
198196
<span className='text-[var(--text-error)]'>
199-
This will permanently remove all selected workflows and folders, including their
200-
contents.
197+
All selected workflows and folders, including their contents, will be archived.
201198
</span>
202199
</>
203200
)
@@ -238,7 +235,7 @@ export function DeleteModal({
238235
You can restore it from Recently Deleted in Settings.
239236
</span>
240237
) : (
241-
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
238+
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
242239
)}
243240
</p>
244241
</ModalBody>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
614614
{memberToRemove?.email}
615615
</span>{' '}
616616
from this workspace?{' '}
617-
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
617+
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
618618
</p>
619619
</ModalBody>
620620
<ModalFooter>
@@ -646,7 +646,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
646646
<span className='font-medium text-[var(--text-primary)]'>
647647
{invitationToRemove?.email}
648648
</span>
649-
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
649+
? <span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
650650
</p>
651651
</ModalBody>
652652
<ModalFooter>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ export function WorkspaceHeader({
668668
Are you sure you want to leave{' '}
669669
<span className='font-base text-[var(--text-primary)]'>{leaveTarget?.name}</span>? You
670670
will lose access to all workflows and data in this workspace.{' '}
671-
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
671+
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
672672
</p>
673673
</ModalBody>
674674
<ModalFooter>

apps/sim/ee/access-control/components/access-control.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1290,7 +1290,7 @@ export function AccessControl() {
12901290
<span className='text-[var(--text-error)]'>
12911291
All members will be removed from this group.
12921292
</span>{' '}
1293-
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
1293+
<span className='text-[var(--text-tertiary)]'>This action cannot be undone.</span>
12941294
</p>
12951295
</ModalBody>
12961296
<ModalFooter>

0 commit comments

Comments
 (0)