Skip to content

Commit 045007a

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/desktop-prod-origin
# Conflicts: # .github/workflows/ci.yml # apps/desktop/README.md # apps/desktop/scripts/build.ts # apps/desktop/scripts/install-local.ts # apps/desktop/src/main/config.ts # apps/desktop/src/main/menu.test.ts # apps/desktop/src/main/menu.ts # apps/desktop/src/main/navigation.test.ts # apps/desktop/src/main/navigation.ts # apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts # packages/db/migrations/meta/_journal.json
2 parents 5e118e3 + a3413cf commit 045007a

119 files changed

Lines changed: 26021 additions & 1401 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -710,8 +710,19 @@ jobs:
710710
# and are always superseded by the next stable. The run-attempt
711711
# suffix keeps re-runs of the same workflow from colliding on the
712712
# tag while preserving semver ordering.
713-
LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' || true)"
714-
LATEST="${LATEST:-v0.0.0}"
713+
# Fail loudly if the query itself fails: silently falling back to
714+
# v0.0.0 would publish a channel build that sorts below the shipped
715+
# stable, and installed shells would never see it as an update.
716+
if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
717+
echo "::error::Could not query the latest stable release."
718+
exit 1
719+
fi
720+
# An empty release list makes jq print "null", which ${VAR:-default}
721+
# does not treat as empty. Anything that is not a bare vX.Y.Z means
722+
# "no stable release to build on top of" — start the channel at 0.0.1.
723+
if [[ ! "$LATEST" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
724+
LATEST="v0.0.0"
725+
fi
715726
IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}"
716727
TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
717728
NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}."

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

Lines changed: 99 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,48 @@ import { db } from '@sim/db'
33
import { folder as folderTable, workflow } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { FolderLockedError } from '@sim/platform-authz/workflow'
6+
import { getPostgresErrorCode } from '@sim/utils/errors'
67
import { generateId } from '@sim/utils/id'
7-
import { and, eq, isNull, min } from 'drizzle-orm'
8+
import { and, eq, isNull } from 'drizzle-orm'
89
import { type NextRequest, NextResponse } from 'next/server'
910
import { duplicateFolderContract } from '@/lib/api/contracts'
1011
import { parseRequest } from '@/lib/api/server'
1112
import { getSession } from '@/lib/auth'
1213
import { generateRequestId } from '@/lib/core/utils/request'
1314
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1415
import type { DbOrTx } from '@/lib/db/types'
16+
import { nextFolderSortOrder } from '@/lib/folders/lifecycle'
1517
import { deduplicateFolderName } from '@/lib/folders/naming'
1618
import { toFolderApi } from '@/lib/folders/queries'
1719
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1820
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1921

2022
const logger = createLogger('FolderDuplicateAPI')
2123

24+
/**
25+
* Duplication only ever copies workflow folders. Named once so the scope is stated rather
26+
* than restated as a literal at every query — the engine reads its resourceType from config
27+
* for exactly this reason.
28+
*/
29+
const FOLDER_RESOURCE_TYPE = 'workflow' as const
30+
31+
/**
32+
* Carries the HTTP status with the failure, so the handler maps errors by type instead of by
33+
* comparing `error.message` to a literal — a coupling that breaks silently the moment
34+
* someone rewords a message.
35+
*/
36+
class FolderDuplicationError extends Error {
37+
constructor(
38+
message: string,
39+
readonly status: number,
40+
/** Message returned to the caller when it must differ from the logged one. */
41+
readonly publicMessage: string = message
42+
) {
43+
super(message)
44+
this.name = 'FolderDuplicationError'
45+
}
46+
}
47+
2248
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
2349
export const POST = withRouteHandler(
2450
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -46,13 +72,13 @@ export const POST = withRouteHandler(
4672
and(
4773
eq(folderTable.id, sourceFolderId),
4874
isNull(folderTable.deletedAt),
49-
eq(folderTable.resourceType, 'workflow')
75+
eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE)
5076
)
5177
)
5278
.then((rows) => rows[0])
5379

5480
if (!sourceFolder) {
55-
throw new Error('Source folder not found')
81+
throw new FolderDuplicationError('Source folder not found', 404)
5682
}
5783

5884
const userPermission = await getUserEntityPermissions(
@@ -62,12 +88,16 @@ export const POST = withRouteHandler(
6288
)
6389

6490
if (!userPermission || userPermission === 'read') {
65-
throw new Error('Source folder not found or access denied')
91+
throw new FolderDuplicationError(
92+
'Source folder not found or access denied',
93+
403,
94+
'Access denied'
95+
)
6696
}
6797

6898
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
6999
if (targetWorkspaceId !== sourceFolder.workspaceId) {
70-
throw new Error('Cross-workspace folder duplication is not supported')
100+
throw new FolderDuplicationError('Cross-workspace folder duplication is not supported', 400)
71101
}
72102

73103
const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => {
@@ -76,57 +106,55 @@ export const POST = withRouteHandler(
76106
const targetParentId = parentId ?? sourceFolder.parentId
77107
await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId)
78108

79-
const folderParentCondition = targetParentId
80-
? eq(folderTable.parentId, targetParentId)
81-
: isNull(folderTable.parentId)
82-
const workflowParentCondition = targetParentId
83-
? eq(workflow.folderId, targetParentId)
84-
: isNull(workflow.folderId)
85-
86-
const [[folderResult], [workflowResult]] = await Promise.all([
87-
tx
88-
.select({ minSortOrder: min(folderTable.sortOrder) })
89-
.from(folderTable)
90-
.where(
91-
and(
92-
eq(folderTable.workspaceId, targetWorkspaceId),
93-
eq(folderTable.resourceType, 'workflow'),
94-
folderParentCondition
95-
)
96-
),
109+
// Placement is the engine's rule (folders and workflows share one ordering space),
110+
// so it is read from there rather than recomputed here.
111+
const sortOrder = await nextFolderSortOrder(
112+
FOLDER_RESOURCE_TYPE,
113+
targetWorkspaceId,
114+
targetParentId,
97115
tx
98-
.select({ minSortOrder: min(workflow.sortOrder) })
99-
.from(workflow)
100-
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
101-
])
102-
103-
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
104-
number | null
105-
>((currentMin, candidate) => {
106-
if (candidate == null) return currentMin
107-
if (currentMin == null) return candidate
108-
return Math.min(currentMin, candidate)
109-
}, null)
110-
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
116+
)
117+
111118
const deduplicatedName = await deduplicateFolderName(
112119
tx,
113120
targetWorkspaceId,
114121
targetParentId,
115-
name
122+
name,
123+
FOLDER_RESOURCE_TYPE
116124
)
117125

118-
await tx.insert(folderTable).values({
119-
id: newFolderId,
120-
resourceType: 'workflow',
121-
userId: session.user.id,
122-
workspaceId: targetWorkspaceId,
123-
name: deduplicatedName,
124-
parentId: targetParentId,
125-
sortOrder,
126-
locked: false,
127-
createdAt: now,
128-
updatedAt: now,
129-
})
126+
try {
127+
await tx.insert(folderTable).values({
128+
id: newFolderId,
129+
resourceType: FOLDER_RESOURCE_TYPE,
130+
userId: session.user.id,
131+
workspaceId: targetWorkspaceId,
132+
name: deduplicatedName,
133+
parentId: targetParentId,
134+
sortOrder,
135+
locked: false,
136+
createdAt: now,
137+
updatedAt: now,
138+
})
139+
} catch (insertError) {
140+
/**
141+
* Scoped to THIS insert on purpose. A 23505 here is one of two real conflicts the
142+
* caller can act on: `newId` is client-supplied, so replaying a duplicate whose
143+
* response was lost hits the primary key; and `deduplicateFolderName` runs before
144+
* the write, so a concurrent create can still take the name in between.
145+
*
146+
* Catching 23505 across the whole handler instead would relabel any unique violation
147+
* raised while copying the workflows inside the folder — a different constraint, on a
148+
* different table — as a folder-name conflict, which is both wrong and misleading.
149+
* Those keep falling through to the generic 500.
150+
*/
151+
if (getPostgresErrorCode(insertError) !== '23505') throw insertError
152+
throw new FolderDuplicationError(
153+
`Folder duplication conflicted for ${sourceFolderId}`,
154+
409,
155+
'A folder with this name already exists in this location'
156+
)
157+
}
130158

131159
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
132160
await duplicateFolderStructure(
@@ -182,41 +210,23 @@ export const POST = withRouteHandler(
182210
const duplicatedFolder = await db
183211
.select()
184212
.from(folderTable)
185-
.where(and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, 'workflow')))
213+
.where(
214+
and(eq(folderTable.id, newFolderId), eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE))
215+
)
186216
.then((rows) => rows[0])
187217

188218
return NextResponse.json({ folder: toFolderApi(duplicatedFolder) }, { status: 201 })
189219
} catch (error) {
190-
if (error instanceof Error) {
191-
if (error instanceof FolderLockedError) {
192-
return NextResponse.json({ error: error.message }, { status: error.status })
193-
}
194-
195-
if (error.message === 'Source folder not found') {
196-
logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`)
197-
return NextResponse.json({ error: 'Source folder not found' }, { status: 404 })
198-
}
199-
200-
if (error.message === 'Source folder not found or access denied') {
201-
logger.warn(
202-
`[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}`
203-
)
204-
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
205-
}
206-
207-
if (error.message === 'Cross-workspace folder duplication is not supported') {
208-
logger.warn(
209-
`[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}`
210-
)
211-
return NextResponse.json({ error: error.message }, { status: 400 })
212-
}
220+
if (error instanceof FolderLockedError) {
221+
return NextResponse.json({ error: error.message }, { status: error.status })
222+
}
213223

214-
if (
215-
error.message === 'Target parent folder not found' ||
216-
error.message === 'Cannot duplicate folder into itself or one of its descendants'
217-
) {
218-
return NextResponse.json({ error: error.message }, { status: 400 })
219-
}
224+
if (error instanceof FolderDuplicationError) {
225+
logger.warn(`[${requestId}] Folder duplication rejected: ${error.message}`, {
226+
sourceFolderId,
227+
userId: session.user.id,
228+
})
229+
return NextResponse.json({ error: error.publicMessage }, { status: error.status })
220230
}
221231

222232
const elapsed = Date.now() - startTime
@@ -249,14 +259,19 @@ async function assertTargetParentFolderMutable(
249259
archivedAt: folderTable.deletedAt,
250260
})
251261
.from(folderTable)
252-
.where(and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, 'workflow')))
262+
.where(
263+
and(eq(folderTable.id, currentFolderId), eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE))
264+
)
253265
.limit(1)
254266

255267
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
256-
throw new Error('Target parent folder not found')
268+
throw new FolderDuplicationError('Target parent folder not found', 400)
257269
}
258270
if (folder.id === sourceFolderId) {
259-
throw new Error('Cannot duplicate folder into itself or one of its descendants')
271+
throw new FolderDuplicationError(
272+
'Cannot duplicate folder into itself or one of its descendants',
273+
400
274+
)
260275
}
261276
if (folder.locked) {
262277
throw new FolderLockedError()
@@ -283,7 +298,7 @@ async function duplicateFolderStructure(
283298
and(
284299
eq(folderTable.parentId, sourceFolderId),
285300
eq(folderTable.workspaceId, sourceWorkspaceId),
286-
eq(folderTable.resourceType, 'workflow'),
301+
eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE),
287302
isNull(folderTable.deletedAt)
288303
)
289304
)
@@ -294,7 +309,7 @@ async function duplicateFolderStructure(
294309

295310
await tx.insert(folderTable).values({
296311
id: newChildFolderId,
297-
resourceType: 'workflow',
312+
resourceType: FOLDER_RESOURCE_TYPE,
298313
userId,
299314
workspaceId: targetWorkspaceId,
300315
name: childFolder.name,

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { restoreFolderContract } from '@/lib/api/contracts'
55
import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { restoreFolder } from '@/lib/folders/lifecycle'
9+
import { folderMutationStatus } from '@/lib/folders/status'
810
import { captureServerEvent } from '@/lib/posthog/server'
9-
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
1011
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1112

1213
const logger = createLogger('RestoreFolderAPI')
@@ -23,29 +24,36 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
2324
const parsed = await parseRequest(restoreFolderContract, request, context)
2425
if (!parsed.success) return parsed.response
2526
const { id: folderId } = parsed.data.params
26-
const { workspaceId } = parsed.data.body
27+
const { workspaceId, resourceType } = parsed.data.body
2728

2829
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
2930
if (permission !== 'admin' && permission !== 'write') {
3031
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
3132
}
3233

33-
const result = await performRestoreFolder({
34+
const result = await restoreFolder({
35+
resourceType,
3436
folderId,
3537
workspaceId,
3638
userId: session.user.id,
3739
})
3840

3941
if (!result.success) {
40-
return NextResponse.json({ error: result.error }, { status: 400 })
42+
return NextResponse.json(
43+
{ error: result.error },
44+
{ status: folderMutationStatus(result.errorCode) }
45+
)
4146
}
4247

43-
logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
48+
logger.info(`Restored folder ${folderId}`, {
49+
resourceType,
50+
restoredItems: result.restoredItems,
51+
})
4452

4553
captureServerEvent(
4654
session.user.id,
4755
'folder_restored',
48-
{ folder_id: folderId, workspace_id: workspaceId },
56+
{ folder_id: folderId, workspace_id: workspaceId, resource_type: resourceType },
4957
{ groups: { workspace: workspaceId } }
5058
)
5159

0 commit comments

Comments
 (0)