Skip to content

Commit a3413cf

Browse files
authored
feat(folders): cut file folders over, and give knowledge bases and tables folders (#6045)
* feat(folders): cut file folders over, and give knowledge bases and tables folders Builds on the generic resourceType-driven folder engine to finish the migration and extend it to two more resource trees. File-folder cutover - Repoints all 30 `workspace_file_folders` query sites onto `folder` scoped to `resourceType = 'file'`, id-keyed lookups included — a caller can hand a workflow folder's id to a file-folder endpoint, and only the predicate stops it - Adds the missing restore-conflict handling: a file folder whose name was taken while it was archived now deduplicates against the RESOLVED parent (restore re-roots when the original parent is still archived) instead of being permanently unrestorable - The forking mapper's `resourceType` predicate lives in the `leftJoin` condition, not the WHERE, where it would silently make the outer join inner - `servedFolderResourceTypeSchema` gains `file`; the soft-delete sweep enumerates all four types rather than dropping its predicate Knowledge Base and Tables folders - `knowledge_base.folder_id` and `user_table_definitions.folder_id` are now written and served; both create/move paths admit a folder only through `findActiveFolder`, scoped to workspace AND resourceType - Restoring a knowledge base whose folder is still archived re-roots it rather than filing it somewhere the page never renders; a workspace move re-roots for the same reason - Both pages get folder rows, breadcrumbs, inline rename on rows and on the breadcrumb, move-to submenus, pin/unpin, and drag-a-row-onto-a-folder Shared folder UI - One `components/folders/` module now backs Knowledge, Tables, and Files: `folderBreadcrumbItems` (data for `Resource.Header`, which owns the crumb chrome), `folderRow`, `folderRowId`/`parseFolderedRowId`, `buildMoveOptions` / `buildDescendantIndex` / `renderMoveOptions`, `nextUntitledFolderName`, `FolderContextMenu`, `useFolderNavigation`, `useFolderRowDragDrop` - Deletes the per-page copies: `files/move-options.tsx` and the Tables-local folder context menu Recently Deleted - Restores folders of every type, not just workflow folders, driven by one declarative table rather than a branch per resource Folder pins resolve against `folder` unscoped: file, knowledge-base, and table folders are all pinnable and all live there, and a folder id addresses exactly one row. * fix(folders): close the folder-engine audit findings Data loss - Migration 0274 catches up file folders created AFTER 0272. That backfill is guarded by `WHERE NOT EXISTS (… resource_type = 'file')`, so it fires once; every file folder created between it and this cutover exists only in `workspace_file_folders`. Without the catch-up they vanish from Files on deploy and their contents become unreachable Authorization - Archived folders were mutable, which bypassed the folder lock: the folder routes and `updateFolder` filtered id + resourceType but never `deletedAt`, while `getFolderLockStatus` does — so an archived-but-locked folder reported unlocked. Lock a subfolder, delete its parent, and any write-level member could rename and reparent it. PUT and the engine now require an active row; DELETE deliberately still does not, because it reuses an archived folder's own `deletedAt` to make a partial cascade retryable - `v1/admin/workflows/import` wrote `folderId` with no validation at all. Migration 0272 dropped the FK, so it accepted a knowledge-base folder, another workspace's folder, or garbage — and the workflow then rendered under no sidebar node while still executing, billing, and escaping the folder delete cascade. It now runs the same `assertFolderInWorkspace` + `assertFolderMutable` pair as `v1/workflows/import` Restore no longer loses state - A folder restore ran a bare `archivedAt = null` over its workflows, while the delete had routed through `archiveWorkflow` — which also disables schedules, webhooks, chats, and MCP tools. Restoring a folder reported success while every schedule stayed disabled forever and every chat 404'd. The workflow tree now restores through `restoreWorkflow`, like the knowledge-base and table trees already did. Deployment state is still deliberately not revived, matching the single-workflow restore - Those canonical restores re-root a resource whose folder is archived — correct on its own, catastrophic inside a cascade, which restores children BEFORE the folder rows and would therefore dump every child at the workspace root. `resolveRestoredFolderId` takes the subtree being restored and exempts it. `restoreTable` gained the re-root check it lacked Correctness - `getFolderPath` in `lib/folders/tree.ts` walked `parentId` with no `visited` set, so a cycle in the client cache — reachable through `useReorderFolders`' unchecked optimistic write — hung the tab. Its twin already had one. Dead `buildFolderMap` deleted - The sidebar's "New folder" inside a folder hardcoded the name, so the second one 409'd, was swallowed with only a log line, and the user got no folder and no message. It now names through the existing `generateSubfolderName` and surfaces failures - `chunkedBatchDelete` deleted by id with no re-check, so a resource restored between the SELECT and the DELETE was hard-deleted anyway. Callers now re-assert their soft-delete predicate on the DELETE - Recently Deleted and the `restore_resource` tool cover every folder tree, not just workflow folders, so deleted knowledge-base and table folders are recoverable Cleanup - The folder duplicate route stops steering control flow through `throw new Error('literal')` matched by string, reuses the engine's `nextFolderSortOrder` instead of recomputing it, and names its workflow scope once - `servedFolderResourceTypeSchema` documents that its default applies only to an omitted value; a present-but-unrecognized one is rejected, never coerced to `workflow` - The `workspace_file_folders` comment described the table as unread and unwritten and invited a DROP. It was live until this cutover, and it is now the rollback copy — the comment says so, and names what must be true before anyone drops it - Pinned rows carry a small non-interactive pin glyph again; they sort to the top and had nothing explaining why since the inline pin button was removed Tests - New `lib/folders/lifecycle.test.ts` covers create/update/delete/restore, the re-root, and the 23505 mappings; `cleanup-soft-deletes.test.ts` now pins the folder target's resourceType predicate, which is what keeps the sweep off a not-yet-cut-over type * improvement(folders): show the pin glyph on Files rows and pin the deploy contract Files rows were left without the pin indicator the other two lists gained, so a pinned file or folder still sorted to the top of the Files page with nothing explaining why. Adds `lib/api/contracts/folders.test.ts`, which pins the three cases that together ARE the rolling-deploy contract: an omitted `resourceType` defaults to `workflow` (so a client predating the field keeps working), a present-but-unrecognized one is rejected rather than coerced, and every served tree is accepted. * fix(folders): close the findings from the full-diff audit Migration 0274 reconciles instead of catching up - The deployed manager writes `workspace_file_folders` EXCLUSIVELY, so `folder`'s file rows are frozen at the 0272 snapshot: every rename, move, delete, and restore since then lives only in the legacy table. An insert-only catch-up left all of that diverged — renames reverted, deleted folders came back as active phantoms, and a stale-active row could hold the name a newer folder legitimately took - That last case was silent: the bare `ON CONFLICT DO NOTHING` swallows a NAME violation as readily as an id one, so the newer folder was discarded and every file inside it stranded. The conflict target is now `(id)`, so a name collision — which would mean the source violated its own unique index — fails loudly instead - Reconciles by parking mirrored names first, then upserting, so no transient state can collide with the partial unique index. Both statements are idempotent, and the header says what nothing else would: this has to be run again after the deploy drains, because old pods keep writing the legacy table until they are gone `/api/folders` no longer serves file folders - Adding `'file'` to `servedFolderResourceTypeSchema` opened a second writer over the same rows. It bypassed the `workspace_file_folders:${workspaceId}` advisory lock that makes the manager's check-then-write pairs atomic, and it accepted names containing `/`, `\`, `.` and `..`, which the file surface forbids because a folder name becomes a path segment — a folder named `a/b` is then unreachable by path forever, and breadcrumbs resolve it as two segments. The storage cutover never needed a second API, so there isn't one Conflicts that returned 500 - `v1/admin/workspaces/[id]/import` created its folder with an unserialized SELECT-then- INSERT, so two imports sharing a folder path failed the whole import. The name is server-chosen, so it now adopts whichever folder won, like `ensureWorkspaceFileFolderPath` - Folder duplicate never caught 23505. `newId` is client-supplied, so replaying a duplicate whose response was lost hit the primary key and returned 500 instead of a 409 the caller can act on Performance - `knowledgeKeys` moved to `hooks/queries/utils/knowledge-keys.ts`. Reading it from `hooks/queries/kb/knowledge` pulled a ~1000-line hook module — and the `@sim/emcn` barrel behind it — into `hooks/queries/folders`, which three server prefetches import. That is the same import edge the navigation-speed audit measured at 11.8MB per workspace route; `tableKeys` already lived in a keys-only module for exactly this reason Merge drop - Knowledge never got the chrome Tables did: its prefetch now fetches the folder tree alongside the bases, so the list does not paint ungrouped and a `?folderId=` deep link does not render an empty breadcrumb, and its loading fallback declares the "New folder" action so the header does not shift on hydration Comments corrected to match reality - The shared folder row and context menu claimed Files as a consumer; Files still builds its own row and routes folders through `FileRowContextMenu`, which the TSDoc now says - `schema.ts` prescribed a row COUNT comparison before dropping the legacy table. The divergence 0274 repairs is in row CONTENTS, which a count check passes straight through * fix(folders): address the review round and the full-PR audit Reverts the workflow restore hook — it was the wrong fix - Greptile flagged that restoring a folder leaves schedules disabled and webhooks/chats inactive. The hook added last round routed workflows through `restoreWorkflow` to address it, but `restoreDependents` ALREADY clears exactly those columns, in bulk, inside the restore transaction and matched on the archive timestamp. The hook bought nothing and cost a per-workflow read/transaction/read OUTSIDE that transaction — roughly 1600 round trips for a folder of 200 workflows — plus a window where the workflows were active and the folder was not, and it made `restoreDependents` dead code - What no restore path can undo is the state archive OVERWRITES: `status: 'disabled'` with `nextRunAt` cleared, `isActive: false`. Archive does not record what those were, so restoring them to a constant would re-enable a schedule the user had disabled and re-run a completed one. Left explicit — redeploy re-activates a schedule — matching deployment state, which restore also deliberately leaves off. Documented on the config rather than guessed - Kept the genuine bug the review surfaced underneath it: `restoreWorkflow` un-archived every dependent by `workflowId` alone, resurrecting a webhook or chat the user had archived days earlier. Now matched on the workflow's own `archivedAt`, which is the semantics this feature documents Bugbot: orphaned knowledge bases vanished from every view - Knowledge filtered on a raw `folderId === currentFolderId` and never re-rooted a base whose folder is missing or archived, so a base restored on its own — or left behind by a partial cascade — was invisible everywhere. Tables already fell those back to the root A dead `?folderId=` is now healed instead of being a dead end - A bookmark to a deleted folder rendered the root title over an empty grid, hid everything actually at the root, and — worse — left every create/upload action targeting the dead id, filing new resources somewhere nothing could reach. `useFolderNavigation` clears it once the folder list resolves, which fixes Files, Knowledge, and Tables at once Parity gaps found by auditing against Files - Tables sorted folders newest-first on a clean URL while Files and Knowledge sorted A→Z: its sort params are defaulted, so the raw column was never null. Reads `activeSort` now - On Tables you could not delete the folder you were standing in — its own row is not in the list, and the crumb menu offered only Rename, which also made the step-out branch unreachable dead code - A failed knowledge-base move was logged and never surfaced, so a rejected move — from the submenu or from a drag — looked like nothing happened - A drag begun in another mount of the page could never be dropped: `onDragOver` bailed before `preventDefault`, so no drop event ever fired and the `dataTransfer` fallback in `onDrop` was unreachable. External/foreign drags are still ignored, so an OS file dropped on the list cannot navigate the tab away Files: a folder cycle crashed the page - The folder-size roll-up recursed with no `visited` guard, so a parent/child cycle in the cached tree — reachable through the optimistic folder-move write — recursed until the stack blew and the page went blank. Also indexes children once instead of re-scanning per node Migration 0274 is now atomic - 0272 ends with an embedded COMMIT for its CONCURRENTLY index builds, so this file is not guaranteed to run inside drizzle's batch transaction. Wrapped in a DO block so the parking pass cannot commit without the reconcile, which would leave every mirrored folder holding a placeholder name - Validated with a read-only dry run: no active name duplicates, no orphaned or cross-workspace parents, and no id collisions with `workflow_folder`, so the insert cannot trip the unique index, the FKs, or the resource-type trigger Smaller corrections - Knowledge indexed its members for the owner comparator instead of two linear scans per comparison, and logs a load failure from an effect rather than the render body - Files uses the shared root sentinel instead of a bare `'__root__'` in two places - `restore_resource` documents that its two new folder types are unreachable until the enum in the copilot service's tool catalog — a different repository — widens - Corrected a forking comment that still described file folders as a separate table * docs(folders): record the 0272 + 0274 dry-run results on the migration A read-only dry run materialises the complete post-0272 `folder` table from both source tables and asserts every constraint it declares: no NULL names, no primary-key collisions between the two source tables, no unique-index violations, no resource-type or workspace trigger violations, and no parent/user/workspace FK violations. 0272's dedup renames only workflow folders, never file folders — the latter is what lets pass 2 write raw names. * fix(folders): close the findings from the line-by-line audit of the PR Data integrity - Workspace fork copied `folderId` verbatim onto the child's tables and knowledge bases. The column carries no workspace, so a forked row pointed at a folder owned by the SOURCE workspace — invisible in the fork, and mutated from under it when the source later deleted that folder (`ON DELETE SET NULL`). Latent until now because nothing wrote a non-null `folderId` for those two resources; this PR is what makes it reachable. Forked resources land at the root, like forked files already did Interaction bugs - Clearing a dead `?folderId=` wrote through the params' default `history: 'push'`, so Back returned to the dead URL, which healed and pushed again — the Back button was dead on that page. It replaces now: opening a folder is navigation, correcting a URL that never pointed anywhere is not - That same heal keyed off `isLoading`, which is false for a DISABLED query, false for an ERRORED one, and false while `keepPreviousData` is showing the previous workspace's folders. In all three the list is empty or stale, so a transient fetch failure — or a workspace switch — threw away a perfectly good open folder. Gated on `isSuccess && !isPlaceholderData` - "New folder" while a search was active created the folder but never showed it: the search filters folders too, so the new row did not match, the rename field never appeared, and the create read as a no-op. Both pages clear the search so the thing just created is on screen - The drag ghost was removed only by `dragend`, which fires on the source row. The table is virtualized, so scrolling the source out of view mid-drag unmounted it, the event never arrived, and the ghost stayed on the page with every row stuck at drag opacity. Cleaned up on unmount as the backstop - A drag begun in another mount highlighted every folder as a valid target — including the dragged folder itself — then silently did nothing on drop. It only highlights when the source is known and was actually checked Correctness - The folder-duplicate route caught 23505 across the WHOLE handler, so a unique violation raised while copying the workflows inside the folder was relabelled "a folder with this name already exists". Scoped to the folder INSERT, which is the only place the two conflicts the caller can act on (client-supplied `newId` replay, concurrent name take) actually arise - The optimistic folder create derived its sort order from the workspace's WORKFLOWS regardless of resource type. Only the workflow tree interleaves folders and resources in one ordering space, so a knowledge-base or table folder was placed against an unrelated one - A table archived between `checkAccess` and the move surfaced as a 500 with a "not found" body; it is a 404 - `FOLDER_RESOURCE_TYPE_BY_RESTORABLE` was a `Partial` record, so adding a folder tree without a mapping would compile and silently restore into the workflow tree. Total record now - Recently Deleted paired query results to folder trees by ARRAY POSITION; reordering the const would have filed knowledge folders under Tables with no type error. Keyed by type - The knowledge move's no-op guard compared against the snapshot taken when the menu opened rather than the live row Performance — the module split now actually does what it claimed - `knowledge-keys.ts` justified itself as keeping server prefetches off the ~1000-line `kb/knowledge` module, but `knowledge/prefetch.ts` still imported its stale-time constant from exactly that module, whose first line pulls the `@sim/emcn` barrel. Worse, this PR had ADDED the same class of edge to four prefetches via `FOLDER_LIST_STALE_TIME`/`mapFolder` - `FOLDER_LIST_STALE_TIME`, `mapFolder`, and `KNOWLEDGE_BASE_LIST_STALE_TIME` now live beside their key factories, so all four server prefetches import only keys-and-constants modules. `mapFolder` keeps its explicit field list rather than a spread, so the cached shape cannot drift from a client fetch Honesty in comments - The KB retention `deleteFilter` narrows the restore race but does not close it — documents hard-deleted by `onBatch` do not come back, so a restore landing mid-batch returns an emptied base. Said so - A failed folder restore leaves children active while their folders are still archived. They are reachable (both lists fall an unresolvable `folderId` back to the root) but they sit at the root until the restore is retried. Said so Product policy made visible - Restore deliberately does not re-enable schedules, webhooks, or chats, because archive overwrites their state without recording it. A deliberately-disabled schedule or webhook is a common state rather than an edge case, so reactivating to a constant would be wrong more often than right. Recently Deleted now says so at the moment of restore instead of leaving it to be discovered Smaller - Pin glyph: dropped the doubled gap, added `role='img'` so the state is announced - "Move here" carries the folder glyph its sibling leaves do - `useMoveTable` had stolen `useDeleteTable`'s doc block - Dead `.returning` column and a `?? null` on a non-nullable param in `moveTableToFolder` - Tables reads one sort source for both blocks, matching Knowledge - New test pins that `updateFolder` refuses an archived folder — the guard that stops a delete from unlocking its locked subfolders * fix(tables): re-read live placement before skipping a move as a no-op `handleMoveTable` and `handleMoveFolder` decided "already there, nothing to do" from `activeTable`/`activeFolder`, which are snapshots taken when the context menu opened. A refetch or a concurrent move since then makes that comparison stale, so the write the user just asked for is silently skipped and the row stays where it was. Both now compare against the live list, matching the knowledge-base move. * chore(folders): drop production figures from migration and code comments This repository is public. The 0274 header and the Recently Deleted policy comment carried concrete counts and ratios taken from the production database, which is operational detail that does not belong in open source. Both now state the property that matters — what was asserted, and why disabled-on-restore is the safe default — without the underlying numbers. * fix(folders): close the findings from auditing the whole release landing unit Audited as the unit that actually ships — #6014 (pinning), #6025 (workflow folders onto the generic table), #6037 (the engine) and this branch together against `main`, rather than this branch against `staging`. Authorization - `PUT /api/folders/reorder` still operated on archived folders. `getFolderLockStatus` skips archived rows, so `assertFolderMutable` there was a guaranteed no-op — meaning a locked folder became freely reparentable the moment its parent was deleted. This branch closed exactly that hole on `PUT /api/folders/[id]` and left its sibling open, then widened reorder to three resource trees. Reordering an archived folder is also wrong on its own terms: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out of an archived subtree silently drops it from that folder's restore Data loss in the purge - `batchDeleteByWorkspaceAndTimestamp` forwarded its eligibility predicate only into the SELECT, never the DELETE. The `folder` target runs through it, so a restore committing between the two statements had its folder hard-deleted anyway — taking the placement of the children the restore had just brought back. The workflow purge re-checks for precisely this reason and the knowledge-base sweep gained the same guard earlier in this branch; `folder` had neither. The wrapper now re-asserts eligibility on the DELETE for every caller Wire boundary - `toPinnedItemApi` had no return type, so `pinned_item.resource_type` — plain `text` by design, against a closed contract enum — was never checked. Annotating it surfaced the real hole immediately: during a rolling deploy an older pod can read a pin a newer one wrote, and returning it would fail response validation and take the WHOLE list down rather than the one row. Unknown kinds are now narrowed out explicitly at the boundary instead of surviving by accident on a filter whose stated job is something else Interaction - The knowledge-base FOLDER move still compared against the snapshot taken when the menu opened. The resource move on that page and both Tables handlers were fixed earlier in this branch; this was the last one Operational - 0274's "re-run after drain" instruction needed a precondition. Cleanup hard-deletes from `folder` but never from `workspace_file_folders`, so a re-run after a purge would reinstate every purged file folder as a soft-deleted phantom whose files are already gone. Retention is far longer than any drain, so running it promptly is sufficient — but the instruction said nothing about ordering Accuracy - Four comments named symbols that do not exist or no longer apply: `useFolderCreateWithDedup` (never existed — it is `nextUntitledFolderName`), `collectActiveSubtreeIds`, `restoreFolderCascade` as the live restore path, and a claim that a server `createSearchParamsCache` reads the shared folder param. The shared param doc also now admits Files declares its own `?folderId=` rather than claiming to be the single declaration - `FOLDER_RESOURCES.file` is unreachable at runtime — `servedFolderResourceTypeSchema` does not serve `'file'` — and a reader would reasonably assume otherwise. Says so, and says what routing file folders through the generic engine would bypass - `pinnedItem.resourceType`'s inline comment was missing `'folder'` - `folders.test.ts` fixtures still carried `color` and `isExpanded`, both deliberately dropped from the generic table — pre-consolidation rows masquerading as folders - `getFolders` in the folder cache had one caller, in its own file * fix(folders): gate the orphan fallback on a resolved folder index, not a loading flag The URL heal was fixed to use `isSuccess && !isPlaceholderData`, but the list filters that decide "this resource's folderId does not resolve, so show it at the root" were left on `isLoading` — the same footgun, one layer down. `isLoading` is false for a disabled query (no workspaceId), false for an errored one, and — because `useFolders` sets `keepPreviousData` — false while the previous workspace's folders are still on screen during a switch. In all three the index is empty or belongs to another workspace, so every foldered knowledge base and table was treated as an orphan and dragged to the root, or filtered out of a deep-linked folder entirely. A transient fetch failure was enough to make a folder look empty. `useFolderNavigation` now exposes `foldersResolved` instead of `isLoading`, so the heal and both list filters share one signal and the footgun is no longer reachable from the hook's surface.
1 parent 41a9ae9 commit a3413cf

99 files changed

Lines changed: 23640 additions & 639 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.

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

Lines changed: 98 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,58 +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,
115122
name,
116-
'workflow'
123+
FOLDER_RESOURCE_TYPE
117124
)
118125

119-
await tx.insert(folderTable).values({
120-
id: newFolderId,
121-
resourceType: 'workflow',
122-
userId: session.user.id,
123-
workspaceId: targetWorkspaceId,
124-
name: deduplicatedName,
125-
parentId: targetParentId,
126-
sortOrder,
127-
locked: false,
128-
createdAt: now,
129-
updatedAt: now,
130-
})
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+
}
131158

132159
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
133160
await duplicateFolderStructure(
@@ -183,41 +210,23 @@ export const POST = withRouteHandler(
183210
const duplicatedFolder = await db
184211
.select()
185212
.from(folderTable)
186-
.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+
)
187216
.then((rows) => rows[0])
188217

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

215-
if (
216-
error.message === 'Target parent folder not found' ||
217-
error.message === 'Cannot duplicate folder into itself or one of its descendants'
218-
) {
219-
return NextResponse.json({ error: error.message }, { status: 400 })
220-
}
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 })
221230
}
222231

223232
const elapsed = Date.now() - startTime
@@ -250,14 +259,19 @@ async function assertTargetParentFolderMutable(
250259
archivedAt: folderTable.deletedAt,
251260
})
252261
.from(folderTable)
253-
.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+
)
254265
.limit(1)
255266

256267
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
257-
throw new Error('Target parent folder not found')
268+
throw new FolderDuplicationError('Target parent folder not found', 400)
258269
}
259270
if (folder.id === sourceFolderId) {
260-
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+
)
261275
}
262276
if (folder.locked) {
263277
throw new FolderLockedError()
@@ -284,7 +298,7 @@ async function duplicateFolderStructure(
284298
and(
285299
eq(folderTable.parentId, sourceFolderId),
286300
eq(folderTable.workspaceId, sourceWorkspaceId),
287-
eq(folderTable.resourceType, 'workflow'),
301+
eq(folderTable.resourceType, FOLDER_RESOURCE_TYPE),
288302
isNull(folderTable.deletedAt)
289303
)
290304
)
@@ -295,7 +309,7 @@ async function duplicateFolderStructure(
295309

296310
await tx.insert(folderTable).values({
297311
id: newChildFolderId,
298-
resourceType: 'workflow',
312+
resourceType: FOLDER_RESOURCE_TYPE,
299313
userId,
300314
workspaceId: targetWorkspaceId,
301315
name: childFolder.name,

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22
import { folder as folderTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
5-
import { and, eq } from 'drizzle-orm'
5+
import { and, eq, isNull } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { deleteFolderContract, updateFolderContract } from '@/lib/api/contracts'
88
import { parseRequest } from '@/lib/api/server'
@@ -45,10 +45,22 @@ export const PUT = withRouteHandler(
4545
const { resourceType } = parsed.data.query
4646
const { name, locked, parentId, sortOrder } = parsed.data.body
4747

48+
/**
49+
* `isNull(deletedAt)` is load-bearing, not tidiness: `getFolderLockStatus` skips
50+
* archived rows, so an archived-but-locked folder reports unlocked. Without this
51+
* filter, deleting a folder makes every locked subfolder under it freely renameable
52+
* and reparentable by any write-level member.
53+
*/
4854
const existingFolder = await db
4955
.select()
5056
.from(folderTable)
51-
.where(and(eq(folderTable.id, id), eq(folderTable.resourceType, resourceType)))
57+
.where(
58+
and(
59+
eq(folderTable.id, id),
60+
eq(folderTable.resourceType, resourceType),
61+
isNull(folderTable.deletedAt)
62+
)
63+
)
5264
.then((rows) => rows[0])
5365

5466
if (!existingFolder) {
@@ -143,6 +155,12 @@ export const DELETE = withRouteHandler(
143155
const { id } = parsed.data.params
144156
const { resourceType } = parsed.data.query
145157

158+
/**
159+
* Deliberately NOT filtered on `deletedAt`, unlike PUT above: `deleteFolder` reuses an
160+
* already-archived folder's own `deletedAt` so a cascade that failed partway can be
161+
* retried onto the same snapshot. 404ing here would strand those stragglers. Delete is
162+
* also idempotent, so re-reaching an archived folder grants nothing new.
163+
*/
146164
const existingFolder = await db
147165
.select()
148166
.from(folderTable)

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { folder as folderTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
55
import { getPostgresErrorCode } from '@sim/utils/errors'
6-
import { and, eq, inArray } from 'drizzle-orm'
6+
import { and, eq, inArray, isNull } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { reorderFoldersContract } from '@/lib/api/contracts'
99
import { parseRequest } from '@/lib/api/server'
@@ -38,10 +38,24 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
3838
}
3939

4040
const folderIds = updates.map((u) => u.id)
41+
/**
42+
* Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes
43+
* them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a
44+
* guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment
45+
* its parent is deleted. Reordering an archived folder is also a correctness problem in its
46+
* own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out
47+
* of an archived subtree silently drops it from that folder's restore.
48+
*/
4149
const existingFolders = await db
4250
.select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
4351
.from(folderTable)
44-
.where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, resourceType)))
52+
.where(
53+
and(
54+
inArray(folderTable.id, folderIds),
55+
eq(folderTable.resourceType, resourceType),
56+
isNull(folderTable.deletedAt)
57+
)
58+
)
4559

4660
const validIds = new Set(
4761
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
@@ -138,7 +152,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
138152
await tx
139153
.update(folderTable)
140154
.set(updateData)
141-
.where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, resourceType)))
155+
.where(
156+
and(
157+
eq(folderTable.id, update.id),
158+
eq(folderTable.resourceType, resourceType),
159+
isNull(folderTable.deletedAt)
160+
)
161+
)
142162
}
143163
})
144164

0 commit comments

Comments
 (0)