Skip to content

Commit 803ad19

Browse files
committed
complete workspace files feature: audit logs, posthog events, folder restore, empty state, keyboard shortcuts, storage indicator, breadcrumb rename
- Audit + PostHog: wire file_renamed, file_deleted, file_moved, file_bulk_deleted, folder_created, folder_renamed, folder_deleted, folder_moved events to all file/folder API routes - Add AuditAction.FOLDER_UPDATED, FILE_MOVED, FOLDER_MOVED to audit types - Folder restore: server function, contract, API route (POST /files/folders/[folderId]/restore), hook (useRestoreWorkspaceFileFolder), Recently Deleted integration with new File Folders tab - Empty state: contextual emptyMessage passed to <Resource> based on search/filters/folder context - Keyboard shortcuts: Delete/Backspace deletes selection, Escape deselects, Cmd+A selects all (list view only, input-aware guard) - Storage indicator: useStorageInfo drives compact "used / limit" display in file list header via leadingActions - Breadcrumb rename: current folder breadcrumb gains Rename dropdown + inline editing via breadcrumbRename (useInlineRename) - Resource: thread leadingActions prop from ResourceProps to ResourceHeader
1 parent 58947a7 commit 803ad19

17 files changed

Lines changed: 486 additions & 28 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { getSession } from '@/lib/auth'
1010
import { generateRequestId } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { captureServerEvent } from '@/lib/posthog/server'
1213
import {
1314
deleteWorkspaceFile,
1415
FileConflictError,
@@ -55,6 +56,12 @@ export const PATCH = withRouteHandler(
5556

5657
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`)
5758

59+
captureServerEvent(
60+
session.user.id,
61+
'file_renamed',
62+
{ workspace_id: workspaceId },
63+
{ groups: { workspace: workspaceId } }
64+
)
5865
recordAudit({
5966
workspaceId,
6067
actorId: session.user.id,
@@ -124,6 +131,12 @@ export const DELETE = withRouteHandler(
124131

125132
logger.info(`[${requestId}] Archived workspace file: ${fileId}`)
126133

134+
captureServerEvent(
135+
session.user.id,
136+
'file_deleted',
137+
{ workspace_id: workspaceId },
138+
{ groups: { workspace: workspaceId } }
139+
)
127140
recordAudit({
128141
workspaceId,
129142
actorId: session.user.id,

apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { createLogger } from '@sim/logger'
23
import { type NextRequest, NextResponse } from 'next/server'
34
import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
45
import { parseRequest } from '@/lib/api/server'
56
import { getSession } from '@/lib/auth'
67
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { captureServerEvent } from '@/lib/posthog/server'
79
import { bulkArchiveWorkspaceFileItems } from '@/lib/uploads/contexts/workspace'
810
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
911

@@ -28,6 +30,36 @@ export const POST = withRouteHandler(
2830

2931
try {
3032
const deletedItems = await bulkArchiveWorkspaceFileItems({ workspaceId, fileIds, folderIds })
33+
captureServerEvent(
34+
session.user.id,
35+
'file_bulk_deleted',
36+
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
37+
{ groups: { workspace: workspaceId } }
38+
)
39+
if (fileIds.length > 0) {
40+
recordAudit({
41+
workspaceId,
42+
actorId: session.user.id,
43+
actorName: session.user.name,
44+
actorEmail: session.user.email,
45+
action: AuditAction.FILE_DELETED,
46+
resourceType: AuditResourceType.FILE,
47+
description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`,
48+
metadata: { fileIds },
49+
})
50+
}
51+
if (folderIds.length > 0) {
52+
recordAudit({
53+
workspaceId,
54+
actorId: session.user.id,
55+
actorName: session.user.name,
56+
actorEmail: session.user.email,
57+
action: AuditAction.FOLDER_DELETED,
58+
resourceType: AuditResourceType.FOLDER,
59+
description: `Deleted ${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`,
60+
metadata: { folderIds },
61+
})
62+
}
3163
return NextResponse.json({ success: true, deletedItems })
3264
} catch (error) {
3365
logger.error('Failed to bulk archive workspace file items:', error)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { createLogger } from '@sim/logger'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { getSession } from '@/lib/auth'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { captureServerEvent } from '@/lib/posthog/server'
9+
import { restoreWorkspaceFileFolder } from '@/lib/uploads/contexts/workspace'
10+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
11+
12+
const logger = createLogger('WorkspaceFileFolderRestoreAPI')
13+
14+
export const POST = withRouteHandler(
15+
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
16+
const session = await getSession()
17+
if (!session?.user?.id) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
19+
}
20+
21+
const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context)
22+
if (!parsed.success) return parsed.response
23+
const { id: workspaceId, folderId } = parsed.data.params
24+
25+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
26+
if (permission !== 'admin' && permission !== 'write') {
27+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
28+
}
29+
30+
try {
31+
const folder = await restoreWorkspaceFileFolder(workspaceId, folderId)
32+
33+
logger.info(`Restored workspace file folder: ${folderId}`)
34+
35+
captureServerEvent(
36+
session.user.id,
37+
'folder_restored',
38+
{ folder_id: folderId, workspace_id: workspaceId },
39+
{ groups: { workspace: workspaceId } }
40+
)
41+
recordAudit({
42+
workspaceId,
43+
actorId: session.user.id,
44+
actorName: session.user.name,
45+
actorEmail: session.user.email,
46+
action: AuditAction.FOLDER_UPDATED,
47+
resourceType: AuditResourceType.FOLDER,
48+
resourceId: folderId,
49+
resourceName: folder.name,
50+
description: `Restored folder "${folder.name}"`,
51+
request,
52+
})
53+
54+
return NextResponse.json({ success: true, folder })
55+
} catch (error) {
56+
logger.error('Failed to restore workspace file folder:', error)
57+
return NextResponse.json(
58+
{
59+
success: false,
60+
error: error instanceof Error ? error.message : 'Failed to restore folder',
61+
},
62+
{ status: 400 }
63+
)
64+
}
65+
}
66+
)

apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { createLogger } from '@sim/logger'
23
import { getPostgresErrorCode } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
@@ -8,6 +9,7 @@ import {
89
import { parseRequest } from '@/lib/api/server'
910
import { getSession } from '@/lib/auth'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { captureServerEvent } from '@/lib/posthog/server'
1113
import {
1214
archiveWorkspaceFileFolderRecursive,
1315
updateWorkspaceFileFolder,
@@ -43,6 +45,23 @@ export const PATCH = withRouteHandler(
4345
folderId,
4446
...parsed.data.body,
4547
})
48+
captureServerEvent(
49+
session.user.id,
50+
'folder_renamed',
51+
{ workspace_id: workspaceId },
52+
{ groups: { workspace: workspaceId } }
53+
)
54+
recordAudit({
55+
workspaceId,
56+
actorId: session.user.id,
57+
actorName: session.user.name,
58+
actorEmail: session.user.email,
59+
action: AuditAction.FOLDER_UPDATED,
60+
resourceType: AuditResourceType.FOLDER,
61+
resourceId: folderId,
62+
resourceName: folder.name,
63+
description: `Updated folder "${folder.name}"`,
64+
})
4665
return NextResponse.json({ success: true, folder })
4766
} catch (error) {
4867
logger.error('Failed to update workspace file folder:', error)
@@ -86,6 +105,22 @@ export const DELETE = withRouteHandler(
86105

87106
try {
88107
const deletedItems = await archiveWorkspaceFileFolderRecursive(workspaceId, folderId)
108+
captureServerEvent(
109+
session.user.id,
110+
'folder_deleted',
111+
{ workspace_id: workspaceId },
112+
{ groups: { workspace: workspaceId } }
113+
)
114+
recordAudit({
115+
workspaceId,
116+
actorId: session.user.id,
117+
actorName: session.user.name,
118+
actorEmail: session.user.email,
119+
action: AuditAction.FOLDER_DELETED,
120+
resourceType: AuditResourceType.FOLDER,
121+
resourceId: folderId,
122+
description: `Deleted folder`,
123+
})
89124
return NextResponse.json({ success: true, deletedItems })
90125
} catch (error) {
91126
logger.error('Failed to delete workspace file folder:', error)

apps/sim/app/api/workspaces/[id]/files/folders/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { createLogger } from '@sim/logger'
23
import { type NextRequest, NextResponse } from 'next/server'
34
import {
@@ -7,6 +8,7 @@ import {
78
import { parseRequest } from '@/lib/api/server'
89
import { getSession } from '@/lib/auth'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { captureServerEvent } from '@/lib/posthog/server'
1012
import {
1113
createWorkspaceFileFolder,
1214
listWorkspaceFileFolders,
@@ -66,6 +68,23 @@ export const POST = withRouteHandler(
6668
name,
6769
parentId,
6870
})
71+
captureServerEvent(
72+
session.user.id,
73+
'folder_created',
74+
{ workspace_id: workspaceId },
75+
{ groups: { workspace: workspaceId } }
76+
)
77+
recordAudit({
78+
workspaceId,
79+
actorId: session.user.id,
80+
actorName: session.user.name,
81+
actorEmail: session.user.email,
82+
action: AuditAction.FOLDER_CREATED,
83+
resourceType: AuditResourceType.FOLDER,
84+
resourceId: folder.id,
85+
resourceName: folder.name,
86+
description: `Created folder "${folder.name}"`,
87+
})
6988
return NextResponse.json({ success: true, folder })
7089
} catch (error) {
7190
logger.error('Failed to create workspace file folder:', error)

apps/sim/app/api/workspaces/[id]/files/move/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { createLogger } from '@sim/logger'
23
import { getPostgresErrorCode } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
56
import { parseRequest } from '@/lib/api/server'
67
import { getSession } from '@/lib/auth'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { captureServerEvent } from '@/lib/posthog/server'
810
import {
911
moveWorkspaceFileItems,
1012
WorkspaceFileFolderConflictError,
@@ -38,6 +40,36 @@ export const POST = withRouteHandler(
3840
folderIds,
3941
targetFolderId,
4042
})
43+
captureServerEvent(
44+
session.user.id,
45+
'file_moved',
46+
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
47+
{ groups: { workspace: workspaceId } }
48+
)
49+
if (fileIds.length > 0) {
50+
recordAudit({
51+
workspaceId,
52+
actorId: session.user.id,
53+
actorName: session.user.name,
54+
actorEmail: session.user.email,
55+
action: AuditAction.FILE_MOVED,
56+
resourceType: AuditResourceType.FILE,
57+
description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`,
58+
metadata: { fileIds, targetFolderId },
59+
})
60+
}
61+
if (folderIds.length > 0) {
62+
recordAudit({
63+
workspaceId,
64+
actorId: session.user.id,
65+
actorName: session.user.name,
66+
actorEmail: session.user.email,
67+
action: AuditAction.FOLDER_MOVED,
68+
resourceType: AuditResourceType.FOLDER,
69+
description: `Moved ${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`,
70+
metadata: { folderIds, targetFolderId },
71+
})
72+
}
4173
return NextResponse.json({
4274
success: true,
4375
movedItems: { files: moved.movedFiles, folders: moved.movedFolders },

apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ interface ResourceProps {
7373
defaultSort?: string
7474
sort?: SortConfig
7575
headerActions?: HeaderAction[]
76+
leadingActions?: ReactNode
7677
columns: ResourceColumn[]
7778
rows: ResourceRow[]
7879
selectedRowId?: string | null
@@ -107,6 +108,7 @@ export const Resource = memo(function Resource({
107108
defaultSort,
108109
sort: sortOverride,
109110
headerActions,
111+
leadingActions,
110112
columns,
111113
rows,
112114
selectedRowId,
@@ -135,6 +137,7 @@ export const Resource = memo(function Resource({
135137
breadcrumbs={breadcrumbs}
136138
create={create}
137139
actions={headerActions}
140+
leadingActions={leadingActions}
138141
/>
139142
<ResourceOptionsBar
140143
search={search}
@@ -493,9 +496,17 @@ const DataRow = memo(function DataRow({
493496
const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false
494497
const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false
495498

496-
const handleClick = useCallback(() => {
497-
onRowClick?.(row.id)
498-
}, [onRowClick, row.id])
499+
const handleClick = useCallback(
500+
(e: React.MouseEvent<HTMLTableRowElement>) => {
501+
if (e.shiftKey && selectable && !selectable.disabled) {
502+
e.preventDefault()
503+
selectable.onSelectRow(row.id, true, true)
504+
return
505+
}
506+
onRowClick?.(row.id)
507+
},
508+
[onRowClick, row.id, selectable]
509+
)
499510

500511
const handleMouseEnter = useCallback(() => {
501512
onRowHover?.(row.id)
@@ -574,7 +585,7 @@ const DataRow = memo(function DataRow({
574585
)}
575586
data-drop-target={isDropTarget || undefined}
576587
draggable={isDraggable}
577-
onClick={onRowClick ? handleClick : undefined}
588+
onClick={onRowClick || selectable ? handleClick : undefined}
578589
onMouseEnter={handleMouseEnter}
579590
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
580591
onDragStart={isDraggable ? handleDragStart : undefined}

apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ export function FilesActionBar({
3131
animate={{ opacity: 1, y: 0 }}
3232
exit={{ opacity: 0, y: 10 }}
3333
transition={{ duration: 0.2 }}
34-
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
34+
className={cn(
35+
'-translate-x-1/2 fixed bottom-6 left-1/2 z-[var(--z-dropdown)] transform',
36+
className
37+
)}
3538
>
3639
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
3740
<span className='px-1 text-[var(--text-secondary)] text-small'>

0 commit comments

Comments
 (0)