diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 282faaf165d..27f2b0f1c8e 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Upload, download, list, rename, archive, restore, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." + "description": "Upload, download, list, rename, delete, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." }, { "name": "Audit Logs", @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List a workspace's files with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `scope=archived` to page through Recently Deleted — that is how you find the id of a file to restore.", + "description": "List a workspace's files with opaque cursor pagination. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `folderPath` to return only files directly inside one canonical folder path; omit it to list files from every folder.", "tags": ["Files"], "x-codeSamples": [ { @@ -54,17 +54,6 @@ { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "`active` (the default) lists live files; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", - "schema": { - "type": "string", - "enum": ["active", "archived"], - "default": "active" - } - }, { "name": "limit", "in": "query", @@ -81,18 +70,25 @@ "$ref": "#/components/parameters/Cursor" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every file in the workspace.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the file `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -110,7 +106,11 @@ "in": "query", "required": false, "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -131,23 +131,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileListResponse" - }, - "example": { - "data": [ - { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } - ], - "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" } } } @@ -208,11 +191,11 @@ "maxLength": 255, "description": "MIME type. When omitted, it is inferred from the file extension." }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Destination folder. Omit to create the file at the workspace root." + "description": "Canonical containing-folder path. `/` is the workspace root." }, "content": { "type": "string", @@ -227,10 +210,6 @@ "description": "Encoding of `content`." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "notes.md" } } } @@ -253,32 +232,34 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "notes.md", - "size": 0, - "type": "text/markdown", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-notes.md", - "folderId": null, - "folderPath": null, - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -290,18 +271,36 @@ "tags": ["Files"], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "201": { "description": "The upload session.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -316,21 +315,41 @@ "name": "uploadId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "responses": { "200": { "description": "The aborted upload session.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -345,26 +364,52 @@ "name": "uploadId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "200": { "description": "Signed URLs for the requested parts.", - "content": { "application/json": { "schema": {} } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -379,26 +424,52 @@ "name": "uploadId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "200": { "description": "The completed upload and registered file.", - "content": { "application/json": { "schema": {} } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -431,22 +502,19 @@ "Content-Type": { "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", "schema": { - "type": "string", - "example": "text/csv" + "type": "string" } }, "Content-Disposition": { "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", "schema": { - "type": "string", - "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + "type": "string" } }, "Content-Length": { "description": "Size of the file in bytes.", "schema": { - "type": "string", - "example": "1024" + "type": "string" } }, "X-RateLimit-Limit": { @@ -491,7 +559,7 @@ "delete": { "operationId": "deleteFile", "summary": "Delete File", - "description": "Archive (soft delete) a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "description": "Delete a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", "tags": ["Files"], "x-codeSamples": [ { @@ -511,7 +579,7 @@ ], "responses": { "200": { - "description": "The file was archived.", + "description": "The file was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -527,12 +595,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2DeleteFileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "deleted": true - } } } } @@ -550,17 +612,11 @@ "$ref": "#/components/responses/NotFound" }, "409": { - "description": "The file could not be archived because of a conflicting state.", + "description": "The file could not be deleted because of a conflicting state.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Failed to delete file" - } } } } @@ -601,21 +657,15 @@ "properties": { "workspaceId": { "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + "description": "The workspace that owns the file." }, "name": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`.", - "example": "renamed.csv" + "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "renamed.csv" } } } @@ -638,20 +688,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "renamed.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } } } } @@ -720,20 +756,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } } } } @@ -1026,23 +1048,18 @@ } } }, - "/api/v2/files/{fileId}/restore": { + "/api/v2/files/move": { "post": { - "operationId": "restoreFile", - "summary": "Restore File", - "description": "Restore an archived file. Find archived ids with `GET /api/v2/files?scope=archived`. If the original name has since been taken by a live file, the file is restored under a suffixed name.", + "operationId": "moveFileItems", + "summary": "Move Files and Folders", + "description": "Move files and/or folders into a folder. `targetFolderPath: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderPaths` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderPath\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" } ], "requestBody": { @@ -1051,24 +1068,33 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId"], + "required": ["workspaceId", "fileIds"], "properties": { "workspaceId": { "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + "description": "The workspace that owns the items." + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to move." + }, + "targetFolderPath": { + "type": "string", + "description": "Canonical destination folder path. Omit to use the workspace root." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" } } } }, "responses": { "200": { - "description": "The file was restored.", + "description": "The items were moved.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1083,13 +1109,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2RestoreFileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "restored": true - } + "$ref": "#/components/schemas/V2MoveFileItemsResponse" } } } @@ -1118,73 +1138,166 @@ } } }, - "/api/v2/files/move": { - "post": { - "operationId": "moveFileItems", - "summary": "Move Files and Folders", - "description": "Move files and/or folders into a folder. `targetFolderId: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderIds` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", + "/api/v2/files/{fileId}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderId\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" + "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace that owns the items.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "fileIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Files to move." - }, - "folderIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Folders to move. Descendants follow their folder." + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file's share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2GetFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "isActive"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, - "targetFolderId": { - "type": ["string", "null"], - "description": "Destination folder. `null` or omitted moves to the workspace root.", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + "isActive": { + "type": "boolean", + "description": "Whether the share should resolve. `false` disables without revoking." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." + }, + "allowedEmails": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." } } }, "examples": { - "intoFolder": { - "summary": "Move two files into a folder", + "publicLink": { + "summary": "Enable a public link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91", "wf_2QrTb9xLm4PvZc7Ns1Ka"], - "targetFolderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + "isActive": true, + "authType": "public" } }, - "toRoot": { - "summary": "Move a folder back to the workspace root", + "passwordProtected": { + "summary": "Enable a password-protected link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"], - "targetFolderId": null + "isActive": true, + "authType": "password", + "password": "EXAMPLE_PASSWORD" + } + }, + "disable": { + "summary": "Disable (keeps the token and stored config)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": false } } } @@ -1193,7 +1306,7 @@ }, "responses": { "200": { - "description": "The items were moved.", + "description": "The share after the update.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1208,13 +1321,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2MoveFileItemsResponse" + "$ref": "#/components/schemas/V2UpsertFileShareResponse" }, "example": { "data": { - "movedItems": { - "files": 2, - "folders": 0 + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] } } } @@ -1233,9 +1353,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1245,18 +1362,23 @@ } } }, - "/api/v2/files/bulk-archive": { - "post": { - "operationId": "bulkArchiveFileItems", - "summary": "Archive Files and Folders", - "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.\n\n**This endpoint is best-effort and idempotent.** Ids that do not exist, belong to another workspace, or are already archived are skipped rather than failing the request — the call still returns `200`. `deletedItems` is what was actually archived, so compare it against your selection if you need to detect that something was skipped. The single-item `DELETE /api/v2/files/{fileId}` does return `404` for a missing id.", + "/api/v2/files/{fileId}/content": { + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/bulk-archive\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"folderIds\": [\"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"]}'" + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" } ], "requestBody": { @@ -1265,44 +1387,30 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId"], + "required": ["workspaceId", "content"], "properties": { "workspaceId": { "type": "string", - "description": "The workspace that owns the items.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + "description": "The workspace that owns the file." }, - "fileIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Files to archive." + "content": { + "type": "string", + "description": "The file's new full contents, interpreted per `encoding`." }, - "folderIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Folders to archive, together with their contents." + "encoding": { + "type": "string", + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "How to decode `content` into bytes." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], - "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"] } } } }, "responses": { "200": { - "description": "The items were archived.", + "description": "The updated file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1317,13 +1425,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2BulkArchiveFileItemsResponse" - }, - "example": { - "data": { - "deletedItems": { - "files": 3, - "folders": 1 + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/bulk-delete": { + "post": { + "operationId": "bulkDeleteFiles", + "summary": "Delete Files", + "description": "Delete up to 1,000 files. Folder deletion is available at `/api/v2/files/folders`.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "fileIds"], + "properties": { + "workspaceId": { + "type": "string" + }, + "fileIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Deletion result.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedItems"], + "properties": { + "deletedItems": { + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "integer" + } + } + } + } } } } @@ -1354,31 +1537,144 @@ } } }, - "/api/v2/files/{fileId}/share": { + "/api/v2/files/folders": { "get": { - "operationId": "getFileShare", - "summary": "Get File Share", - "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "operationId": "listFilesFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], "parameters": [ { - "$ref": "#/components/parameters/FileIdPath" + "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "$ref": "#/components/parameters/WorkspaceIdQuery" + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilesFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createFilesFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } } - ], + }, "responses": { - "200": { - "description": "The file's share state.", + "201": { + "description": "Folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1393,20 +1689,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" - }, - "example": { - "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/FilesFolder" + } + } } } } @@ -1425,6 +1718,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1433,87 +1729,29 @@ } } }, - "put": { - "operationId": "upsertFileShare", - "summary": "Enable or Disable File Share", - "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "patch": { + "operationId": "relocateFilesFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["workspaceId", "isActive"], + "required": ["workspaceId", "path", "destinationPath"], "properties": { "workspaceId": { - "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "isActive": { - "type": "boolean", - "description": "Whether the share should resolve. `false` disables without revoking." + "type": "string" }, - "authType": { + "path": { "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + "description": "Current canonical non-root path." }, - "password": { + "destinationPath": { "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." - }, - "allowedEmails": { - "type": "array", - "maxItems": 200, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 320 - }, - "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." - } - } - }, - "examples": { - "publicLink": { - "summary": "Enable a public link", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, - "authType": "public" - } - }, - "passwordProtected": { - "summary": "Enable a password-protected link", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, - "authType": "password", - "password": "EXAMPLE_PASSWORD" - } - }, - "disable": { - "summary": "Disable (keeps the token and stored config)", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": false + "description": "New canonical non-root path." } } } @@ -1522,7 +1760,7 @@ }, "responses": { "200": { - "description": "The share after the update.", + "description": "Folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1537,20 +1775,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" - }, - "example": { - "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/FilesFolder" + } + } } } } @@ -1569,6 +1804,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1576,75 +1814,38 @@ "$ref": "#/components/responses/InternalError" } } - } - }, - "/api/v2/files/{fileId}/content": { - "put": { - "operationId": "updateFileContent", - "summary": "Replace File Content", - "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", + }, + "delete": { + "operationId": "deleteFilesFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" - } - ], "parameters": [ { - "$ref": "#/components/parameters/FileIdPath" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId", "content"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "content": { - "type": "string", - "description": "The file's new full contents, interpreted per `encoding`." - }, - "encoding": { - "type": "string", - "enum": ["utf-8", "base64"], - "default": "utf-8", - "description": "How to decode `content` into bytes." - } - } - }, - "examples": { - "text": { - "summary": "Replace with UTF-8 text", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "content": "id,name\n1,alpha\n" - } - }, - "binary": { - "summary": "Replace with base64-encoded bytes", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "content": "aWQsbmFtZQoxLGFscGhhCg==", - "encoding": "base64" - } - } - } + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" } } - }, + ], "responses": { "200": { - "description": "The updated file.", + "description": "Deletion result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1659,20 +1860,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 16, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T11:05:00Z" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "files"], + "properties": { + "folders": { + "type": "integer" + }, + "files": { + "type": "integer" + } + } + } + } + } } } } @@ -1690,8 +1905,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" + "409": { + "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1728,7 +1943,10 @@ "in": "header", "required": true, "description": "The signed control token returned when the upload session was created.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, "FileIdPath": { "name": "fileId", @@ -1784,7 +2002,7 @@ "size", "type", "key", - "folderId", + "folderPath", "folderPath", "uploadedBy", "uploadedAt", @@ -1828,15 +2046,10 @@ "description": "ISO 8601 timestamp of when the file was uploaded.", "example": "2026-01-15T10:30:00Z" }, - "folderId": { - "type": ["string", "null"], - "description": "The containing file folder, or null when the file sits at the workspace root.", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" - }, "folderPath": { - "type": ["string", "null"], - "description": "Slash-joined folder names for `folderId`, or null at the workspace root.", - "example": "Reports/Q1" + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" }, "updatedAt": { "type": "string", @@ -1848,18 +2061,18 @@ }, "V2DeleteFileResult": { "type": "object", - "description": "Acknowledgement returned by a successful archive (soft delete).", + "description": "Acknowledgement returned by a successful delete.", "required": ["id", "deleted"], "properties": { "id": { "type": "string", - "description": "The unique identifier of the archived file.", + "description": "The unique identifier of the deleted file.", "example": "wf_V1StGXR8z5jdHi6BmyT91" }, "deleted": { "type": "boolean", "const": true, - "description": "Always true on a successful archive." + "description": "Always true on a successful delete." } } }, @@ -2035,23 +2248,6 @@ } } }, - "V2FileItemCounts": { - "type": "object", - "description": "Counts of what an operation actually touched. A folder cascades to its descendants, so these exceed the size of the selection.", - "required": ["files", "folders"], - "properties": { - "files": { - "type": "integer", - "description": "Number of files affected.", - "example": 3 - }, - "folders": { - "type": "integer", - "description": "Number of folders affected.", - "example": 1 - } - } - }, "V2FileShare": { "type": "object", "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", @@ -2115,40 +2311,19 @@ } } }, - "V2RestoreFileResult": { - "type": "object", - "description": "Acknowledgement returned by a successful file restore.", - "required": ["id", "restored"], - "properties": { - "id": { - "type": "string", - "description": "The unique identifier of the restored file.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" - }, - "restored": { - "type": "boolean", - "const": true, - "description": "Always true on a successful restore." - } - } - }, "V2MoveFileItemsResult": { "type": "object", "description": "What the move actually relocated.", "required": ["movedItems"], "properties": { "movedItems": { - "$ref": "#/components/schemas/V2FileItemCounts" - } - } - }, - "V2BulkArchiveFileItemsResult": { - "type": "object", - "description": "What the archive actually soft-deleted, including the cascade.", - "required": ["deletedItems"], - "properties": { - "deletedItems": { - "$ref": "#/components/schemas/V2FileItemCounts" + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "integer" + } + } } } }, @@ -2180,16 +2355,6 @@ } } }, - "V2RestoreFileResponse": { - "type": "object", - "description": "The result of restoring a file.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2RestoreFileResult" - } - } - }, "V2MoveFileItemsResponse": { "type": "object", "description": "The result of a move.", @@ -2200,16 +2365,6 @@ } } }, - "V2BulkArchiveFileItemsResponse": { - "type": "object", - "description": "The result of a bulk archive.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2BulkArchiveFileItemsResult" - } - } - }, "V2GetFileShareResponse": { "type": "object", "description": "The file's public share state.", @@ -2229,6 +2384,32 @@ "$ref": "#/components/schemas/V2UpsertFileShareResult" } } + }, + "FilesFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 4a5924ad949..e3d094a24a3 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API v2 \u2014 Knowledge Bases", - "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** \u2014 Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque \u2014 do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` \u21c4 `NOT_FOUND`).\n- **Rate limiting** \u2014 Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -50,18 +50,25 @@ "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the knowledge base `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -79,7 +86,11 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -663,7 +674,7 @@ "post": { "operationId": "uploadKnowledgeDocument", "summary": "Upload Document", - "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background \u2014 poll the Get Document endpoint to observe progress.", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", "tags": ["Knowledge Bases"], "x-codeSamples": [ { @@ -821,30 +832,58 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { "$ref": "#/components/responses/UsageLimitExceeded" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { "parameters": [ - { "$ref": "#/components/parameters/KnowledgeBaseId" }, - { "$ref": "#/components/parameters/UploadId" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "delete": { "operationId": "abortKnowledgeDocumentUpload", "summary": "Abort Document Upload", "description": "Abort an incomplete knowledge-document upload and discard its provider parts. Aborting an already aborted session is safe.", "tags": ["Knowledge Bases"], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], "responses": { "200": { "description": "The aborted upload session.", @@ -856,131 +895,536 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { "parameters": [ - { "$ref": "#/components/parameters/KnowledgeBaseId" }, - { "$ref": "#/components/parameters/UploadId" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "post": { "operationId": "createKnowledgeDocumentUploadPartUrls", "summary": "Create Document Upload Part URLs", "description": "Issue short-lived signed PUT URLs for up to 100 part numbers. PUT each byte range directly to the returned URL with the returned headers.", "tags": ["Knowledge Bases"], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePartUrlsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartUrlsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "post": { + "operationId": "completeKnowledgeDocumentUpload", + "summary": "Complete Document Upload", + "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteUploadBody" + } + } + } + }, + "responses": { + "200": { + "description": "The completed upload and queued knowledge document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + } + }, + "/api/v2/knowledge/folders": { + "get": { + "operationId": "listKnowledgeFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Knowledge Bases"], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePartUrlsBody" + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } } } } }, "responses": { - "200": { - "description": "Signed URLs for the requested parts.", + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PartUrlsEnvelope" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + } + } + } } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } - } - }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { - "parameters": [ - { "$ref": "#/components/parameters/KnowledgeBaseId" }, - { "$ref": "#/components/parameters/UploadId" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } - ], - "post": { - "operationId": "completeKnowledgeDocumentUpload", - "summary": "Complete Document Upload", - "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + }, + "patch": { + "operationId": "relocateKnowledgeFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", "tags": ["Knowledge Bases"], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompleteUploadBody" - } - } - } - }, - "responses": { - "200": { - "description": "The completed upload and queued knowledge document.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentUploadEnvelope" + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } } } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { "$ref": "#/components/responses/UsageLimitExceeded" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - } - }, - "/api/v2/knowledge/{id}/documents/{documentId}": { - "parameters": [ - { - "$ref": "#/components/parameters/KnowledgeBaseId" - }, - { - "$ref": "#/components/parameters/DocumentId" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "get": { - "operationId": "getKnowledgeDocument", - "summary": "Get Document", - "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", - "tags": ["Knowledge Bases"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } - ], + }, "responses": { "200": { - "description": "The document detail.", + "description": "Folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -995,7 +1439,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DocumentEnvelope" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + } + } + } } } } @@ -1006,44 +1462,54 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" } - }, + } + }, + "delete": { + "operationId": "deleteKnowledgeFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Knowledge Bases"], "parameters": [ { - "name": "workspaceId", + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", "in": "query", "required": true, + "description": "Canonical non-root folder path.", "schema": { - "type": "string", - "minLength": 1 - }, - "description": "Workspace that owns the knowledge base." - } - ] - }, - "delete": { - "operationId": "deleteKnowledgeDocument", - "summary": "Delete Document", - "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", - "tags": ["Knowledge Bases"], - "x-codeSamples": [ + "type": "string" + } + }, { - "label": "cURL", - "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "The document was deleted.", + "description": "Deletion result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1058,7 +1524,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteEnvelope" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "knowledgeBases"], + "properties": { + "folders": { + "type": "integer" + }, + "knowledgeBases": { + "type": "integer" + } + } + } + } + } + } } } } @@ -1075,25 +1569,16 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" } - }, - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1 - }, - "description": "Workspace that owns the knowledge base." - } - ] + } } } }, @@ -1289,7 +1774,8 @@ "embeddingDimension", "chunkingConfig", "createdAt", - "updatedAt" + "updatedAt", + "folderPath" ], "properties": { "id": { @@ -1349,6 +1835,10 @@ "format": "date-time", "description": "ISO 8601 timestamp when the knowledge base was last modified.", "example": "2025-06-18T16:45:00Z" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -1393,6 +1883,10 @@ }, "chunkingConfig": { "$ref": "#/components/schemas/ChunkingConfigInput" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -1422,6 +1916,10 @@ }, "chunkingConfig": { "$ref": "#/components/schemas/ChunkingConfigInput" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -1453,19 +1951,46 @@ "maximum": 104857600, "description": "Exact file size in bytes." }, - "tag1": { "type": "string", "maxLength": 1000 }, - "tag2": { "type": "string", "maxLength": 1000 }, - "tag3": { "type": "string", "maxLength": 1000 }, - "tag4": { "type": "string", "maxLength": 1000 }, - "tag5": { "type": "string", "maxLength": 1000 }, - "tag6": { "type": "string", "maxLength": 1000 }, - "tag7": { "type": "string", "maxLength": 1000 }, + "tag1": { + "type": "string", + "maxLength": 1000 + }, + "tag2": { + "type": "string", + "maxLength": 1000 + }, + "tag3": { + "type": "string", + "maxLength": 1000 + }, + "tag4": { + "type": "string", + "maxLength": 1000 + }, + "tag5": { + "type": "string", + "maxLength": 1000 + }, + "tag6": { + "type": "string", + "maxLength": 1000 + }, + "tag7": { + "type": "string", + "maxLength": 1000 + }, "processingOptions": { "type": "object", "additionalProperties": false, "properties": { - "recipe": { "type": "string", "maxLength": 255 }, - "lang": { "type": "string", "maxLength": 35 } + "recipe": { + "type": "string", + "maxLength": 255 + }, + "lang": { + "type": "string", + "maxLength": 35 + } }, "description": "Optional processing recipe and language, bound into the signed upload state." } @@ -1515,7 +2040,14 @@ "type": ["string", "null"] }, "document": { - "oneOf": [{ "$ref": "#/components/schemas/DocumentSummary" }, { "type": "null" }], + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentSummary" + }, + { + "type": "null" + } + ], "description": "The queued document after completion; null while uploading or after abort." } } @@ -1534,11 +2066,19 @@ "additionalProperties": false, "required": ["method", "url", "headers"], "properties": { - "method": { "type": "string", "const": "put" }, - "url": { "type": "string", "format": "uri" }, + "method": { + "type": "string", + "const": "put" + }, + "url": { + "type": "string", + "format": "uri" + }, "headers": { "type": "object", - "additionalProperties": { "type": "string" } + "additionalProperties": { + "type": "string" + } } } }, @@ -1547,17 +2087,33 @@ "additionalProperties": false, "required": ["method", "partSize", "partCount"], "properties": { - "method": { "type": "string", "const": "multipart" }, - "partSize": { "type": "integer", "minimum": 1 }, - "partCount": { "type": "integer", "minimum": 1, "maximum": 640 } + "method": { + "type": "string", + "const": "multipart" + }, + "partSize": { + "type": "integer", + "minimum": 1 + }, + "partCount": { + "type": "integer", + "minimum": 1, + "maximum": 640 + } } }, "UploadTransfer": { "oneOf": [ - { "$ref": "#/components/schemas/PutUploadTransfer" }, - { "$ref": "#/components/schemas/MultipartUploadTransfer" } + { + "$ref": "#/components/schemas/PutUploadTransfer" + }, + { + "$ref": "#/components/schemas/MultipartUploadTransfer" + } ], - "discriminator": { "propertyName": "method" } + "discriminator": { + "propertyName": "method" + } }, "CreateDocumentUploadEnvelope": { "type": "object", @@ -1568,9 +2124,16 @@ "additionalProperties": false, "required": ["session", "uploadToken", "transfer"], "properties": { - "session": { "$ref": "#/components/schemas/DocumentUpload" }, - "uploadToken": { "type": "string", "minLength": 1 }, - "transfer": { "$ref": "#/components/schemas/UploadTransfer" } + "session": { + "$ref": "#/components/schemas/DocumentUpload" + }, + "uploadToken": { + "type": "string", + "minLength": 1 + }, + "transfer": { + "$ref": "#/components/schemas/UploadTransfer" + } } } } @@ -2046,7 +2609,7 @@ }, "similarity": { "type": "number", - "description": "Similarity score in the range 0\u20131 for vector search (higher is more similar). 1 for tag-only matches.", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", "example": 0.8423 } } @@ -2153,6 +2716,32 @@ } } } + }, + "KnowledgeFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 4631df64376..0fd9b86657c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -60,9 +60,9 @@ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" }, { - "name": "folderIds", + "name": "folderPaths", "in": "query", - "description": "Comma-separated list of folder IDs. Returns logs for all workflows within these folders.", + "description": "Comma-separated list of folder paths. Returns logs for all workflows within these folders.", "schema": { "type": "string" } @@ -353,7 +353,7 @@ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Customer Support Agent", "description": "Routes incoming support tickets and drafts responses", - "folderId": null, + "folderPath": "/", "userId": "usr_1a2b3c4d5e", "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "createdAt": "2025-01-10T09:00:00.000Z", @@ -576,7 +576,7 @@ "id", "name", "description", - "folderId", + "folderPath", "userId", "workspaceId", "createdAt", @@ -599,10 +599,10 @@ "description": "Workflow description, or null if none was set.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { - "type": ["string", "null"], - "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", - "example": null + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" }, "userId": { "type": ["string", "null"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 3e4466ecbcc..be371e32bcc 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -33,10 +33,6 @@ "name": "Custom Tools", "description": "Create and manage the workspace's own code-backed tools that agents can call (v2 API)." }, - { - "name": "Folders", - "description": "Organize workflows, knowledge bases, and tables into folder trees (v2 API)." - }, { "name": "Credentials", "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)." @@ -62,13 +58,19 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -86,16 +88,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "MCP servers registered in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -106,9 +118,13 @@ "data": { "type": "array", "description": "The MCP servers registered in the workspace.", - "items": { "$ref": "#/components/schemas/McpServer" } + "items": { + "$ref": "#/components/schemas/McpServer" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -140,11 +156,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -164,7 +190,9 @@ "description": "The MCP server to register.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateMcpServerBody" }, + "schema": { + "$ref": "#/components/schemas/CreateMcpServerBody" + }, "examples": { "headerAuth": { "summary": "Header-authenticated server", @@ -174,7 +202,9 @@ "description": "Internal documentation tools", "url": "https://mcp.example.com/sse", "authType": "headers", - "headers": { "Authorization": "Bearer YOUR_TOKEN" }, + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + }, "timeout": 30000, "retries": 3 } @@ -198,13 +228,21 @@ "201": { "description": "The MCP server was registered.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/McpServerData" }, + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, "example": { "data": { "mcpServer": { @@ -231,12 +269,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -254,20 +304,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/McpServerId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/McpServerId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The MCP server.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/McpServerData" }, + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, "example": { "data": { "mcpServer": { @@ -291,12 +353,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { @@ -311,13 +385,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/McpServerId" }], + "parameters": [ + { + "$ref": "#/components/parameters/McpServerId" + } + ], "requestBody": { "required": true, "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateMcpServerBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateMcpServerBody" + }, "examples": { "disable": { "summary": "Disable a server", @@ -330,7 +410,9 @@ "summary": "Rotate the auth header", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "headers": { "Authorization": "Bearer NEW_TOKEN" } + "headers": { + "Authorization": "Bearer NEW_TOKEN" + } } } } @@ -341,13 +423,21 @@ "200": { "description": "The updated MCP server.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/McpServerData" }, + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, "example": { "data": { "mcpServer": { @@ -371,12 +461,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -392,30 +494,59 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/McpServerId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/McpServerId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The MCP server was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, - "example": { "data": { "id": "mcp-3f7a9c21", "deleted": true } } + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "mcp-3f7a9c21", + "deleted": true + } + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -433,13 +564,19 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -457,16 +594,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "Skills available in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -477,9 +624,13 @@ "data": { "type": "array", "description": "The skills available in the workspace, without their bodies.", - "items": { "$ref": "#/components/schemas/SkillSummary" } + "items": { + "$ref": "#/components/schemas/SkillSummary" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -506,11 +657,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -530,7 +691,9 @@ "description": "The skill to create.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateSkillBody" }, + "schema": { + "$ref": "#/components/schemas/CreateSkillBody" + }, "examples": { "refundPolicy": { "summary": "A support playbook", @@ -549,13 +712,21 @@ "201": { "description": "The skill was created.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SkillData" }, + "schema": { + "$ref": "#/components/schemas/SkillData" + }, "example": { "data": { "skill": { @@ -572,12 +743,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -595,20 +778,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/SkillId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/SkillId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The skill.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SkillData" }, + "schema": { + "$ref": "#/components/schemas/SkillData" + }, "example": { "data": { "skill": { @@ -625,12 +820,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { @@ -645,13 +852,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/SkillId" }], + "parameters": [ + { + "$ref": "#/components/parameters/SkillId" + } + ], "requestBody": { "required": true, "description": "The fields to change. At least one of `name`, `description`, or `content` is required.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateSkillBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateSkillBody" + }, "examples": { "editDescription": { "summary": "Change the description only", @@ -675,13 +888,21 @@ "200": { "description": "The updated skill.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SkillData" }, + "schema": { + "$ref": "#/components/schemas/SkillData" + }, "example": { "data": { "skill": { @@ -698,13 +919,27 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -720,30 +955,59 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/SkillId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/SkillId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The skill was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, - "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -761,13 +1025,19 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -785,16 +1055,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "Custom tools defined in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -805,9 +1085,13 @@ "data": { "type": "array", "description": "The custom tools defined in the workspace.", - "items": { "$ref": "#/components/schemas/CustomTool" } + "items": { + "$ref": "#/components/schemas/CustomTool" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -822,7 +1106,11 @@ "description": "Look up an order by id", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -837,11 +1125,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -861,7 +1159,9 @@ "description": "The custom tool to create.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateCustomToolBody" }, + "schema": { + "$ref": "#/components/schemas/CreateCustomToolBody" + }, "examples": { "lookupOrder": { "summary": "A tool that calls an internal API", @@ -875,7 +1175,11 @@ "description": "Look up an order by id", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -891,13 +1195,21 @@ "201": { "description": "The custom tool was created.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, "example": { "data": { "customTool": { @@ -909,7 +1221,11 @@ "name": "lookup_order", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -923,12 +1239,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -946,20 +1274,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CustomToolId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CustomToolId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The custom tool.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, "example": { "data": { "customTool": { @@ -971,7 +1311,11 @@ "name": "lookup_order", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -985,12 +1329,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { @@ -1005,13 +1361,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/CustomToolId" }], + "parameters": [ + { + "$ref": "#/components/parameters/CustomToolId" + } + ], "requestBody": { "required": true, "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateCustomToolBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateCustomToolBody" + }, "examples": { "editCode": { "summary": "Replace the implementation only", @@ -1035,13 +1397,21 @@ "200": { "description": "The updated custom tool.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, "example": { "data": { "customTool": { @@ -1053,7 +1423,11 @@ "name": "lookup_order", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -1067,13 +1441,27 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -1089,416 +1477,81 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CustomToolId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CustomToolId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The custom tool was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, - "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, - "/api/v2/folders": { + "/api/v2/credentials": { "get": { - "operationId": "listFolders", - "summary": "List Folders", - "description": "List a workspace's folder tree for one resource type. One folder engine serves several trees, so `resourceType` is **required** — it selects which tree you are addressing.\n\nPass `scope=archived` to list folders in Recently Deleted instead of live ones. A workspace's tree for one resource type is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`. Folders come back in tree order (`sortOrder`, then creation time); build the hierarchy from `parentId`.", - "tags": ["Folders"], + "operationId": "listCredentials", + "summary": "List Credentials", + "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Credentials"], "x-codeSamples": [ { "label": "cURL", "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/folders?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/FolderResourceTypeQuery" }, { - "name": "scope", - "in": "query", - "required": false, - "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", - "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field to sort by. `position` is the tree's own manual arrangement, which is the default order.", - "schema": { - "type": "string", - "enum": ["position", "name", "createdAt", "updatedAt"], - "default": "position" - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } - } - ], - "responses": { - "200": { - "description": "Folders in the workspace's tree for the requested resource type.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data", "nextCursor"], - "properties": { - "data": { - "type": "array", - "description": "The folders in the requested tree.", - "items": { "$ref": "#/components/schemas/Folder" } - }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } - } - }, - "example": { - "data": [ - { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-20T14:02:11.000Z", - "deletedAt": null - } - ], - "nextCursor": null - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - }, - "post": { - "operationId": "createFolder", - "summary": "Create Folder", - "description": "Create a folder in one of a workspace's resource trees. Requires `write` permission on the workspace.\n\n`resourceType` is required and selects the tree. Pass `parentId: null` (or omit it) to create the folder at the root; a `parentId` must name a live folder of the same `resourceType` in the same workspace.\n\nA sibling folder with the same name returns `409 CONFLICT`. If the parent is a locked workflow folder, the request returns `423 LOCKED`.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/folders\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Onboarding\"\n }'" - } - ], - "requestBody": { - "required": true, - "description": "The folder to create.", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateFolderBody" }, - "examples": { - "rootFolder": { - "summary": "A workflow folder at the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "name": "Onboarding" - } - }, - "nestedKnowledgeFolder": { - "summary": "A knowledge-base folder nested under another", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "knowledge_base", - "name": "Policies", - "parentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "The folder was created.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderData" }, - "example": { - "data": { - "folder": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-20T14:02:11.000Z", - "updatedAt": "2025-06-20T14:02:11.000Z", - "deletedAt": null - } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - } - }, - "/api/v2/folders/{id}": { - "get": { - "operationId": "getFolder", - "summary": "Get Folder", - "description": "Fetch a single folder by id. Archived folders resolve too — check `deletedAt` to tell them apart from live ones.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { "$ref": "#/components/parameters/FolderId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/FolderResourceTypeQuery" } - ], - "responses": { - "200": { - "description": "The folder.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderData" }, - "example": { - "data": { - "folder": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-20T14:02:11.000Z", - "deletedAt": null - } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - }, - "patch": { - "operationId": "updateFolder", - "summary": "Update Folder", - "description": "Rename, move, or reorder a folder. Only the fields you send are changed.\n\nMoving is `parentId` — pass `null` to move to the root. A move that would place a folder inside its own subtree is rejected.\n\n`locked` applies to workflow folders only and requires workspace `admin`; sending it for another tree returns `400`. Everything else needs workspace `write`.\n\nArchived folders cannot be updated (`404`), and a mutation lock anywhere on the path returns `423 LOCKED`.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Customer Onboarding\"\n }'" - } - ], - "parameters": [{ "$ref": "#/components/parameters/FolderId" }], - "requestBody": { - "required": true, - "description": "The fields to change. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required.", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateFolderBody" }, - "examples": { - "rename": { - "summary": "Rename a folder", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "name": "Customer Onboarding" - } - }, - "moveToRoot": { - "summary": "Move a folder to the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "parentId": null - } - }, - "lock": { - "summary": "Lock a workflow folder (requires admin)", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "locked": true - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The updated folder.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderData" }, - "example": { - "data": { - "folder": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Customer Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-21T08:30:00.000Z", - "deletedAt": null - } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - }, - "delete": { - "operationId": "deleteFolder", - "summary": "Delete Folder", - "description": "Archive a folder and everything under it. The cascade moves the subtree — subfolders and the resources filed in them — into Recently Deleted, and `deletedItems` reports how much was archived; only the count matching `resourceType` is populated.\n\nDeleting is idempotent: re-issuing it against an already archived folder retries the cascade onto the same snapshot rather than 404ing, so a run that failed partway can be completed.\n\nA mutation lock anywhere in the subtree returns `423 LOCKED`.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { "$ref": "#/components/parameters/FolderId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/FolderResourceTypeQuery" } - ], - "responses": { - "200": { - "description": "The folder and its subtree were archived.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderDeleteAcknowledgement" }, - "example": { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "deleted": true, - "deletedItems": { "folders": 3, "workflows": 12 } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - } - }, - "/api/v2/credentials": { - "get": { - "operationId": "listCredentials", - "summary": "List Credentials", - "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", - "tags": ["Credentials"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { - "name": "type", + "name": "type", "in": "query", "required": false, "description": "Only return credentials of this kind.", @@ -1512,14 +1565,22 @@ "in": "query", "required": false, "description": "Only return credentials for this integration.", - "schema": { "type": "string", "minLength": 1, "example": "slack" } + "schema": { + "type": "string", + "minLength": 1, + "example": "slack" + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -1537,16 +1598,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "Credentials visible to the caller in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -1557,9 +1628,13 @@ "data": { "type": "array", "description": "The credentials visible to the caller.", - "items": { "$ref": "#/components/schemas/Credential" } + "items": { + "$ref": "#/components/schemas/Credential" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -1583,11 +1658,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -1607,7 +1692,9 @@ "description": "The credential to create.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateCredentialBody" }, + "schema": { + "$ref": "#/components/schemas/CreateCredentialBody" + }, "examples": { "workspaceEnvVar": { "summary": "A workspace-wide environment secret", @@ -1636,13 +1723,21 @@ "201": { "description": "The credential exists with this source. Returned whether it was inserted now or already present.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialData" }, + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, "example": { "data": { "credential": { @@ -1663,13 +1758,27 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" }, - "503": { "$ref": "#/components/responses/ServiceUnavailable" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } } } }, @@ -1687,20 +1796,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CredentialId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CredentialId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The credential.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialData" }, + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, "example": { "data": { "credential": { @@ -1721,18 +1842,30 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { "operationId": "updateCredential", "summary": "Update Credential", - "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin — access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", "tags": ["Credentials"], "x-codeSamples": [ { @@ -1741,13 +1874,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/CredentialId" }], + "parameters": [ + { + "$ref": "#/components/parameters/CredentialId" + } + ], "requestBody": { "required": true, "description": "The fields to change. At least one field besides `workspaceId` is required.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateCredentialBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateCredentialBody" + }, "examples": { "rename": { "summary": "Rename a credential", @@ -1771,13 +1910,21 @@ "200": { "description": "The updated credential.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialData" }, + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, "example": { "data": { "credential": { @@ -1798,20 +1945,36 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" }, - "503": { "$ref": "#/components/responses/ServiceUnavailable" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } } }, "delete": { "operationId": "deleteCredential", "summary": "Delete Credential", - "description": "Delete a credential. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", + "description": "Delete a credential. Requires credential admin — access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", "tags": ["Credentials"], "x-codeSamples": [ { @@ -1821,32 +1984,59 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CredentialId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CredentialId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The credential was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, "example": { - "data": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "deleted": true } + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } } @@ -1863,19 +2053,32 @@ "headers": { "RateLimitLimit": { "description": "The maximum number of requests permitted in the current rate-limit window.", - "schema": { "type": "integer", "example": 60 } + "schema": { + "type": "integer", + "example": 60 + } }, "RateLimitRemaining": { "description": "The number of requests remaining in the current rate-limit window.", - "schema": { "type": "integer", "example": 59 } + "schema": { + "type": "integer", + "example": 59 + } }, "RateLimitReset": { "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { "type": "string", "format": "date-time", "example": "2025-06-20T14:16:00Z" } + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } }, "RetryAfter": { "description": "Number of seconds to wait before retrying the request.", - "schema": { "type": "integer", "example": 30 } + "schema": { + "type": "integer", + "example": 30 + } } }, "parameters": { @@ -1895,21 +2098,33 @@ "in": "path", "required": true, "description": "The unique identifier of the MCP server.", - "schema": { "type": "string", "minLength": 1, "example": "mcp-3f7a9c21" } + "schema": { + "type": "string", + "minLength": 1, + "example": "mcp-3f7a9c21" + } }, "SkillId": { "name": "id", "in": "path", "required": true, "description": "The unique identifier of the skill. Built-in skills use their name as their id.", - "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + "schema": { + "type": "string", + "minLength": 1, + "example": "V1StGXR8Z5jdHi6BmyT" + } }, "CustomToolId": { "name": "id", "in": "path", "required": true, "description": "The unique identifier of the custom tool.", - "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + "schema": { + "type": "string", + "minLength": 1, + "example": "V1StGXR8Z5jdHi6BmyT" + } }, "FolderId": { "name": "id", @@ -1932,17 +2147,6 @@ "minLength": 1, "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" } - }, - "FolderResourceTypeQuery": { - "name": "resourceType", - "in": "query", - "required": true, - "description": "Which resource tree the folder belongs to. Required — folder ids are unique, but addressing the wrong tree would file a folder where its page can never see it.", - "schema": { - "type": "string", - "enum": ["workflow", "knowledge_base", "table"], - "example": "workflow" - } } }, "responses": { @@ -1950,12 +2154,19 @@ "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "BAD_REQUEST", "message": "Workspace ID is required", - "details": [{ "path": "workspaceId", "message": "Workspace ID is required" }] + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] } } } @@ -1965,8 +2176,15 @@ "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } } } }, @@ -1974,8 +2192,15 @@ "description": "The authenticated caller does not have the required permission on the workspace, or the URL was rejected by the server's MCP domain policy.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "FORBIDDEN", "message": "Access denied" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } } } }, @@ -1983,8 +2208,15 @@ "description": "The requested resource does not exist or is not accessible from this workspace.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "NOT_FOUND", "message": "MCP server not found" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "MCP server not found" + } + } } } }, @@ -1992,7 +2224,9 @@ "description": "The request conflicts with the current state of the workspace — for example a resource with the same identity already exists.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "CONFLICT", @@ -2006,7 +2240,9 @@ "description": "A mutation lock on the resource (or something inside it) blocks the change. Unlock it and retry.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "LOCKED", @@ -2019,19 +2255,31 @@ "RateLimited": { "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", "headers": { - "Retry-After": { "$ref": "#/components/headers/RetryAfter" }, - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "RATE_LIMITED", "message": "API rate limit exceeded", - "details": { "retryAfter": "2025-06-20T14:16:00Z" } + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } } } } @@ -2041,8 +2289,15 @@ "description": "An unexpected error occurred on the server.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "INTERNAL_ERROR", "message": "Internal server error" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } } } }, @@ -2050,7 +2305,9 @@ "description": "An upstream provider could not be reached to verify the request. Retry shortly.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "SERVICE_UNAVAILABLE", @@ -2115,7 +2372,10 @@ "type": "string", "description": "The identifier of the resource that was deleted." }, - "deleted": { "type": "boolean", "const": true } + "deleted": { + "type": "boolean", + "const": true + } } } } @@ -2139,8 +2399,14 @@ "type": "string", "description": "The server's unique identifier, derived from the workspace and the server URL." }, - "name": { "type": "string", "description": "Display name of the server." }, - "description": { "type": "string", "description": "Optional description." }, + "name": { + "type": "string", + "description": "Display name of the server." + }, + "description": { + "type": "string", + "description": "Optional description." + }, "transport": { "type": "string", "enum": ["streamable-http"], @@ -2151,12 +2417,18 @@ "enum": ["none", "headers", "oauth"], "description": "How Sim authenticates to the server." }, - "url": { "type": "string", "description": "The server's endpoint URL." }, + "url": { + "type": "string", + "description": "The server's endpoint URL." + }, "timeout": { "type": "number", "description": "Per-request timeout in milliseconds." }, - "retries": { "type": "number", "description": "Number of retries per request." }, + "retries": { + "type": "number", + "description": "Number of retries per request." + }, "enabled": { "type": "boolean", "description": "Whether the server's tools are available to workflows." @@ -2184,8 +2456,14 @@ "format": "date-time", "description": "When Sim last connected successfully." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, "oauthClientId": { "type": "string", "description": "Pre-registered OAuth client id, when the server does not support dynamic client registration." @@ -2196,7 +2474,9 @@ }, "headerNames": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Names of the configured request headers. Values are never returned." }, "hasOauthClientSecret": { @@ -2212,7 +2492,11 @@ "data": { "type": "object", "required": ["mcpServer"], - "properties": { "mcpServer": { "$ref": "#/components/schemas/McpServer" } } + "properties": { + "mcpServer": { + "$ref": "#/components/schemas/McpServer" + } + } } } }, @@ -2256,7 +2540,9 @@ }, "headers": { "type": "object", - "additionalProperties": { "type": "string" }, + "additionalProperties": { + "type": "string" + }, "description": "Write-only. Request headers sent to the server, e.g. `Authorization`. Never returned on read." }, "timeout": { @@ -2298,25 +2584,53 @@ "minLength": 1, "description": "The workspace that owns the server." }, - "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "description": { "type": "string", "maxLength": 2000 }, - "transport": { "type": "string", "enum": ["streamable-http"] }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "transport": { + "type": "string", + "enum": ["streamable-http"] + }, "url": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL." }, - "authType": { "type": "string", "enum": ["none", "headers", "oauth"] }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"] + }, "headers": { "type": "object", - "additionalProperties": { "type": "string" }, + "additionalProperties": { + "type": "string" + }, "description": "Write-only. Replaces the stored header map wholesale." }, - "timeout": { "type": "integer", "minimum": 1000, "maximum": 300000 }, - "retries": { "type": "integer", "minimum": 0, "maximum": 10 }, - "enabled": { "type": "boolean" }, - "oauthClientId": { "type": ["string", "null"], "maxLength": 512 }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "type": "boolean" + }, + "oauthClientId": { + "type": ["string", "null"], + "maxLength": 512 + }, "oauthClientSecret": { "type": ["string", "null"], "maxLength": 2048, @@ -2329,7 +2643,10 @@ "description": "A skill without its body. Fetch the skill by id to read `content`.", "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], "properties": { - "id": { "type": "string", "description": "The skill's unique identifier." }, + "id": { + "type": "string", + "description": "The skill's unique identifier." + }, "name": { "type": "string", "description": "Kebab-case name, unique within the workspace. This is what agents reference." @@ -2342,8 +2659,14 @@ "type": "boolean", "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "Skill": { @@ -2351,7 +2674,10 @@ "description": "A skill, including its full body.", "required": ["id", "name", "description", "content", "readOnly", "createdAt", "updatedAt"], "properties": { - "id": { "type": "string", "description": "The skill's unique identifier." }, + "id": { + "type": "string", + "description": "The skill's unique identifier." + }, "name": { "type": "string", "description": "Kebab-case name, unique within the workspace. This is what agents reference." @@ -2368,8 +2694,14 @@ "type": "boolean", "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "SkillData": { @@ -2379,7 +2711,11 @@ "data": { "type": "object", "required": ["skill"], - "properties": { "skill": { "$ref": "#/components/schemas/Skill" } } + "properties": { + "skill": { + "$ref": "#/components/schemas/Skill" + } + } } } }, @@ -2432,8 +2768,16 @@ "maxLength": 64, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, - "description": { "type": "string", "minLength": 1, "maxLength": 1024 }, - "content": { "type": "string", "minLength": 1, "maxLength": 50000 } + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000 + } } }, "CustomToolSchema": { @@ -2441,7 +2785,10 @@ "description": "OpenAI-style function declaration describing the tool's callable surface. The parameter properties are caller-defined, so the shape below the function level is open.", "required": ["type", "function"], "properties": { - "type": { "type": "string", "const": "function" }, + "type": { + "type": "string", + "const": "function" + }, "function": { "type": "object", "required": ["name", "parameters"], @@ -2460,7 +2807,10 @@ "description": "JSON Schema for the tool's arguments.", "required": ["type", "properties"], "properties": { - "type": { "type": "string", "description": "Usually `object`." }, + "type": { + "type": "string", + "description": "Usually `object`." + }, "properties": { "type": "object", "additionalProperties": true, @@ -2468,7 +2818,9 @@ }, "required": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Names of the required arguments." } } @@ -2482,18 +2834,29 @@ "description": "A code-backed tool defined in a workspace that agents can call.", "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], "properties": { - "id": { "type": "string", "description": "The tool's unique identifier." }, + "id": { + "type": "string", + "description": "The tool's unique identifier." + }, "title": { "type": "string", "description": "Display title, unique within the workspace. Tools also resolve by title at call time." }, - "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, "code": { "type": "string", "description": "The tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "CustomToolData": { @@ -2503,7 +2866,11 @@ "data": { "type": "object", "required": ["customTool"], - "properties": { "customTool": { "$ref": "#/components/schemas/CustomTool" } } + "properties": { + "customTool": { + "$ref": "#/components/schemas/CustomTool" + } + } } } }, @@ -2524,7 +2891,9 @@ "maxLength": 200, "description": "Display title, unique within the workspace." }, - "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, "code": { "type": "string", "maxLength": 100000, @@ -2543,161 +2912,20 @@ "minLength": 1, "description": "The workspace that owns the tool." }, - "title": { "type": "string", "minLength": 1, "maxLength": 200 }, - "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, - "code": { "type": "string", "maxLength": 100000 } - } - }, - "Folder": { - "type": "object", - "description": "A folder in one of a workspace's resource trees.", - "required": [ - "id", - "resourceType", - "name", - "parentId", - "locked", - "sortOrder", - "createdAt", - "updatedAt", - "deletedAt" - ], - "properties": { - "id": { "type": "string", "description": "The folder's unique identifier." }, - "resourceType": { - "type": "string", - "enum": ["workflow", "file", "knowledge_base", "table"], - "description": "Which resource tree the folder belongs to. Only `workflow`, `knowledge_base`, and `table` are served by this API; `file` folders have their own surface." - }, - "name": { "type": "string", "description": "Display name." }, - "parentId": { - "type": ["string", "null"], - "description": "The containing folder, or null when the folder sits at the workspace root." - }, - "locked": { - "type": "boolean", - "description": "Whether the folder is locked against modification. Workflow folders only; always false elsewhere." - }, - "sortOrder": { - "type": "number", - "description": "Position among its siblings, ascending." - }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the folder was archived into Recently Deleted, or null when it is live." - } - } - }, - "FolderData": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["folder"], - "properties": { "folder": { "$ref": "#/components/schemas/Folder" } } - } - } - }, - "FolderDeleteAcknowledgement": { - "type": "object", - "description": "Acknowledgement that a folder was archived, with what the cascade took with it.", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["id", "deleted"], - "properties": { - "id": { - "type": "string", - "description": "The identifier of the folder that was archived." - }, - "deleted": { "type": "boolean", "const": true }, - "deletedItems": { - "type": "object", - "description": "How much the cascade archived. Only the count matching the folder's `resourceType` is populated.", - "required": ["folders"], - "properties": { - "folders": { - "type": "integer", - "description": "Subfolders archived, including the folder itself." - }, - "workflows": { "type": "integer" }, - "files": { "type": "integer" }, - "knowledgeBases": { "type": "integer" }, - "tables": { "type": "integer" } - } - } - } - } - } - }, - "CreateFolderBody": { - "type": "object", - "description": "A new folder.", - "additionalProperties": false, - "required": ["workspaceId", "resourceType", "name"], - "properties": { - "workspaceId": { + "title": { "type": "string", "minLength": 1, - "description": "The workspace to create the folder in." + "maxLength": 200 }, - "resourceType": { - "type": "string", - "enum": ["workflow", "knowledge_base", "table"], - "description": "Which resource tree to create the folder in. Required." + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" }, - "name": { + "code": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Display name. Must be unique among its siblings." - }, - "parentId": { - "type": ["string", "null"], - "minLength": 1, - "description": "The containing folder. Omit or pass null to create at the workspace root." - }, - "sortOrder": { - "type": "integer", - "minimum": 0, - "description": "Position among its siblings. Defaults to the top of the list." + "maxLength": 100000 } } }, - "UpdateFolderBody": { - "type": "object", - "description": "Fields to change on an existing folder. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values.", - "additionalProperties": false, - "required": ["workspaceId", "resourceType"], - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the folder." - }, - "resourceType": { - "type": "string", - "enum": ["workflow", "knowledge_base", "table"], - "description": "Which resource tree the folder belongs to. Required." - }, - "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "locked": { - "type": "boolean", - "description": "Workflow folders only, and changing it requires workspace `admin`." - }, - "parentId": { - "type": ["string", "null"], - "minLength": 1, - "description": "New parent folder. Pass null to move to the workspace root." - }, - "sortOrder": { "type": "integer", "minimum": 0 } - } - }, "Credential": { "type": "object", "description": "A stored credential. Secret material is write-only and never appears here.", @@ -2715,14 +2943,22 @@ "updatedAt" ], "properties": { - "id": { "type": "string", "description": "The credential's unique identifier." }, + "id": { + "type": "string", + "description": "The credential's unique identifier." + }, "type": { "type": "string", "enum": ["oauth", "env_workspace", "env_personal", "service_account"], "description": "What kind of credential this is." }, - "displayName": { "type": "string", "description": "Display name." }, - "description": { "type": ["string", "null"] }, + "displayName": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": ["string", "null"] + }, "providerId": { "type": ["string", "null"], "description": "The integration this credential authenticates against, when it has one." @@ -2744,8 +2980,14 @@ "enum": ["admin", "member"], "description": "The caller's role on this credential. Only admins can update or delete it." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "CredentialData": { @@ -2755,7 +2997,11 @@ "data": { "type": "object", "required": ["credential"], - "properties": { "credential": { "$ref": "#/components/schemas/Credential" } } + "properties": { + "credential": { + "$ref": "#/components/schemas/Credential" + } + } } } }, @@ -2781,7 +3027,10 @@ "maxLength": 255, "description": "Display name. Derived from the env key or the verified provider account when omitted." }, - "description": { "type": "string", "maxLength": 500 }, + "description": { + "type": "string", + "maxLength": 500 + }, "providerId": { "type": "string", "minLength": 1, @@ -2817,14 +3066,22 @@ "minLength": 1, "description": "Atlassian site domain, paired with `apiToken`." }, - "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, "clientSecret": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Write-only. Client-credentials secret." }, - "orgId": { "type": "string", "minLength": 1, "maxLength": 255 }, + "orgId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "dataCenter": { "type": "string", "minLength": 1, @@ -2844,7 +3101,11 @@ "minLength": 1, "description": "The workspace that owns the credential." }, - "displayName": { "type": "string", "minLength": 1, "maxLength": 255 }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "description": { "type": ["string", "null"], "maxLength": 500, @@ -2855,18 +3116,41 @@ "minLength": 1, "description": "Write-only. Replaces the stored service-account JSON key." }, - "signingSecret": { "type": "string", "minLength": 1, "description": "Write-only." }, - "botToken": { "type": "string", "minLength": 1, "description": "Write-only." }, - "apiToken": { "type": "string", "minLength": 1, "description": "Write-only." }, - "domain": { "type": "string", "minLength": 1 }, - "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "signingSecret": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "botToken": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "apiToken": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "domain": { + "type": "string", + "minLength": 1 + }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, "clientSecret": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Write-only." }, - "orgId": { "type": "string", "minLength": 1, "maxLength": 255 }, + "orgId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "dataCenter": { "type": "string", "minLength": 1, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index a80ff6774b4..5520ec9c1e1 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -51,7 +51,7 @@ "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every table in the workspace.", @@ -64,7 +64,7 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the table `name`. Matches nothing else \u2014 not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", "schema": { "type": "string", "minLength": 1, @@ -118,45 +118,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TableListEnvelope" - }, - "example": { - "data": [ - { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "contacts", - "description": "Customer contact records", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - }, - { - "id": "col_d4e5f6", - "name": "name", - "type": "string", - "required": true - } - ] - }, - "rowCount": 2, - "maxRows": 100000, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z", - "folderId": null, - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": false - }, - "job": null - } - ], - "nextCursor": null } } } @@ -220,51 +181,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TableEnvelope" - }, - "example": { - "data": { - "table": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "contacts", - "description": "Customer contacts", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - }, - { - "id": "col_d4e5f6", - "name": "name", - "type": "string", - "required": true - }, - { - "id": "col_g7h8i9", - "name": "age", - "type": "number", - "required": false, - "unique": false - } - ] - }, - "rowCount": 0, - "maxRows": 100000, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z", - "folderId": null, - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": false - }, - "job": null - } - } } } } @@ -354,7 +270,7 @@ "delete": { "operationId": "deleteTable", "summary": "Delete Table", - "description": "Archive a table. Returns the id of the archived table.", + "description": "Delete a table. Returns the id of the deleted table.", "tags": ["Tables"], "x-codeSamples": [ { @@ -374,7 +290,7 @@ ], "responses": { "200": { - "description": "The table was archived.", + "description": "The table was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -390,11 +306,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/DeleteTableEnvelope" - }, - "example": { - "data": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - } } } } @@ -422,7 +333,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderId`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API \u2014 a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the body shape, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.", + "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderPath`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table deleted mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderPath\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", "tags": ["Tables"], "x-codeSamples": [ { @@ -443,22 +354,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/UpdateTableBody" - }, - "examples": { - "rename": { - "summary": "Rename", - "value": { - "workspaceId": "ws_123", - "name": "customers" - } - }, - "move": { - "summary": "Move to the workspace root", - "value": { - "workspaceId": "ws_123", - "folderId": null - } - } } } } @@ -481,38 +376,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TableEnvelope" - }, - "example": { - "data": { - "table": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "customers", - "description": "Customer contact records", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - } - ] - }, - "rowCount": 42, - "maxRows": 100000, - "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": true - }, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-16T09:12:00.000Z", - "job": null - } - } } } } @@ -646,7 +509,7 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name \u2014 rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", "tags": ["Tables"], "x-codeSamples": [ { @@ -790,7 +653,7 @@ "get": { "operationId": "listTableRows", "summary": "List rows", - "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface \u2014 use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -1526,7 +1389,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` \u2014 a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", "tags": ["Tables"], "parameters": [ { @@ -1571,7 +1434,7 @@ "minimum": 0, "maximum": 1000, "default": 100, - "description": "Omitted \u2192 100. `1..1000` \u2192 page size. `0` \u2192 the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." }, "cursor": { "type": "string", @@ -1685,116 +1548,6 @@ } } }, - "/api/v2/tables/{tableId}/restore": { - "post": { - "operationId": "restoreTable", - "summary": "Restore Table", - "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table\u2019s name \u2014 rename that table first, then retry.", - "tags": ["Tables"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/restore\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/TableId" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceScopedBody" - }, - "example": { - "workspaceId": "ws_123" - } - } - } - }, - "responses": { - "200": { - "description": "The restored table.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/RateLimitLimit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/RateLimitRemaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/RateLimitReset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TableEnvelope" - }, - "example": { - "data": { - "table": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "customers", - "description": "Customer contact records", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - } - ] - }, - "rowCount": 42, - "maxRows": 100000, - "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": true - }, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-16T09:12:00.000Z", - "job": null - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/tables/{tableId}/views": { "get": { "operationId": "listTableViews", @@ -1819,7 +1572,7 @@ ], "responses": { "200": { - "description": "The table\u2019s saved views.", + "description": "The table’s saved views.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1894,7 +1647,7 @@ "post": { "operationId": "createTableView", "summary": "Create View", - "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary \u2014 rows it hides stay readable through the row and query endpoints.", + "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary — rows it hides stay readable through the row and query endpoints.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2115,7 +1868,7 @@ "patch": { "operationId": "updateTableView", "summary": "Update View", - "description": "Rename a view, replace or merge its config, or promote it to the table\u2019s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table\u2019s existing default in the same transaction.", + "description": "Rename a view, replace or merge its config, or promote it to the table’s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table’s existing default in the same transaction.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2142,7 +1895,7 @@ }, "examples": { "promote": { - "summary": "Make this the table\u2019s default view", + "summary": "Make this the table’s default view", "value": { "workspaceId": "ws_123", "isDefault": true @@ -2244,7 +1997,7 @@ "delete": { "operationId": "deleteTableView", "summary": "Delete View", - "description": "Remove a saved view. Deleting the table\u2019s default simply leaves the table unfiltered; no rows are affected.", + "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2317,7 +2070,7 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "The table\u2019s workflow and enrichment groups \u2014 the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "description": "The table’s workflow and enrichment groups — the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2337,7 +2090,7 @@ ], "responses": { "200": { - "description": "The table\u2019s workflow groups.", + "description": "The table’s workflow groups.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -2403,7 +2156,7 @@ "post": { "operationId": "addTableWorkflowGroup", "summary": "Add Workflow Group", - "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns \u2014 one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2772,7 +2525,7 @@ "post": { "operationId": "runTableColumns", "summary": "Run Column Groups", - "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) \u2014 never both. Omit both to run every row. Starting a run clears the target groups\u2019 cells to pending, so a read taken immediately after will show them empty.", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) — never both. Omit both to run every row. Starting a run clears the target groups’ cells to pending, so a read taken immediately after will show them empty.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2882,7 +2635,7 @@ "post": { "operationId": "runRowEnrichment", "summary": "Run Enrichment For One Row", - "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** \u2014 the response acknowledges the dispatch; read the row back for the result.", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** — the response acknowledges the dispatch; read the row back for the result.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2971,7 +2724,7 @@ "post": { "operationId": "findTableRows", "summary": "Find Rows", - "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row\u2019s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor \u2014 when `truncated` is true, narrow the predicate rather than paging.", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row’s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor — when `truncated` is true, narrow the predicate rather than paging.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3086,7 +2839,7 @@ "x-removed-get": { "operationId": "listTableJobs", "summary": "List Export Jobs", - "description": "Export jobs across a workspace \u2014 running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "description": "Export jobs across a workspace — running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3106,7 +2859,7 @@ ], "responses": { "200": { - "description": "The workspace\u2019s export jobs.", + "description": "The workspace’s export jobs.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -3163,7 +2916,7 @@ "x-removed-post": { "operationId": "importTableCsvAsync", "summary": "Import CSV (Background)", - "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "description": "Start a background import of a file already uploaded to workspace storage — the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace’s storage prefix. The table’s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3418,7 +3171,7 @@ "x-removed-post": { "operationId": "cancelTableJob", "summary": "Cancel Job", - "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place \u2014 there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place — there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3950,7 +3703,7 @@ "post": { "operationId": "cancelTableRuns", "summary": "Cancel Column Runs", - "description": "Stop in-flight and pending workflow or enrichment cell runs \u2014 the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row\u2019s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "description": "Stop in-flight and pending workflow or enrichment cell runs — the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row’s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3981,7 +3734,7 @@ } }, "oneRow": { - "summary": "Stop one row\u2019s runs", + "summary": "Stop one row’s runs", "value": { "workspaceId": "ws_123", "scope": "row", @@ -4039,6 +3792,398 @@ } } } + }, + "/api/v2/tables/folders": { + "get": { + "operationId": "listTablesFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TablesFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTablesFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/TablesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateTablesFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/TablesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTablesFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "tables"], + "properties": { + "folders": { + "type": "integer" + }, + "tables": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -4311,7 +4456,7 @@ }, "id": { "type": "string", - "description": "Stable column id. Server-assigned \u2014 normally omit." + "description": "Stable column id. Server-assigned — normally omit." }, "options": { "type": "array", @@ -4342,7 +4487,7 @@ "schema", "rowCount", "maxRows", - "folderId", + "folderPath", "locks", "createdAt", "updatedAt", @@ -4396,9 +4541,9 @@ "format": "date-time", "description": "ISO 8601 timestamp when the table was last modified." }, - "folderId": { - "type": ["string", "null"], - "description": "Folder holding the table, or null when it sits at the workspace root." + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." }, "locks": { "$ref": "#/components/schemas/TableLocks" @@ -4502,9 +4647,9 @@ } } }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -4826,7 +4971,7 @@ }, "DeleteTableEnvelope": { "type": "object", - "description": "Confirmation that a table was archived.", + "description": "Confirmation that a table was deleted.", "required": ["data"], "properties": { "data": { @@ -4835,7 +4980,7 @@ "properties": { "id": { "type": "string", - "description": "The id of the archived table." + "description": "The id of the deleted table." } } } @@ -5032,7 +5177,7 @@ } }, "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", "oneOf": [ { "type": "object", @@ -5084,7 +5229,7 @@ "field": { "type": "string", "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." }, "op": { "enum": [ @@ -5109,7 +5254,7 @@ "isNull", "isNotNull" ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." }, "value": { "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." @@ -5122,7 +5267,7 @@ "properties": { "id": { "type": "string", - "description": "Stable option id \u2014 the value stored in cells." + "description": "Stable option id — the value stored in cells." }, "name": { "type": "string", @@ -5169,9 +5314,9 @@ "minLength": 1, "description": "New table name." }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to move the table into. Pass null to move it to the workspace root; omit to leave the placement untouched." + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } }, "additionalProperties": false @@ -5207,7 +5352,7 @@ }, "ViewConfig": { "type": "object", - "description": "A view\u2019s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "description": "A view’s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", "properties": { "columnWidths": { "type": "object", @@ -5236,7 +5381,7 @@ "items": { "type": "string" }, - "description": "Column ids hidden by the view. A deny-list \u2014 a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + "description": "Column ids hidden by the view. A deny-list — a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." }, "filter": { "$ref": "#/components/schemas/Predicate" @@ -5248,7 +5393,7 @@ }, "View": { "type": "object", - "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only \u2014 a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only — a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", "required": [ "id", "tableId", @@ -5278,7 +5423,7 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this view is the table\u2019s default. At most one view per table is." + "description": "Whether this view is the table’s default. At most one view per table is." }, "createdBy": { "type": ["string", "null"], @@ -5347,7 +5492,7 @@ }, "isDefault": { "type": "boolean", - "description": "Promote this view to the table\u2019s default. Setting it demotes the table\u2019s existing default in the same transaction." + "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction." } } }, @@ -5380,7 +5525,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 a table carries a bounded set of views, so the list is a single full page." + "description": "Always null — a table carries a bounded set of views, so the list is a single full page." } } }, @@ -5408,7 +5553,7 @@ "properties": { "id": { "type": "string", - "description": "Group id \u2014 pass to the run endpoints." + "description": "Group id — pass to the run endpoints." }, "workflowId": { "type": "string", @@ -5503,13 +5648,13 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 groups are bounded per table, so the list is a single full page." + "description": "Always null — groups are bounded per table, so the list is a single full page." } } }, "RunColumnBody": { "type": "object", - "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) \u2014 never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) — never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", "required": ["workspaceId", "groupIds"], "properties": { "workspaceId": { @@ -5616,7 +5761,7 @@ "properties": { "ordinal": { "type": "integer", - "description": "The row\u2019s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments \u2014 use it to page straight to the match." + "description": "The row’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — use it to page straight to the match." }, "rowId": { "type": "string", @@ -5645,7 +5790,7 @@ }, "truncated": { "type": "boolean", - "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor \u2014 narrow the predicate instead of paging." + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor — narrow the predicate instead of paging." } } } @@ -5665,7 +5810,7 @@ }, "importId": { "type": "string", - "description": "Job id \u2014 pass to `POST /job/cancel` to stop the import." + "description": "Job id — pass to `POST /job/cancel` to stop the import." } } } @@ -5684,7 +5829,7 @@ "fileKey": { "type": "string", "minLength": 1, - "description": "Storage key of the uploaded file. Must sit under this workspace\u2019s `workspace/{workspaceId}/` prefix.", + "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.", "example": "workspace/ws_123/imports/contacts.csv" }, "fileName": { @@ -5698,7 +5843,7 @@ }, "mapping": { "type": "object", - "description": "CSV header \u2192 column name, or null to skip the header.", + "description": "CSV header → column name, or null to skip the header.", "additionalProperties": { "type": ["string", "null"] } @@ -5748,7 +5893,7 @@ }, "jobId": { "type": "string", - "description": "Job id \u2014 poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." + "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } @@ -5765,7 +5910,7 @@ "properties": { "url": { "type": "string", - "description": "Presigned URL. Expires shortly after issue \u2014 fetch it promptly." + "description": "Presigned URL. Expires shortly after issue — fetch it promptly." }, "fileName": { "type": "string", @@ -5832,7 +5977,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 the listing is bounded server-side to a single page." + "description": "Always null — the listing is bounded server-side to a single page." } } }, @@ -5867,7 +6012,7 @@ }, "canceled": { "type": "boolean", - "description": "False when the job had already finished. Cancelling is idempotent \u2014 a late request is not an error." + "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." } } } @@ -5885,7 +6030,7 @@ }, "scope": { "enum": ["all", "row"], - "description": "`all` cancels every running and pending cell; `row` cancels one row\u2019s cells." + "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." }, "rowId": { "type": "string", @@ -5941,7 +6086,7 @@ }, "rowsProcessed": { "type": "integer", - "description": "Rows handled so far \u2014 progress for a running job." + "description": "Rows handled so far — progress for a running job." }, "error": { "type": ["string", "null"], @@ -5951,7 +6096,7 @@ }, "WorkflowGroupOutputColumnInput": { "type": "object", - "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted \u2014 the server stamps it from the group being written.", + "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.", "required": ["name", "type"], "additionalProperties": false, "properties": { @@ -5985,7 +6130,7 @@ }, "group": { "type": "object", - "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` \u2014 the mismatch is a 400.", + "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.", "required": ["outputs"], "properties": { "id": { @@ -6009,7 +6154,7 @@ "type": "string", "enum": ["manual", "enrichment"], "default": "manual", - "description": "`manual` means workflow-backed \u2014 not hand-entered." + "description": "`manual` means workflow-backed — not hand-entered." }, "dependencies": { "type": "object", @@ -6050,13 +6195,13 @@ "autoRun": { "type": "boolean", "default": false, - "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) \u2014 on an API key this fans out a metered run per row. Prefer POST /columns/run." + "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run." } } }, "UpdateWorkflowGroupBody": { "type": "object", - "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** \u2014 the same behavior as DELETE /columns on a bound column. There is no detach.", + "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.", "required": ["workspaceId", "groupId"], "additionalProperties": false, "properties": { @@ -6179,6 +6324,32 @@ } } } + }, + "TablesFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { @@ -6302,7 +6473,7 @@ } }, "Conflict": { - "description": "The request conflicts with the current state of the resource \u2014 for example a rename to a name another table in the workspace already uses.", + "description": "The request conflicts with the current state of the resource — for example a rename to a name another table in the workspace already uses.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 40984613cbb..59faa644642 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -51,13 +51,12 @@ "$ref": "#/components/parameters/WorkspaceId" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Filter results to only include workflows within this folder.", "schema": { - "type": "string", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "type": "string" } }, { @@ -96,7 +95,11 @@ "in": "query", "required": false, "description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -114,7 +117,11 @@ "in": "query", "required": false, "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -150,24 +157,6 @@ "description": "Opaque cursor for fetching the next page. `null` when there are no more results." } } - }, - "example": { - "data": [ - { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" - } - ], - "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" } } } @@ -208,24 +197,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/CreateWorkflowBody" - }, - "examples": { - "minimal": { - "summary": "At the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Customer Support Agent" - } - }, - "inFolder": { - "summary": "Inside a folder, with a description", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" - } - } } } } @@ -254,21 +225,6 @@ "$ref": "#/components/schemas/WorkflowListItem" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-06-29T21:30:00.000Z", - "updatedAt": "2026-06-29T21:30:00.000Z" - } } } } @@ -347,36 +303,6 @@ "$ref": "#/components/schemas/WorkflowDetail" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "variables": { - "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { - "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", - "name": "supportEmail", - "type": "string", - "value": "support@example.com" - } - }, - "inputs": [ - { - "name": "ticketBody", - "type": "string", - "description": "The raw text of the incoming support ticket." - } - ], - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" - } } } } @@ -419,20 +345,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/UpdateWorkflowBody" - }, - "examples": { - "rename": { - "summary": "Rename", - "value": { - "name": "Customer Support Agent v2" - } - }, - "moveToRoot": { - "summary": "Move out of its folder to the workspace root", - "value": { - "folderId": null - } - } } } } @@ -461,21 +373,6 @@ "$ref": "#/components/schemas/WorkflowListItem" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent v2", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-06-29T21:30:00.000Z", - "updatedAt": "2026-06-30T08:12:00.000Z" - } } } } @@ -513,7 +410,7 @@ "delete": { "operationId": "deleteWorkflowV2", "summary": "Delete Workflow", - "description": "Archive a workflow. The workflow moves to Recently Deleted rather than being dropped, so its execution logs stay attributable, and it stops being returned by the list and detail endpoints. The last remaining workflow in a workspace cannot be deleted (400).", + "description": "Delete a workflow. It stops being returned by list and detail endpoints while its execution logs remain attributable. The last remaining workflow in a workspace cannot be deleted (400).", "tags": ["Workflows"], "x-codeSamples": [ { @@ -530,7 +427,7 @@ ], "responses": { "200": { - "description": "The workflow was archived.", + "description": "The workflow was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -552,12 +449,6 @@ "$ref": "#/components/schemas/DeleteWorkflowResult" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true - } } } } @@ -1178,9 +1069,6 @@ } } }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "400": { "$ref": "#/components/responses/BadRequest" }, @@ -1193,12 +1081,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, "409": { "description": "A workflow with the same name already exists and deduplication failed.", "content": { @@ -1209,8 +1091,17 @@ } } }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" } } } @@ -1431,12 +1322,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, "409": { "description": "The `X-Execution-Id` was already used.", "content": { @@ -1450,6 +1335,12 @@ "413": { "description": "Request body exceeds the 10 MB limit." }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, "503": { "description": "Execution infrastructure temporarily unavailable.", "content": { @@ -1766,6 +1657,398 @@ } } } + }, + "/api/v2/workflows/folders": { + "get": { + "operationId": "listWorkflowsFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createWorkflowsFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateWorkflowsFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteWorkflowsFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "workflows"], + "properties": { + "folders": { + "type": "integer" + }, + "workflows": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -1880,7 +2163,7 @@ "id", "name", "description", - "folderId", + "folderPath", "workspaceId", "isDeployed", "deployedAt", @@ -1906,11 +2189,10 @@ "description": "Optional description of what the workflow does. `null` when unset.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { + "folderPath": { "type": "string", - "nullable": true, - "description": "The folder this workflow belongs to. `null` when at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" }, "workspaceId": { "type": "string", @@ -1984,7 +2266,7 @@ "id", "name", "description", - "folderId", + "folderPath", "workspaceId", "isDeployed", "deployedAt", @@ -2131,7 +2413,7 @@ }, "workflow": { "type": "object", - "required": ["id", "name", "description", "workspaceId", "folderId"], + "required": ["id", "name", "description", "workspaceId", "folderPath"], "properties": { "id": { "type": "string" @@ -2145,8 +2427,9 @@ "workspaceId": { "type": ["string", "null"] }, - "folderId": { - "type": ["string", "null"] + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -2165,10 +2448,10 @@ "type": "string", "minLength": 1 }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, - "description": "Target folder; must belong to the workspace and be unlocked." + "description": "Canonical containing-folder path. `/` is the workspace root." }, "name": { "type": "string", @@ -2203,7 +2486,7 @@ "name", "description", "workspaceId", - "folderId", + "folderPath", "createdAt", "updatedAt" ], @@ -2221,8 +2504,9 @@ "workspaceId": { "type": "string" }, - "folderId": { - "type": ["string", "null"] + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." }, "createdAt": { "type": "string", @@ -2331,12 +2615,11 @@ "description": "Optional description of what the workflow does.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, - "nullable": true, - "description": "Folder to create the workflow in. Omit or send `null` to create it at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" } } }, @@ -2360,29 +2643,28 @@ "description": "New description. Send `null` to clear it.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, - "nullable": true, - "description": "Destination folder. Send `null` to move the workflow to the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" } } }, "DeleteWorkflowResult": { "type": "object", - "description": "Acknowledgement that a workflow was archived.", + "description": "Acknowledgement that a workflow was deleted.", "required": ["id", "deleted"], "properties": { "id": { "type": "string", - "description": "The archived workflow's identifier.", + "description": "The deleted workflow's identifier.", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "deleted": { "type": "boolean", "enum": [true], - "description": "Always `true` on a successful archive." + "description": "Always `true` on a successful delete." } } }, @@ -2483,6 +2765,36 @@ "description": "The deployed workflow graph snapshot (blocks, edges, loops, parallels). This is the state that executes while the version is active, and the state a rollback restores." } } + }, + "WorkflowsFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "locked", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "locked": { + "type": "boolean", + "description": "Whether this workflow folder is locked." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 9cd730013ae..0ffbe540e89 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -13,8 +13,8 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { DbOrTx } from '@/lib/db/types' -import { nextFolderSortOrder } from '@/lib/folders/lifecycle' import { deduplicateFolderName } from '@/lib/folders/naming' +import { nextFolderSortOrder } from '@/lib/folders/orchestration' import { toFolderApi } from '@/lib/folders/queries' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 0022d3e8c8c..f67b0f38a2c 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -5,7 +5,7 @@ import { restoreFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { restoreFolder } from '@/lib/folders/lifecycle' +import { restoreFolder } from '@/lib/folders/orchestration' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 25db5804c3c..035223d827a 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -8,8 +8,8 @@ import { authMockFns, createMockRequest, dbChainMockFns, - foldersLifecycleMock, - foldersLifecycleMockFns, + foldersOrchestrationMock, + foldersOrchestrationMockFns, type MockUser, permissionsMock, permissionsMockFns, @@ -34,8 +34,8 @@ const { mockLogger } = vi.hoisted(() => { } }) -const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder -const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder +const mockDeleteFolder = foldersOrchestrationMockFns.mockDeleteFolder +const mockUpdateFolder = foldersOrchestrationMockFns.mockUpdateFolder /** Parent ids the mocked engine treats as closing a cycle for the folder under test. */ const cyclicParentIds = new Set() @@ -49,7 +49,7 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock) +vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 176e43a9aba..682f9f40497 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -10,7 +10,7 @@ import { getSession } from '@/lib/auth' import { HttpError } from '@/lib/core/utils/http-error' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { folderResourceConfig } from '@/lib/folders/config' -import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' +import { deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { toFolderApi } from '@/lib/folders/queries' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 869aaa89f73..c9803b66fbb 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -31,21 +31,30 @@ describe('PUT /api/folders/reorder', () => { const mockFrom = vi.fn() const mockWhere = vi.fn() const mockTxUpdate = vi.fn() + const mockTxExecute = vi.fn() beforeEach(() => { vi.clearAllMocks() + mockFrom.mockReset() + mockWhere.mockReset() + mockTxUpdate.mockReset() + mockTxExecute.mockReset() + mockDb.transaction.mockReset() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDb.select.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockTxUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }) mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb({ update: mockTxUpdate }) + cb({ + execute: mockTxExecute, + select: vi.fn().mockReturnValue({ from: mockFrom }), + update: mockTxUpdate, + }) ) }) @@ -76,8 +85,8 @@ describe('PUT /api/folders/reorder', () => { ]) const uniqueViolation = Object.assign(new Error('duplicate key value'), { code: '23505' }) - mockDb.transaction.mockImplementationOnce(async () => { - throw uniqueViolation + mockTxUpdate.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(uniqueViolation) }), }) const req = createMockRequest('PUT', { @@ -88,6 +97,7 @@ describe('PUT /api/folders/reorder', () => { const response = await PUT(req) + expect(mockTxUpdate).toHaveBeenCalled() expect(response.status).toBe(409) const data = await response.json() expect(data.error).toBe('A folder with this name already exists in this location') @@ -108,7 +118,7 @@ describe('PUT /api/folders/reorder', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Parent folder not found') - expect(mockDb.transaction).not.toHaveBeenCalled() + expect(mockTxUpdate).not.toHaveBeenCalled() }) it('rejects a batch that would form a cycle', async () => { @@ -139,6 +149,6 @@ describe('PUT /api/folders/reorder', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Cannot create circular folder reference') - expect(mockDb.transaction).not.toHaveBeenCalled() + expect(mockTxUpdate).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index b27dbdabe37..dd644402927 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,4 +1,3 @@ -import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' @@ -10,7 +9,9 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withTransactionRetry } from '@/lib/db/transaction' import { folderResourceConfig } from '@/lib/folders/config' +import { acquireFolderMutationLock } from '@/lib/folders/locks' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') @@ -37,136 +38,146 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Write access required' }, { status: 403 }) } - const folderIds = updates.map((u) => u.id) - /** - * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes - * them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a - * guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment - * its parent is deleted. Reordering an archived folder is also a correctness problem in its - * own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out - * of an archived subtree silently drops it from that folder's restore. - */ - const existingFolders = await db - .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) - .from(folderTable) - .where( - and( - inArray(folderTable.id, folderIds), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) + return await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) + const folderIds = updates.map((u) => u.id) + /** + * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` + * excludes them: lock resolution skips archived rows, so an archived-but-locked + * folder would otherwise become mutable while its cascade is still recoverable. + */ + const existingFolders = await tx + .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + + const validIds = new Set( + existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) ) - ) + const validUpdates = updates.filter((u) => validIds.has(u.id)) - const validIds = new Set( - existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) - ) + if (validUpdates.length === 0) { + return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) + } - const validUpdates = updates.filter((u) => validIds.has(u.id)) + const targetParentIds = Array.from( + new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) + ) - if (validUpdates.length === 0) { - return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) - } + if (targetParentIds.length > 0) { + const parentFolders = await tx + .select({ + id: folderTable.id, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, + }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, targetParentIds), + eq(folderTable.resourceType, resourceType) + ) + ) - const targetParentIds = Array.from( - new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) - ) + const validParentIds = new Set( + parentFolders + .filter((f) => f.workspaceId === workspaceId && !f.archivedAt) + .map((f) => f.id) + ) - if (targetParentIds.length > 0) { - const parentFolders = await db - .select({ - id: folderTable.id, - workspaceId: folderTable.workspaceId, - archivedAt: folderTable.deletedAt, - }) - .from(folderTable) - .where( - and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, resourceType)) - ) + for (const update of validUpdates) { + if (!update.parentId) continue + if (update.parentId === update.id) { + return NextResponse.json( + { error: 'Folder cannot be its own parent' }, + { status: 400 } + ) + } + if (!validParentIds.has(update.parentId)) { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + } + } + } - const validParentIds = new Set( - parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) - ) + const workspaceFolders = await tx + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType) + ) + ) - for (const update of validUpdates) { - if (!update.parentId) continue - if (update.parentId === update.id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) + const parentById = new Map() + for (const folder of workspaceFolders) { + parentById.set(folder.id, folder.parentId) } - if (!validParentIds.has(update.parentId)) { - return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + for (const update of validUpdates) { + if (update.parentId !== undefined) { + parentById.set(update.id, update.parentId || null) + } } - } - } - - const workspaceFolders = await db - .select({ id: folderTable.id, parentId: folderTable.parentId }) - .from(folderTable) - .where( - and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, resourceType)) - ) - const parentById = new Map() - for (const folder of workspaceFolders) { - parentById.set(folder.id, folder.parentId) - } - for (const update of validUpdates) { - if (update.parentId !== undefined) { - parentById.set(update.id, update.parentId || null) - } - } - - for (const update of validUpdates) { - const visited = new Set() - let cursor: string | null = update.id - while (cursor) { - if (visited.has(cursor)) { - return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } - ) + for (const update of validUpdates) { + const visited = new Set() + let cursor: string | null = update.id + while (cursor) { + if (visited.has(cursor)) { + return NextResponse.json( + { error: 'Cannot create circular folder reference' }, + { status: 400 } + ) + } + visited.add(cursor) + cursor = parentById.get(cursor) ?? null + } } - visited.add(cursor) - cursor = parentById.get(cursor) ?? null - } - } - // Folder locking is a workflow-only feature; other resource types leave `locked` false. - if (folderResourceConfig(resourceType).supportsLocking) { - for (const update of validUpdates) { - await assertFolderMutable(update.id) - if (update.parentId !== undefined) { - await assertFolderMutable(update.parentId) + if (folderResourceConfig(resourceType).supportsLocking) { + for (const update of validUpdates) { + await assertFolderMutable(update.id) + if (update.parentId !== undefined) { + await assertFolderMutable(update.parentId) + } + } } - } - } - await db.transaction(async (tx) => { - for (const update of validUpdates) { - const updateData: Partial = { - sortOrder: update.sortOrder, - updatedAt: new Date(), - } - if (update.parentId !== undefined) { - updateData.parentId = update.parentId || null - } - await tx - .update(folderTable) - .set(updateData) - .where( - and( - eq(folderTable.id, update.id), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) + for (const update of validUpdates) { + const updateData: Partial = { + sortOrder: update.sortOrder, + updatedAt: new Date(), + } + if (update.parentId !== undefined) { + updateData.parentId = update.parentId || null + } + await tx + .update(folderTable) + .set(updateData) + .where( + and( + eq(folderTable.id, update.id), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) ) - ) - } - }) + } - logger.info( - `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` - ) + logger.info( + `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` + ) - return NextResponse.json({ success: true, updated: validUpdates.length }) + return NextResponse.json({ success: true, updated: validUpdates.length }) + }, + { label: 'reorder-folders' } + ) } catch (error) { if (error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index bc6e72de1f0..65895a1d9b7 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -55,29 +55,36 @@ interface CapturedFolderValues { function createMockTransaction(mockData: { selectResults?: Array> insertResult?: Array<{ id: string; [key: string]: unknown }> + insertError?: Error onInsertValues?: (values: CapturedFolderValues) => void }) { - const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData + const { selectResults = [[], []], insertResult = [], insertError, onInsertValues } = mockData return async (callback: (tx: unknown) => Promise) => { const where = vi.fn() for (const result of selectResults) { - where.mockReturnValueOnce(result) + const withLimit = result as typeof result & { limit: ReturnType } + withLimit.limit = vi.fn().mockReturnValue(result) + where.mockReturnValueOnce(withLimit) } where.mockReturnValue([]) const tx = { + execute: vi.fn(), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where, }), }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values: CapturedFolderValues) => { - onInsertValues?.(values) - return { - returning: vi.fn().mockReturnValue(insertResult), - } - }), + insert: vi.fn().mockImplementation(() => { + if (insertError) throw insertError + return { + values: vi.fn().mockImplementation((values: CapturedFolderValues) => { + onInsertValues?.(values) + return { + returning: vi.fn().mockReturnValue(insertResult), + } + }), + } }), } return await callback(tx) @@ -160,6 +167,7 @@ describe('Folders API Route', () => { mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) mockReturning.mockReturnValue([mockFolders[0]]) + mockTransaction.mockImplementation(createMockTransaction({})) mockGetUserEntityPermissions.mockResolvedValue('admin') }) @@ -363,7 +371,7 @@ describe('Folders API Route', () => { mockTransaction.mockImplementationOnce( createMockTransaction({ - selectResults: [[], []], + selectResults: [[{ workspaceId: 'workspace-123', archivedAt: null }], [], []], insertResult: [{ ...mockFolders[1] }], }) ) @@ -530,9 +538,9 @@ describe('Folders API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockInsert.mockImplementationOnce(() => { - throw new Error('Database insert failed') - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ insertError: new Error('Database insert failed') }) + ) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 84eba3c8cab..1ac1c31e9f1 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { folderResourceConfig } from '@/lib/folders/config' -import { createFolder } from '@/lib/folders/lifecycle' +import { createFolder } from '@/lib/folders/orchestration' import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 93627bc4105..4182a4ea4a1 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -37,7 +37,6 @@ export type ApiEndpoint = | 'audit-logs' | 'tables' | 'table-detail' - | 'table-restore' | 'table-rows' | 'table-row-detail' | 'table-rows-find' @@ -54,7 +53,7 @@ export type ApiEndpoint = | 'file-share' | 'file-content' | 'file-move' - | 'file-bulk-archive' + | 'file-bulk-delete' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' @@ -66,8 +65,6 @@ export type ApiEndpoint = | 'skill-detail' | 'custom-tools' | 'custom-tool-detail' - | 'folders' - | 'folder-detail' | 'credentials' | 'credential-detail' diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index bef7e2d43ce..65a270342fa 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -9,9 +9,9 @@ import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' @@ -69,7 +69,7 @@ export const GET = withRouteHandler( if (!log) return v2Error('NOT_FOUND', 'Audit log not found') - return v2Data(formatAuditLogEntry(log), { rateLimit }) + return v2Data(formatV2AuditLogEntry(log), { rateLimit }) } catch (error) { logger.error(`[${requestId}] Audit log detail fetch error`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/audit-logs/format.test.ts b/apps/sim/app/api/v2/audit-logs/format.test.ts new file mode 100644 index 00000000000..cece8dd51df --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' + +describe('formatV2AuditLogEntry', () => { + it('removes internal folder identifiers recursively from the public projection', () => { + const formatted = formatV2AuditLogEntry({ + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'user-1', + actorName: 'Teddy', + actorEmail: 'teddy@example.com', + action: 'folder.moved', + resourceType: 'folder', + resourceId: 'internal-folder-id', + resourceName: 'Reports', + description: 'Moved Reports', + metadata: { + folderId: 'internal-folder-id', + targetFolderId: 'internal-target-id', + nested: { tableImportFolderId: 'internal-import-id', path: '/Reports' }, + }, + createdAt: new Date('2024-01-01T00:00:00Z'), + }) + + expect(formatted.resourceId).toBeNull() + expect(formatted.metadata).toEqual({ nested: { path: '/Reports' } }) + }) +}) diff --git a/apps/sim/app/api/v2/audit-logs/format.ts b/apps/sim/app/api/v2/audit-logs/format.ts new file mode 100644 index 00000000000..b1e7fb88b7c --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/format.ts @@ -0,0 +1,57 @@ +import type { auditLog } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import type { InferSelectModel } from 'drizzle-orm' + +type DbAuditLog = Pick< + InferSelectModel, + | 'id' + | 'workspaceId' + | 'actorId' + | 'actorName' + | 'actorEmail' + | 'action' + | 'resourceType' + | 'resourceId' + | 'resourceName' + | 'description' + | 'metadata' + | 'createdAt' +> + +const INTERNAL_FOLDER_ID_KEYS = new Set([ + 'folderId', + 'folderIds', + 'parentId', + 'tableImportFolderId', + 'targetFolderId', +]) + +function sanitizeMetadata(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeMetadata) + if (!isRecordLike(value)) return value + + const sanitized: Record = {} + for (const [key, child] of Object.entries(value)) { + if (INTERNAL_FOLDER_ID_KEYS.has(key)) continue + sanitized[key] = sanitizeMetadata(child) + } + return sanitized +} + +/** Removes database folder identifiers from the public v2 audit projection. */ +export function formatV2AuditLogEntry(log: DbAuditLog) { + return { + id: log.id, + workspaceId: log.workspaceId, + actorId: log.actorId, + actorName: log.actorName, + actorEmail: log.actorEmail, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceType === 'folder' ? null : log.resourceId, + resourceName: log.resourceName, + description: log.description, + metadata: sanitizeMetadata(log.metadata), + createdAt: log.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index a264e5f47a4..32ef339a8f9 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -6,7 +6,6 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildFilterConditions, buildOrgScopeCondition, @@ -14,6 +13,7 @@ import { queryAuditLogs, } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, @@ -97,7 +97,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { params.cursor ) - return v2CursorList(data.map(formatAuditLogEntry), nextCursor ?? null, { rateLimit }) + return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) } catch (error) { logger.error(`[${requestId}] Audit logs fetch error`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 860002a33e5..3ce03cf3cdd 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -176,8 +176,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { size: 8, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index cfabd3446dd..309bb0b34c1 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -107,8 +107,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { size: 1024, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts deleted file mode 100644 index 0610ef9649e..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformRestore: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performRestoreWorkspaceFile: mockPerformRestore, -})) - -import { POST } from '@/app/api/v2/files/[fileId]/restore/route' - -const WS = 'workspace-1' -const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const callRestore = (body: unknown) => - POST( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/restore`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - { params: Promise.resolve({ fileId: FILE_ID }) } - ) - -describe('POST /api/v2/files/[fileId]/restore', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRestore.mockResolvedValue({ success: true }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callRestore({ workspaceId: WS }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callRestore({}) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callRestore({ workspaceId: WS }) - expect(res.status).toBe(403) - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callRestore({ workspaceId: WS }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('restores the file and acknowledges', async () => { - const res = await callRestore({ workspaceId: WS }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ id: FILE_ID, restored: true }) - expect(mockPerformRestore).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - }) - }) - - it('maps a not_found errorCode to 404 rather than a blanket 500', async () => { - mockPerformRestore.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) - - const res = await callRestore({ workspaceId: WS }) - const body = await res.json() - - expect(res.status).toBe(404) - expect(body.error.code).toBe('NOT_FOUND') - expect(body.error.message).toBe('File not found') - }) -}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts deleted file mode 100644 index 0b559b9ed90..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2RestoreFileContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileRestoreAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - -/** - * POST /api/v2/files/[fileId]/restore — Restore an archived file. - * - * Find archived ids with `GET /api/v2/files?scope=archived`. A name collision - * with a live file is resolved by the manager's restore-name suffix, so the - * restored file may come back under a different name. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RestoreFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performRestoreWorkspaceFile({ workspaceId, fileId, userId }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to restore file') - ) - } - - return v2Data({ id: fileId, restored: true as const }, { rateLimit }) - } catch (error) { - logger.error('Error restoring file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 1afe6c6b97b..eb0f2b3738a 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -217,8 +217,7 @@ describe('PATCH /api/v2/files/[fileId]', () => { size: 1024, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index e85ccd4b923..133d5debeb7 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -129,7 +129,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File }) /** - * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. + * DELETE /api/v2/files/[fileId] — Delete a file. * * Delegates to the shared orchestration, which is workspace-scoped and records * its own audit entry (the request is forwarded so that entry captures client @@ -171,7 +171,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil ) } - logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) + logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) } catch (error) { diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts similarity index 77% rename from apps/sim/app/api/v2/files/bulk-archive/route.test.ts rename to apps/sim/app/api/v2/files/bulk-delete/route.test.ts index 9d4982abc62..31795501de6 100644 --- a/apps/sim/app/api/v2/files/bulk-archive/route.test.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({ performDeleteWorkspaceFileItems: mockPerformDelete, })) -import { POST } from '@/app/api/v2/files/bulk-archive/route' +import { POST } from '@/app/api/v2/files/bulk-delete/route' const WS = 'workspace-1' @@ -44,16 +44,16 @@ const RATE_LIMIT_DENIED = { retryAfterMs: 1000, } -const callArchive = (body: unknown) => +const callDelete = (body: unknown) => POST( - new NextRequest('http://localhost:3000/api/v2/files/bulk-archive', { + new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) ) -describe('POST /api/v2/files/bulk-archive', () => { +describe('POST /api/v2/files/bulk-delete', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) @@ -66,14 +66,14 @@ describe('POST /api/v2/files/bulk-archive', () => { const { v2Error } = await import('@/app/api/v2/lib/response') vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(404) expect(mockPerformDelete).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { - const res = await callArchive({ workspaceId: WS, fileIds: [], folderIds: [] }) + const res = await callDelete({ workspaceId: WS, fileIds: [] }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') expect(mockPerformDelete).not.toHaveBeenCalled() @@ -85,29 +85,28 @@ describe('POST /api/v2/files/bulk-archive', () => { code: 'FORBIDDEN', message: 'Access denied', }) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) expect(mockPerformDelete).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('archives the selection and reports the full cascade', async () => { - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'], folderIds: ['fold_1'] }) + it('deletes the selection and reports the file count', async () => { + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) const body = await res.json() expect(res.status).toBe(200) - expect(body.data).toEqual({ deletedItems: { files: 3, folders: 1 } }) + expect(body.data).toEqual({ deletedItems: { files: 3 } }) expect(mockPerformDelete).toHaveBeenCalledWith({ workspaceId: WS, userId: 'user-1', fileIds: ['wf_1'], - folderIds: ['fold_1'], request: expect.anything(), }) }) @@ -119,7 +118,7 @@ describe('POST /api/v2/files/bulk-archive', () => { errorCode: 'not_found', }) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_missing'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts similarity index 66% rename from apps/sim/app/api/v2/files/bulk-archive/route.ts rename to apps/sim/app/api/v2/files/bulk-delete/route.ts index 3f8ff8c256f..e0bd99a4b89 100644 --- a/apps/sim/app/api/v2/files/bulk-archive/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2BulkArchiveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,21 +17,18 @@ import { v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileBulkArchiveAPI') +const logger = createLogger('V2FileBulkDeleteAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * POST /api/v2/files/bulk-archive — Archive (soft delete) files and folders. - * - * Archiving a folder cascades to its descendants; `deletedItems` reports the - * totals actually archived, which therefore exceed the selection size. Archived - * items stay listable via `scope=archived` and can be restored. + * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by + * `/api/v2/files/folders` so this resource operation never accepts folder ids. */ export const POST = withRouteHandler(async (request: NextRequest) => { try { - const rateLimit = await checkRateLimit(request, 'file-bulk-archive') + const rateLimit = await checkRateLimit(request, 'file-bulk-delete') if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! @@ -40,14 +37,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (gate) return gate const parsed = await parseRequest( - v2BulkArchiveFileItemsContract, + v2BulkDeleteFilesContract, request, {}, { validationErrorResponse: v2ValidationError } ) if (!parsed.success) return parsed.response - const { workspaceId, fileIds, folderIds } = parsed.data.body + const { workspaceId, fileIds } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -56,20 +53,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, fileIds, - folderIds, request, }) if (!result.success || !result.deletedItems) { return v2ErrorForOrchestration( result.errorCode, - messageForOrchestrationError(result, 'Failed to archive file items') + messageForOrchestrationError(result, 'Failed to delete files') ) } - return v2Data({ deletedItems: result.deletedItems }, { rateLimit }) + return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) } catch (error) { - logger.error('Error archiving file items', { error: getErrorMessage(error, 'Unknown error') }) + logger.error('Error deleting files', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') } }) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts new file mode 100644 index 00000000000..12b73d316f1 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -0,0 +1,172 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateFileFolderContract, + v2DeleteFileFolderContract, + v2ListFileFoldersContract, + v2RelocateFileFolderContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { toFolderPathView } from '@/lib/folders/paths' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, + performRelocateWorkspaceFileFolderByPath, +} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListFileFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'file') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'file', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing file folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + return v2Data( + { folder: toFolderPathView(result.folder, result.path) }, + { rateLimit, status: 201 } + ) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performRelocateWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data({ path, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) +}) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts index 02c232ba4a7..b6651476b8f 100644 --- a/apps/sim/app/api/v2/files/move/route.test.ts +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -101,25 +101,24 @@ describe('POST /api/v2/files/move', () => { const res = await callMove({ workspaceId: WS, fileIds: ['wf_1', 'wf_2'], - targetFolderId: 'fold_1', + targetFolderPath: '/Reports', }) const body = await res.json() expect(res.status).toBe(200) - expect(body.data).toEqual({ movedItems: { files: 2, folders: 0 } }) + expect(body.data).toEqual({ movedItems: { files: 2 } }) expect(mockPerformMove).toHaveBeenCalledWith({ workspaceId: WS, userId: 'user-1', fileIds: ['wf_1', 'wf_2'], - folderIds: [], - targetFolderId: 'fold_1', + targetFolderPath: '/Reports', }) }) - it('treats an omitted targetFolderId as the workspace root', async () => { - await callMove({ workspaceId: WS, folderIds: ['fold_2'] }) + it('treats an omitted targetFolderPath as the workspace root', async () => { + await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(mockPerformMove).toHaveBeenCalledWith( - expect.objectContaining({ folderIds: ['fold_2'], targetFolderId: null }) + expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' }) ) }) @@ -130,7 +129,11 @@ describe('POST /api/v2/files/move', () => { errorCode: 'conflict', }) - const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'], targetFolderId: 'fold_1' }) + const res = await callMove({ + workspaceId: WS, + fileIds: ['wf_1'], + targetFolderPath: '/Reports', + }) const body = await res.json() expect(res.status).toBe(409) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index bd48c7c55e7..fa4f8b0053c 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -23,9 +23,9 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * POST /api/v2/files/move — Move files and/or folders into a folder. + * POST /api/v2/files/move — Move files into a folder. * - * `targetFolderId: null` (or an omitted field) moves the selection to the + * An omitted `targetFolderPath` moves the selection to the * workspace root. The whole selection moves under one advisory lock, so a name * collision at the destination fails the request as `CONFLICT` rather than * partially applying. @@ -48,7 +48,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, fileIds, folderIds, targetFolderId } = parsed.data.body + const { workspaceId, fileIds, targetFolderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -57,8 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, fileIds, - folderIds, - targetFolderId: targetFolderId ?? null, + targetFolderPath: targetFolderPath ?? '/', }) if (!result.success || !result.movedItems) { @@ -68,7 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - return v2Data({ movedItems: result.movedItems }, { rateLimit }) + return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) } catch (error) { logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 31d0eea1b59..d4e4cee44ed 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -1,7 +1,5 @@ /** * @vitest-environment node - * - * Public v2 files list: gate ordering and the `scope` split that makes Recently Deleted reachable. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,12 +10,14 @@ const { mockQueryWorkspaceFiles, mockResolveWorkspaceAccess, mockV2ApiGateError, + mockLoadActiveFolderPathIndex, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockPerformCreateWorkspaceFile: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockQueryWorkspaceFiles: vi.fn(), mockV2ApiGateError: vi.fn().mockResolvedValue(null), + mockLoadActiveFolderPathIndex: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -33,6 +33,10 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ queryWorkspaceFiles: mockQueryWorkspaceFiles, })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, @@ -82,7 +86,6 @@ function buildRecord(overrides: Record = {}) { /** What the route forwards for a bare `?workspaceId=` list. */ const DEFAULT_LIST_ARGS = { - scope: 'active', folderId: undefined, search: undefined, sortBy: 'uploadedAt', @@ -108,6 +111,14 @@ describe('GET /api/v2/files', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), + pathById: new Map([['fold_1', '/Reports']]), + idByPath: new Map([ + ['/Reports', 'fold_1'], + ['/Fixtures', 'fold_1'], + ]), + }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -171,8 +182,7 @@ describe('GET /api/v2/files', () => { size: 1024, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: FOLDER_ID, - folderPath: 'Reports/Q1', + folderPath: '/Reports/Q1', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', @@ -181,26 +191,17 @@ describe('GET /api/v2/files', () => { expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) }) - it('defaults to the active scope and passes archived through', async () => { + it('lists active files only and rejects the removed archived scope', async () => { await callList(`workspaceId=${WS}`) expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) - const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) - mockQueryWorkspaceFiles.mockResolvedValue({ files: [archived], nextKeys: null }) - const res = await callList(`workspaceId=${WS}&scope=archived`) - const body = await res.json() - - expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - scope: 'archived', - }) - expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) + expect(res.status).toBe(400) }) it('forwards search, folder, and sort into the query rather than filtering the result', async () => { await callList( - `workspaceId=${WS}&search=report&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc` ) expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { @@ -212,6 +213,15 @@ describe('GET /api/v2/files', () => { }) }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: null, + }) + }) + it('400s on a sort field outside the enum instead of passing it toward the query', async () => { const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) @@ -322,7 +332,7 @@ describe('POST /api/v2/files', () => { userId: 'user-1', name: 'untitled.md', contentType: 'text/markdown', - folderId: undefined, + folderPath: '/', content: Buffer.alloc(0), exactName: true, request, @@ -344,7 +354,7 @@ describe('POST /api/v2/files', () => { workspaceId: WS, name: 'seed.bin', contentType: 'application/octet-stream', - folderId: FOLDER_ID, + folderPath: '/Fixtures', content: Buffer.from([1, 2, 3]).toString('base64'), encoding: 'base64', }) @@ -357,7 +367,7 @@ describe('POST /api/v2/files', () => { workspaceId: WS, name: 'seed.bin', contentType: 'application/octet-stream', - folderId: FOLDER_ID, + folderPath: '/Fixtures', content: Buffer.from([1, 2, 3]), exactName: true, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index cc2e0177832..09ba08501d3 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -9,6 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -17,6 +18,7 @@ import { } from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' +import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, @@ -42,9 +44,6 @@ export const revalidate = 0 * GET /api/v2/files — List files in a workspace with search, sort, and cursor * pagination. * - * `scope=archived` reads Recently Deleted, which is what makes the restore - * endpoints usable — a caller can find the id of something it deleted. - * * Filtering, ordering, and the page slice all run inside * {@link queryWorkspaceFiles}' query. The route only translates the validated * params and the opaque cursor, so a `search` never costs a full-workspace read. @@ -69,18 +68,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, scope, folderId, search, sortBy, sortOrder, limit, cursor } = - parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const sort = cursorSortKey(sortBy, sortOrder) const decoded = decodeSortedCursor(cursor, sort) if (decoded.status === 'invalid') return v2CursorSortError() const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - scope, folderId, search, sortBy, @@ -129,7 +133,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : parsed.response } - const { workspaceId, name, contentType, folderId, content, encoding } = parsed.data.body + const { workspaceId, name, contentType, folderPath, content, encoding } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -138,7 +142,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, name, contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderId, + folderPath: folderPath ?? '/', content: Buffer.from(content, encoding), exactName: true, request, diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 8065f99830b..25f2a733ed0 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -7,13 +7,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, mockResolveWorkspaceAccess, - mockAssertFolder, mockCreateUploadSession, + mockLoadActiveFolderPathIndex, + mockWithFolderTreeLock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), - mockAssertFolder: vi.fn(), mockCreateUploadSession: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockWithFolderTreeLock: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -25,8 +27,12 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - assertWorkspaceFileFolderTarget: mockAssertFolder, +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/folders/locks', () => ({ + withFolderTreeLock: mockWithFolderTreeLock, })) vi.mock('@/lib/uploads/upload-session/service', () => ({ @@ -44,6 +50,41 @@ const RATE_LIMIT = { remaining: 99, resetAt: new Date('2026-08-03T22:00:00.000Z'), } +const UPLOAD_SESSION = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: `${WORKSPACE_ID}/file.csv`, + finalKey: `${WORKSPACE_ID}/file.csv`, + stagingKey: 'upload-sessions/upload-1/file.csv', + storageProvider: 's3', + providerUploadId: null, + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + partSize: null, + partCount: null, + status: 'uploading', + uploadToken: 'signed-upload-token', + metadata: {}, + completedFileId: null, + error: null, + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + createdAt: new Date('2026-08-03T21:00:00.000Z'), + updatedAt: new Date('2026-08-03T21:00:00.000Z'), + completedAt: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'text/csv' }, + }, +} function request(body: Record) { return POST( @@ -60,42 +101,15 @@ describe('POST /api/v2/files/uploads', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockAssertFolder.mockResolvedValue(null) - mockCreateUploadSession.mockResolvedValue({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: null, - workflowId: null, - executionId: null, - purpose: 'workspace_file', - method: 'put', - storageContext: 'workspace', - storageKey: `${WORKSPACE_ID}/file.csv`, - finalKey: `${WORKSPACE_ID}/file.csv`, - stagingKey: 'upload-sessions/upload-1/file.csv', - storageProvider: 's3', - providerUploadId: null, - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - partSize: null, - partCount: null, - status: 'uploading', - uploadToken: 'signed-upload-token', - metadata: {}, - completedFileId: null, - error: null, - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - createdAt: new Date('2026-08-03T21:00:00.000Z'), - updatedAt: new Date('2026-08-03T21:00:00.000Z'), - completedAt: null, - transfer: { - method: 'put', - url: 'https://storage.example/upload', - headers: { 'content-type': 'text/csv' }, - }, + mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => + operation({}) + ) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map([['/Reports', 'folder-reports']]), }) + mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION) }) it('creates one signed PUT session for a small file', async () => { @@ -144,7 +158,7 @@ describe('POST /api/v2/files/uploads', () => { }) expect(response.status).toBe(403) - expect(mockAssertFolder).not.toHaveBeenCalled() + expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled() expect(mockCreateUploadSession).not.toHaveBeenCalled() }) @@ -161,4 +175,38 @@ describe('POST /api/v2/files/uploads', () => { expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) ) }) + + it('releases the folder tree lock before creating an upload session', async () => { + let lockHeld = false + mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => { + lockHeld = true + try { + return await operation({}) + } finally { + lockHeld = false + } + }) + mockCreateUploadSession.mockImplementationOnce(async () => { + expect(lockHeld).toBe(false) + return UPLOAD_SESSION + }) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + folderPath: '/Reports', + }) + + expect(response.status).toBe(201) + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + WORKSPACE_ID, + 'file', + expect.any(Object) + ) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + ) + }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 10cc3089ef2..b57f128b19a 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -4,10 +4,10 @@ import type { NextRequest } from 'next/server' import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { createUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, @@ -37,11 +37,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { workspaceId, name, contentType, size, folderId } = parsed.data.body + const { workspaceId, name, contentType, size, folderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) - + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'file', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') const session = await createUploadSession({ workspaceId, userId, @@ -49,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileName: name, contentType, fileSize: size, - metadata: { folderId: normalizedFolderId }, + metadata: { folderId: resolution.folderId }, localOrigin: request.nextUrl.origin, }) return v2Data( diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index c49966ce7f2..f5003680ad4 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,4 +1,5 @@ import type { V2File } from '@/lib/api/contracts/v2/files' +import { buildFolderPath } from '@/lib/folders/paths' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' /** Shared serialization for the v2 files surface. */ @@ -8,14 +9,22 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' * supplied it) and the internal storage/versioning columns are not exposed. */ export function toV2File(record: WorkspaceFileRecord): V2File { + const folderPath = record.folderId + ? buildFolderPath( + (() => { + if (!record.folderPath) throw new Error('File references an unresolved folder') + return record.folderPath.split('/') + })() + ) + : '/' + return { id: record.id, name: record.name, size: record.size, type: record.type, key: record.key, - folderId: record.folderId ?? null, - folderPath: record.folderPath ?? null, + folderPath, uploadedBy: record.uploadedBy, uploadedAt: record.uploadedAt.toISOString(), updatedAt: record.updatedAt.toISOString(), diff --git a/apps/sim/app/api/v2/folders/[id]/route.test.ts b/apps/sim/app/api/v2/folders/[id]/route.test.ts deleted file mode 100644 index 6ab9c1d3957..00000000000 --- a/apps/sim/app/api/v2/folders/[id]/route.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 folder detail: the archived-row split between PATCH and DELETE, the - * admin gate on `locked`, and the 423 a mutation lock produces. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockFindActiveFolder, - mockFindFolderInWorkspace, - mockUpdateFolder, - mockDeleteFolder, - mockAssertFolderMutable, - FolderLockedErrorMock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockFindActiveFolder: vi.fn(), - mockFindFolderInWorkspace: vi.fn(), - mockUpdateFolder: vi.fn(), - mockDeleteFolder: vi.fn(), - mockAssertFolderMutable: vi.fn(), - FolderLockedErrorMock: class FolderLockedError extends Error { - status = 423 - }, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/folders/queries', () => ({ - findActiveFolder: mockFindActiveFolder, - findFolderInWorkspace: mockFindFolderInWorkspace, -})) - -vi.mock('@/lib/folders/lifecycle', () => ({ - updateFolder: mockUpdateFolder, - deleteFolder: mockDeleteFolder, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mockAssertFolderMutable, - FolderLockedError: FolderLockedErrorMock, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { DELETE, GET, PATCH } from '@/app/api/v2/folders/[id]/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -function buildRow(overrides: Record = {}) { - return { - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - userId: 'user-1', - workspaceId: 'workspace-1', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - deletedAt: null, - ...overrides, - } -} - -const routeContext = () => ({ params: Promise.resolve({ id: 'fld_abc123' }) }) -const url = (query = 'workspaceId=workspace-1&resourceType=workflow') => - `http://localhost:3000/api/v2/folders/fld_abc123?${query}` - -const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) -const callDelete = (query?: string) => - DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/folders/fld_abc123', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() - ) -} - -describe('GET /api/v2/folders/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockFindFolderInWorkspace.mockResolvedValue(buildRow()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() - }) - - it('400s when resourceType is missing', async () => { - const res = await callGet('workspaceId=workspace-1') - expect(res.status).toBe(400) - expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(403) - expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the folder is not in this workspace tree', async () => { - mockFindFolderInWorkspace.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the public folder shape', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.folder).toEqual({ - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }) - expect(mockFindFolderInWorkspace).toHaveBeenCalledWith('fld_abc123', 'workspace-1', 'workflow') - }) -}) - -describe('PATCH /api/v2/folders/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockFindActiveFolder.mockResolvedValue(buildRow()) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockUpdateFolder.mockResolvedValue({ success: true, folder: buildRow({ name: 'Renamed' }) }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - - expect(res.status).toBe(404) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow' }) - expect(res.status).toBe(400) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(403) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('requires only write permission for an ordinary rename', async () => { - await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', name: 'Renamed' }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'write' - ) - }) - - it('escalates to admin when locked is being set', async () => { - await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', locked: true }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'admin' - ) - }) - - it('400s when locked is sent for a tree that does not support locking', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'table', - locked: true, - }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('workflow folders') - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('404s on an archived folder so a locked subtree cannot be edited through it', async () => { - mockFindActiveFolder.mockResolvedValue(null) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(404) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('423s when a mutation lock blocks the change', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('updates the folder and returns the public shape', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.folder.name).toBe('Renamed') - expect(mockUpdateFolder).toHaveBeenCalledWith( - expect.objectContaining({ - resourceType: 'workflow', - folderId: 'fld_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'Renamed', - }) - ) - }) -}) - -describe('DELETE /api/v2/folders/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockFindFolderInWorkspace.mockResolvedValue(buildRow()) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockDeleteFolder.mockResolvedValue({ - success: true, - deletedItems: { folders: 2, workflows: 5 }, - }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('400s when resourceType is missing', async () => { - const res = await callDelete('workspaceId=workspace-1') - expect(res.status).toBe(400) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(403) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the folder is not in this workspace tree', async () => { - mockFindFolderInWorkspace.mockResolvedValue(null) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('423s when a mutation lock blocks the delete', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callDelete() - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('deletes the folder and reports the cascade counts', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - data: { id: 'fld_abc123', deleted: true, deletedItems: { folders: 2, workflows: 5 } }, - }) - expect(mockDeleteFolder).toHaveBeenCalledWith( - expect.objectContaining({ - resourceType: 'workflow', - folderId: 'fld_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - folderName: 'Onboarding', - }) - ) - }) -}) diff --git a/apps/sim/app/api/v2/folders/[id]/route.ts b/apps/sim/app/api/v2/folders/[id]/route.ts deleted file mode 100644 index b05d090d600..00000000000 --- a/apps/sim/app/api/v2/folders/[id]/route.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { - v2DeleteFolderContract, - v2GetFolderContract, - v2UpdateFolderContract, -} from '@/lib/api/contracts/v2/folders' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { folderResourceConfig } from '@/lib/folders/config' -import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' -import { findActiveFolder, findFolderInWorkspace } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2Folder, v2FolderMutationError } from '@/app/api/v2/folders/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FolderDetailAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface RouteContext { - params: Promise<{ id: string }> -} - -/** GET /api/v2/folders/[id] — Fetch a single folder, archived or live. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, resourceType } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folder = await findFolderInWorkspace(id, workspaceId, resourceType) - if (!folder) return v2Error('NOT_FOUND', 'Folder not found') - - return v2Data({ folder: toV2Folder(folder) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** PATCH /api/v2/folders/[id] — Rename, move, reorder, or lock a folder. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, resourceType, name, locked, parentId, sortOrder } = parsed.data.body - - /** - * Setting `locked` is an admin capability, matching the UI; every other - * field needs only workspace write. - */ - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workspaceId, - locked === undefined ? 'write' : 'admin' - ) - if (access) return v2WorkspaceAccessError(access) - - /** - * Archived folders are excluded deliberately: `getFolderLockStatus` skips - * archived rows, so an archived-but-locked folder reports unlocked. Without - * this filter, deleting a folder would make every locked subfolder under it - * freely renameable and reparentable. - */ - const existing = await findActiveFolder(id, workspaceId, resourceType) - if (!existing) return v2Error('NOT_FOUND', 'Folder not found') - - const supportsLocking = Boolean(folderResourceConfig(resourceType).supportsLocking) - if (locked !== undefined && !supportsLocking) { - return v2Error('BAD_REQUEST', 'Folder locking is only supported for workflow folders') - } - - if (supportsLocking) { - const hasNonLockUpdate = - name !== undefined || parentId !== undefined || sortOrder !== undefined - if (hasNonLockUpdate) await assertFolderMutable(id) - if (parentId !== undefined) await assertFolderMutable(parentId) - } - - const result = await updateFolder({ - resourceType, - folderId: id, - workspaceId, - userId, - name, - locked, - parentId, - sortOrder, - }) - - if (!result.success || !result.folder) { - return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to update folder') - } - - return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Error updating folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** DELETE /api/v2/folders/[id] — Archive a folder and cascade to its contents. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, resourceType } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * Archived rows are included on purpose: `deleteFolder` reuses an already - * archived folder's own `deletedAt` so a cascade that failed partway can be - * retried onto the same snapshot. 404ing here would strand those. - */ - const existing = await findFolderInWorkspace(id, workspaceId, resourceType) - if (!existing) return v2Error('NOT_FOUND', 'Folder not found') - - if (folderResourceConfig(resourceType).supportsLocking) { - await assertFolderMutable(id) - } - - const result = await deleteFolder({ - resourceType, - folderId: id, - workspaceId, - userId, - folderName: existing.name, - }) - - if (!result.success) { - return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - - return v2Data({ id, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Error deleting folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts deleted file mode 100644 index 4f2622ee903..00000000000 --- a/apps/sim/app/api/v2/folders/route.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 folders list/create: gate ordering, the required-`resourceType` - * departure from the internal default, and the lock check on create. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockListFoldersForWorkspace, - mockCreateFolder, - mockAssertFolderMutable, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListFoldersForWorkspace: vi.fn(), - mockCreateFolder: vi.fn(), - mockAssertFolderMutable: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/folders/queries', () => ({ - listFoldersForWorkspace: mockListFoldersForWorkspace, -})) - -vi.mock('@/lib/folders/lifecycle', () => ({ - createFolder: mockCreateFolder, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mockAssertFolderMutable, - FolderLockedError: class FolderLockedError extends Error { - status = 423 - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { GET, POST } from '@/app/api/v2/folders/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const FOLDER_API = { - id: 'fld_abc123', - resourceType: 'workflow' as const, - name: 'Onboarding', - userId: 'user-1', - workspaceId: 'workspace-1', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, -} - -function buildRow(overrides: Record = {}) { - return { - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - userId: 'user-1', - workspaceId: 'workspace-1', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - deletedAt: null, - ...overrides, - } -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - search: undefined, - sortBy: 'position', - sortOrder: 'asc', -} - -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) - -function callCreate(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) -} - -const VALID_BODY = { - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Onboarding', -} - -describe('GET /api/v2/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListFoldersForWorkspace.mockResolvedValue([FOLDER_API]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - - expect(res.status).toBe(404) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('resourceType=workflow') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('400s when resourceType is omitted instead of defaulting to workflow', async () => { - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(400) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('400s on a resourceType outside the served set', async () => { - const res = await callList('workspaceId=workspace-1&resourceType=file') - expect(res.status).toBe(400) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - expect(res.status).toBe(403) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns the public folder shape without internal scoping columns', async () => { - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }, - ]) - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( - 'workspace-1', - 'active', - 'workflow', - DEFAULT_LIST_ARGS - ) - }) - - it('passes the archived scope through', async () => { - await callList('workspaceId=workspace-1&resourceType=table&scope=archived') - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( - 'workspace-1', - 'archived', - 'table', - DEFAULT_LIST_ARGS - ) - }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&resourceType=workflow&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList( - `workspaceId=workspace-1&resourceType=workflow&search=report&sortBy=name&sortOrder=asc` - ) - - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockCreateFolder.mockResolvedValue({ success: true, folder: buildRow() }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('400s when the name is empty', async () => { - const res = await callCreate({ ...VALID_BODY, name: ' ' }) - expect(res.status).toBe(400) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('400s when resourceType is omitted', async () => { - const res = await callCreate({ workspaceId: 'workspace-1', name: 'Onboarding' }) - expect(res.status).toBe(400) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(403) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('409s when a sibling folder already has the name', async () => { - mockCreateFolder.mockResolvedValue({ - success: false, - error: 'A folder with this name already exists in this location', - errorCode: 'conflict', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('creates the folder and returns 201', async () => { - const res = await callCreate({ ...VALID_BODY, parentId: null }) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.folder).toMatchObject({ id: 'fld_abc123', name: 'Onboarding' }) - expect(body.data.folder.userId).toBeUndefined() - expect(mockCreateFolder).toHaveBeenCalledWith( - expect.objectContaining({ - resourceType: 'workflow', - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Onboarding', - }) - ) - }) -}) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts deleted file mode 100644 index ed74572a617..00000000000 --- a/apps/sim/app/api/v2/folders/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2CreateFolderContract, v2ListFoldersContract } from '@/lib/api/contracts/v2/folders' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { folderResourceConfig } from '@/lib/folders/config' -import { createFolder } from '@/lib/folders/lifecycle' -import { listFoldersForWorkspace } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2Folder, toV2FolderFromApi, v2FolderMutationError } from '@/app/api/v2/folders/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FoldersAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** GET /api/v2/folders — List a workspace's folder tree for one resource type. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folders') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListFoldersContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, resourceType, scope, search, sortBy, sortOrder } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType, { - search, - sortBy, - sortOrder, - }) - - // One workspace's tree for one resource type is bounded → a single full page. - return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** POST /api/v2/folders — Create a folder in one of a workspace's resource trees. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folders') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFolderContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, resourceType, name, parentId, sortOrder } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - // Locking is a workflow-only feature; other trees leave `locked` false. - if (folderResourceConfig(resourceType).supportsLocking) { - await assertFolderMutable(parentId ?? null) - } - - const result = await createFolder({ - resourceType, - userId, - workspaceId, - name, - parentId, - sortOrder, - }) - - if (!result.success || !result.folder) { - return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - - return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit, status: 201 }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Error creating folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/folders/utils.ts b/apps/sim/app/api/v2/folders/utils.ts deleted file mode 100644 index c041e880013..00000000000 --- a/apps/sim/app/api/v2/folders/utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { folder as folderTable } from '@sim/db/schema' -import { omit } from '@sim/utils/object' -import type { NextResponse } from 'next/server' -import type { FolderApi } from '@/lib/api/contracts/folders' -import type { V2Folder } from '@/lib/api/contracts/v2/folders' -import type { FolderMutationErrorCode } from '@/lib/folders/status' -import { v2Error } from '@/app/api/v2/lib/response' - -/** Shared serialization + error mapping for the v2 folders surface. */ - -type FolderRow = typeof folderTable.$inferSelect - -/** - * Narrows an already-serialized {@link FolderApi} (what the shared list query - * returns) to the public projection. - */ -export function toV2FolderFromApi(row: FolderApi): V2Folder { - return omit(row, ['userId', 'workspaceId']) -} - -/** - * Public folder projection. `userId` and `workspaceId` are internal scoping - * columns and are not exposed. - */ -export function toV2Folder(row: FolderRow): V2Folder { - return { - id: row.id, - resourceType: row.resourceType, - name: row.name, - parentId: row.parentId, - locked: row.locked, - sortOrder: row.sortOrder, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - deletedAt: row.deletedAt?.toISOString() ?? null, - } -} - -/** - * Renders a folder mutation failure in the v2 error envelope. `locked` keeps its - * 423, matching what the table domain returns when the same mutation lock blocks - * a single-table delete. - */ -export function v2FolderMutationError( - errorCode: FolderMutationErrorCode | undefined, - message: string -): NextResponse { - switch (errorCode) { - case 'validation': - return v2Error('BAD_REQUEST', message) - case 'not_found': - return v2Error('NOT_FOUND', 'Folder not found') - case 'conflict': - return v2Error('CONFLICT', message) - case 'locked': - return v2Error('LOCKED', message) - default: - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -} diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index 65364a2b808..abbcbc61e06 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -9,6 +9,8 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performDeleteKnowledgeBase, performUpdateKnowledgeBase, @@ -16,6 +18,7 @@ import { import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -85,7 +88,20 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle ) if (result instanceof NextResponse) return result - return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { rateLimit }) + const folderIndex = await loadActiveFolderPathIndex( + parsed.data.query.workspaceId, + 'knowledge_base' + ) + + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(result.kb), + folderPath: folderPathForId(folderIndex, result.kb.folderId), + }, + }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error getting knowledge base`, { error: getErrorMessage(error, 'Unknown error'), @@ -113,25 +129,44 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { workspaceId, name, description, chunkingConfig } = parsed.data.body + const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const outcome = await performUpdateKnowledgeBase({ - knowledgeBaseId: id, - workspaceId, - userId, - source: 'api', - updates: { name, description, chunkingConfig }, - requestId, - request, + const mutation = await withFolderTreeLock(workspaceId, 'knowledge_base', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base', tx) + const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath) + if (folderPath !== undefined && folderId === undefined) return { found: false as const } + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId, + userId, + source: 'api', + updates: { name, description, chunkingConfig, folderId }, + requestId, + request, + }) + return { found: true as const, index, outcome } }) + if (!mutation.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const { index: folderIndex, outcome } = mutation if (!outcome.success) { return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } - return v2Data({ knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, { rateLimit }) + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(outcome.knowledgeBase), + folderPath: folderPathForId(folderIndex, outcome.knowledgeBase.folderId), + }, + }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts new file mode 100644 index 00000000000..4995d5cc486 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/folders/route.ts @@ -0,0 +1,187 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeFolderContract, + v2DeleteKnowledgeFolderContract, + v2ListKnowledgeFoldersContract, + v2RelocateKnowledgeFolderContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListKnowledgeFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'knowledge_base', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts index 2b6d8f635a6..bdd087b441e 100644 --- a/apps/sim/app/api/v2/knowledge/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -7,13 +7,17 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetKnowledgeBases } = vi.hoisted( - () => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetKnowledgeBases: vi.fn(), - }) -) +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetKnowledgeBases, + mockLoadActiveFolderPathIndex, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetKnowledgeBases: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), +})) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, @@ -24,6 +28,10 @@ vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBases: mockGetKnowledgeBases, })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn(), })) @@ -83,11 +91,16 @@ describe('GET /api/v2/knowledge', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()]) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fold_1', { id: 'fold_1', name: 'Support', parentId: null }]]), + pathById: new Map([['fold_1', '/Support']]), + idByPath: new Map([['/Support', 'fold_1']]), + }) }) it('forwards search, folder, and sort into the query rather than filtering the result', async () => { const res = await callList( - `workspaceId=${WS}&search=support&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + `workspaceId=${WS}&search=support&folderPath=${encodeURIComponent('/Support')}&sortBy=name&sortOrder=desc` ) expect(res.status).toBe(200) @@ -105,6 +118,15 @@ describe('GET /api/v2/knowledge', () => { expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS) }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { + ...DEFAULT_LIST_ARGS, + folderId: null, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { const res = await callList(`workspaceId=${WS}&sortBy=name);--`) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 01811a343f5..90c1392c40e 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -8,10 +8,16 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' import { getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + withResolvedFolderPathMutation, +} from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, @@ -51,18 +57,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { folderId, search, sortBy, sortOrder, }) - const items = knowledgeBases.map(formatKnowledgeBase) + const items = knowledgeBases.map((knowledgeBase) => ({ + ...formatKnowledgeBase(knowledgeBase), + folderPath: folderPathForId(folderIndex, knowledgeBase.folderId), + })) // `getKnowledgeBases` returns the full bounded workspace set → single page. return v2CursorList(items, null, { rateLimit }) @@ -97,27 +113,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, name, description, chunkingConfig } = parsed.data.body + const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const outcome = await performCreateKnowledgeBase({ - userId, - source: 'api', + const mutation = await withResolvedFolderPathMutation({ workspaceId, - name, - description, - chunkingConfig, - requestId, - request, + resourceType: 'knowledge_base', + path: folderPath ?? '/', + mutate: (folderId) => + performCreateKnowledgeBase({ + userId, + source: 'api', + workspaceId, + name, + description, + chunkingConfig, + folderId, + requestId, + request, + }), }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const outcome = mutation.value if (!outcome.success) { return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } return v2Data( - { knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, + { + knowledgeBase: { + ...formatKnowledgeBase(outcome.knowledgeBase), + folderPath: folderPathForId(mutation.index, outcome.knowledgeBase.folderId), + }, + }, { rateLimit, status: 201 } ) } catch (error) { diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts new file mode 100644 index 00000000000..6d506a58fb3 --- /dev/null +++ b/apps/sim/app/api/v2/lib/folders.ts @@ -0,0 +1,85 @@ +import type { folder } from '@sim/db/schema' +import type { NextResponse } from 'next/server' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { + type FolderPathIndex, + isFolderPathEffectivelyLocked, + ROOT_FOLDER_PATH, + toFolderPathView, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { v2ErrorForOrchestration } from '@/app/api/v2/lib/response' + +type FolderRow = typeof folder.$inferSelect + +export function resolveFolderPathId( + index: FolderPathIndex, + path: string +): string | null | undefined { + return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) +} + +export type ResolvedFolderPathMutation = + | { found: false } + | { found: true; folderId: string | null; index: FolderPathIndex; value: T } + +export type ResolvedFolderPathIdentity = { found: false } | { found: true; folderId: string | null } + +/** Resolves a canonical path to its stable internal identity under the folder tree lock. */ +export async function resolveFolderPathIdentity(params: { + workspaceId: string + resourceType: FolderResourceType + path: string +}): Promise { + return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveFolderPathId(index, params.path) + return folderId === undefined ? { found: false } : { found: true, folderId } + }) +} + +/** Resolves a canonical path and keeps that folder tree stable through a resource mutation. */ +export async function withResolvedFolderPathMutation(params: { + workspaceId: string + resourceType: FolderResourceType + path: string + mutate: (folderId: string | null) => Promise +}): Promise> { + return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveFolderPathId(index, params.path) + if (folderId === undefined) return { found: false } + const value = await params.mutate(folderId) + return { found: true, folderId, index, value } + }) +} + +export function folderPathForId( + index: FolderPathIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Resource references an inactive or missing folder') + return path +} + +export function toV2PathFolder( + row: FolderRow, + index: FolderPathIndex, + includeLocked: boolean +) { + const path = index.pathById.get(row.id) + if (!path) throw new Error('Folder path index is missing a listed folder') + const base = toFolderPathView(row, path) + return includeLocked ? { ...base, locked: isFolderPathEffectivelyLocked(index, row.id) } : base +} + +export function v2FolderPathMutationError( + errorCode: OrchestrationErrorCode | undefined, + message: string +): NextResponse { + return v2ErrorForOrchestration(errorCode, message) +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts index 02593d73ec9..b9307d353f7 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -8,6 +8,7 @@ import type { NextRequest } from 'next/server' import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -59,6 +60,7 @@ export const GET = withRouteHandler( workflowWorkspaceId: workflow.workspaceId, workflowCreatedAt: workflow.createdAt, workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) @@ -72,6 +74,8 @@ export const GET = withRouteHandler( const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) if (access) return v2Error('NOT_FOUND', 'Log not found') + const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') + const executionData = await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } @@ -91,12 +95,14 @@ export const GET = withRouteHandler( id: log.workflowId, name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, - folderId: log.workflowFolderId, + folderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, userId: log.workflowUserId, workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, - deleted: !log.workflowName, + deleted: !log.workflowName || log.workflowArchivedAt !== null, }, executionData, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index c4e4077bdc5..ef1eb76e97b 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -3,15 +3,17 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, @@ -55,10 +57,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderPaths = params.folderPaths?.split(',').filter(Boolean) + const folderIndex = folderPaths + ? await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + : null + const resolvedFolderIds = folderPaths?.map((path) => resolveFolderPathId(folderIndex!, path)) + if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const nonRootFolderIds = resolvedFolderIds?.filter( + (folderId): folderId is string => typeof folderId === 'string' + ) + const includesRoot = resolvedFolderIds?.includes(null) ?? false + const filters = { workspaceId: params.workspaceId, workflowIds: params.workflowIds?.split(',').filter(Boolean), - folderIds: params.folderIds?.split(',').filter(Boolean), + folderIds: nonRootFolderIds, triggers: params.triggers?.split(',').filter(Boolean), level: params.level, startDate: params.startDate ? new Date(params.startDate) : undefined, @@ -76,6 +91,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const conditions = buildLogFilters(filters) + const rootFolderCondition = folderPaths + ? or( + includesRoot ? isNull(workflow.folderId) : undefined, + nonRootFolderIds && nonRootFolderIds.length > 0 + ? inArray(workflow.folderId, nonRootFolderIds) + : undefined + ) + : undefined const orderBy = getOrderBy(params.order) const rows = await db @@ -95,10 +118,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, workflowName: workflow.name, workflowDescription: workflow.description, + workflowArchivedAt: workflow.archivedAt, }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(conditions) + .where(and(conditions, rootFolderCondition)) .orderBy(...orderBy) .limit(params.limit + 1) @@ -131,7 +155,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, - deleted: !log.workflowName, + deleted: !log.workflowName || log.workflowArchivedAt !== null, } } return item diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts deleted file mode 100644 index 804db7fd7f0..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 table restore. The target is archived by definition, so the route - * resolves it with archived rows included and checks the permission against - * that row's own workspace rather than going through `checkAccess`. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockGetTableById, - mockGetUserEntityPermissions, - mockPerformRestoreTable, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockGetTableById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockPerformRestoreTable: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/lib/table', () => ({ getTableById: mockGetTableById })) -vi.mock('@/lib/table/orchestration', () => ({ performRestoreTable: mockPerformRestoreTable })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) -vi.mock('@/app/api/table/utils', () => ({ - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { POST } from '@/app/api/v2/tables/[tableId]/restore/route' - -const UNLOCKED = { - schemaLocked: false, - insertLocked: false, - updateLocked: false, - deleteLocked: false, -} -const ARCHIVED_TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } -const RESTORED_TABLE = { - id: 'table-1', - name: 'Tasks', - description: null, - workspaceId: 'ws-1', - schema: { columns: [] }, - rowCount: 7, - maxRows: 1000, - folderId: null, - locks: UNLOCKED, - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/restore', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -describe('POST /api/v2/tables/[tableId]/restore', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockGetTableById.mockResolvedValue(ARCHIVED_TABLE) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockGateError.mockResolvedValue(null) - }) - - it('restores through the orchestration function and returns the table', async () => { - mockPerformRestoreTable.mockResolvedValue({ success: true, table: RESTORED_TABLE }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - table: { - id: 'table-1', - name: 'Tasks', - description: null, - schema: { columns: [] }, - rowCount: 7, - maxRows: 1000, - folderId: null, - locks: UNLOCKED, - job: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - }, - }) - // Archived tables are invisible to `getTableById` by default; without the - // opt-in the route would 404 every restore. - expect(mockGetTableById).toHaveBeenCalledWith('table-1', { includeArchived: true }) - expect(mockPerformRestoreTable).toHaveBeenCalledWith( - expect.objectContaining({ tableId: 'table-1', userId: 'user-1' }) - ) - }) - - it('404s an archived table belonging to another workspace', async () => { - mockGetTableById.mockResolvedValue({ ...ARCHIVED_TABLE, workspaceId: 'ws-other' }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(403) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('maps a name collision with a live table to 409 CONFLICT', async () => { - mockPerformRestoreTable.mockResolvedValue({ - success: false, - errorCode: 'conflict', - error: 'A table named "Tasks" already exists', - }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('400s a body with no workspace', async () => { - const res = await callPost({}) - - expect(res.status).toBe(400) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockGetTableById).not.toHaveBeenCalled() - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(429) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts deleted file mode 100644 index 25485da61da..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2RestoreTableContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getTableById } from '@/lib/table' -import { performRestoreTable } from '@/lib/table/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { toApiTable } from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableRestoreAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/v2/tables/[tableId]/restore — Un-archive a table. - * - * The only table endpoint that cannot use `checkAccess`: its target is archived - * by definition, and `checkAccess` resolves active tables only. The permission - * check is therefore done against the archived row's own workspace, which is - * also what makes the workspace-match check an IDOR guard rather than a - * formality. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-restore') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RestoreTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const archived = await getTableById(tableId, { includeArchived: true }) - // Mask a missing table and a foreign one alike so archived-table existence - // never leaks across workspaces. - if (!archived || archived.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const permission = await getUserEntityPermissions(userId, 'workspace', archived.workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return v2Error('FORBIDDEN', 'Access denied') - } - - const outcome = await performRestoreTable({ tableId, userId, requestId }) - if (!outcome.success || !outcome.table) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to restore table') - } - - return v2Data({ table: toApiTable(outcome.table) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error restoring table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 735529f80a0..866165ddb60 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -19,7 +19,7 @@ const { mockPerformUpdateTableLocks, mockRecordAudit, mockGetTableById, - mockFindActiveFolder, + mockLoadActiveFolderPathIndex, mockGateError, mockSignalSchemaChanged, } = vi.hoisted(() => ({ @@ -32,7 +32,7 @@ const { mockPerformUpdateTableLocks: vi.fn(), mockRecordAudit: vi.fn(), mockGetTableById: vi.fn(), - mockFindActiveFolder: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), mockGateError: vi.fn(), mockSignalSchemaChanged: vi.fn(), })) @@ -66,7 +66,9 @@ vi.mock('@/lib/table', () => ({ vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged, })) -vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), })) @@ -138,7 +140,11 @@ beforeEach(() => { mockResolveWorkspaceScope.mockResolvedValue(null) mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) mockGetTableById.mockResolvedValue(UPDATED_TABLE) - mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', { id: 'folder-1', name: 'Reports', parentId: null }]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) mockGateError.mockResolvedValue(null) }) @@ -186,7 +192,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { schema: { columns: [] }, rowCount: 0, maxRows: 1000, - folderId: null, + folderPath: '/', locks: UNLOCKED, job: null, createdAt: '2026-01-01T00:00:00.000Z', @@ -228,19 +234,17 @@ describe('PATCH /api/v2/tables/[tableId]', () => { it('moves the table only after confirming the folder belongs to the workspace', async () => { mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) - const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Reports' }) expect(res.status).toBe(200) - expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith('ws-1', 'table', expect.any(Object)) expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) ) }) it('404s a folder from outside the workspace without attempting the move', async () => { - mockFindActiveFolder.mockResolvedValue(null) - - const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-elsewhere' }) + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Elsewhere' }) expect(res.status).toBe(404) expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() @@ -249,12 +253,10 @@ describe('PATCH /api/v2/tables/[tableId]', () => { it('rejects a bad folder without applying the rename that came with it', async () => { // The three operations are separate transactions, so validation has to run // before the first write — otherwise a rejected PATCH still renames. - mockFindActiveFolder.mockResolvedValue(null) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', - folderId: 'folder-elsewhere', + folderPath: '/Elsewhere', }) expect(res.status).toBe(404) @@ -274,7 +276,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { error: 'gone', }) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) expect(res.status).toBe(404) expect((await res.json()).error.details).toEqual({ applied: ['name'] }) @@ -288,7 +290,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { error: 'taken', }) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) expect(res.status).toBe(409) expect((await res.json()).error.details).toBeUndefined() @@ -305,7 +307,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { error: 'gone', }) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) expect(res.status).toBe(404) expect(mockPerformRenameTable).toHaveBeenCalled() @@ -369,7 +371,9 @@ describe('PATCH /api/v2/tables/[tableId]', () => { it('omits applied details when the failure happened before any write', async () => { mockGetTableById.mockRejectedValue(new Error('connection reset')) - const res = await callPatch({ workspaceId: 'ws-1', folderId: 'nope' }) + mockLoadActiveFolderPathIndex.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Nope' }) // Absence is meaningful: nothing is live, so a retry is safe. expect((await res.json()).error.details).toBeUndefined() diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 447bd76e4c2..c5406bae297 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -10,7 +10,8 @@ import { parseRequest } from '@/lib/api/server' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { findActiveFolder } from '@/lib/folders/queries' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { getTableById } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' import { @@ -20,6 +21,7 @@ import { } from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -44,7 +46,7 @@ const logger = createLogger('V2TableDetailAPI') * means "these changes are live despite the error". */ function appliedDetails( - applied: readonly ('name' | 'folderId')[] + applied: readonly ('name' | 'folderPath')[] ): { applied: readonly string[] } | undefined { return applied.length > 0 ? { applied } : undefined } @@ -88,7 +90,11 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - return v2Data({ table: toApiTable(result.table) }, { rateLimit }) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data( + { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error getting table`, { error: getErrorMessage(error, 'Unknown error'), @@ -119,7 +125,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl * archived. Reporting a bare 500 there tells the caller nothing landed, and * it retries into a duplicate-name conflict or a repeated move. */ - const applied: ('name' | 'folderId')[] = [] + const applied: ('name' | 'folderPath')[] = [] try { const rateLimit = await checkRateLimit(request, 'table-detail') @@ -149,76 +155,75 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl return v2Error('NOT_FOUND', 'Table not found') } - // The two operations are separate transactions, so a rejection discovered - // partway through would leave the earlier one persisted while the response - // reports failure. Everything a request can be rejected for is therefore - // checked up front: a rejected PATCH changes nothing. - if (validated.folderId != null) { - // Scoped to `resourceType: 'table'` so a folder id from another resource's - // tree can't file the table somewhere Tables never lists. - if (!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))) { + return await withFolderTreeLock(table.workspaceId, 'table', async (tx) => { + const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table', tx) + const folderId = + validated.folderPath === undefined + ? undefined + : resolveFolderPathId(folderIndex, validated.folderPath) + if (validated.folderPath !== undefined && folderId === undefined) { return v2Error('NOT_FOUND', 'Folder not found in this workspace') } - } - // Every deterministic rejection is already behind us, so a failure here is - // a genuine fault (lost race, archived mid-request, database error) rather - // than a bad request. The two operations commit independently — a single - // transaction would have to span two shared service functions that also - // back the first-party route and two copilot tools, and would break their - // per-operation audits — so instead of pretending atomicity the response - // states exactly which operations landed. A caller that gets an error can - // then reconcile rather than having to re-read and diff. - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null - - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } + // Rename and move retain their shared services' independent transactions and audits. + // Validate deterministic failures first and report `applied` so callers can reconcile + // if a later fault lands after an earlier operation commits. + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } - if (!failure && validated.folderId !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: validated.folderId, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderId') - } else { - // The move re-asserts workspace and active state, so a miss means the - // table was archived between `checkAccess` and the write. - failure = { - outcome: - outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, - fallback: 'Failed to move table', + if (!failure && validated.folderPath !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: folderId ?? null, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderPath') + } else { + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + failure = { + outcome: + outcome.errorCode === 'not_found' + ? { ...outcome, error: 'Table not found' } + : outcome, + fallback: 'Failed to move table', + } } } - } - // Live-collab: tell open viewers the definition changed so they refetch. - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } + // Live-collab: tell open viewers the definition changed so they refetch. + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } - // Re-read so the response reflects every applied change at once. A miss - // means the table was archived after the writes committed, so the caller - // still has to be told what landed. - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } + // Re-read so the response reflects every applied change at once. A miss + // means the table was archived after the writes committed, so the caller + // still has to be told what landed. + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } - return v2Data({ table: toApiTable(updated) }, { rateLimit }) + return v2Data( + { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, + { rateLimit } + ) + }) } catch (error) { const details = appliedDetails(applied) @@ -242,7 +247,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl } }) -/** DELETE /api/v2/tables/[tableId] — Archive a table. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts new file mode 100644 index 00000000000..c9744bf395b --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -0,0 +1,182 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateTableFolderContract, + v2DeleteTableFolderContract, + v2ListTableFoldersContract, + v2RelocateTableFolderContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListTableFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'table', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing table folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts index a45e7de4fce..f5c6a5f7541 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -9,11 +9,13 @@ const { mockResolveWorkspaceScope, mockCreateTableImportResource, mockToV2CreateTableImport, + mockLoadActiveFolderPathIndex, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), mockCreateTableImportResource: vi.fn(), mockToV2CreateTableImport: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -34,6 +36,10 @@ vi.mock('@/lib/table/orchestration/import-resource', () => ({ toV2CreateTableImport: mockToV2CreateTableImport, })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + import { POST } from '@/app/api/v2/tables/imports/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -51,6 +57,11 @@ describe('POST /api/v2/tables/imports', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) mockResolveWorkspaceScope.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) it.each([ @@ -94,7 +105,8 @@ describe('POST /api/v2/tables/imports', () => { expect(mockCreateTableImportResource).toHaveBeenCalledWith( requestBody, 'user-1', - 'http://localhost:3000' + 'http://localhost:3000', + null ) expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) expect(await response.json()).toEqual({ data: responseData }) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index ad533a55e58..5908ffd57a0 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -9,6 +9,7 @@ import { toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, @@ -40,11 +41,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const created = await createTableImportResource( - parsed.data.body, - userId, - request.nextUrl.origin - ) + let created: Awaited> + if (parsed.data.body.target.type === 'new') { + const mutation = await withResolvedFolderPathMutation({ + workspaceId: parsed.data.body.workspaceId, + resourceType: 'table', + path: parsed.data.body.target.folderPath ?? '/', + mutate: (folderId) => + createTableImportResource(parsed.data.body, userId, request.nextUrl.origin, folderId), + }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + created = mutation.value + } else { + created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin) + } return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) } catch (error) { const lockError = v2TableLockError(error) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 51eff8841ec..0412235b367 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -14,12 +14,14 @@ const { mockResolveWorkspaceAccess, mockIsFeatureEnabled, mockGetWorkspaceOrganizationId, + mockLoadActiveFolderPathIndex, } = vi.hoisted(() => ({ mockQueryTables: vi.fn(), mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockIsFeatureEnabled: vi.fn(), mockGetWorkspaceOrganizationId: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -50,6 +52,10 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + import { GET } from '@/app/api/v2/tables/route' const RATE_LIMIT_OK = { @@ -91,6 +97,11 @@ describe('GET /api/v2/tables', () => { mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) mockIsFeatureEnabled.mockResolvedValue(true) mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -162,6 +173,15 @@ describe('GET /api/v2/tables', () => { expect((await res.json()).nextCursor).toBeNull() }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList('workspaceId=workspace-1&folderPath=%2F') + + expect(mockQueryTables).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ folderId: null }) + ) + }) + it('passes limit and the decoded cursor through to the query', async () => { mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index a8db12e87f9..f57826e1bc2 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -6,9 +6,15 @@ import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + withResolvedFolderPathMutation, +} from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, @@ -53,11 +59,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, search, sortBy, sortOrder, limit, cursor } = parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const sort = cursorSortKey(sortBy, sortOrder) const decoded = decodeSortedCursor(cursor, sort) if (decoded.status === 'invalid') return v2CursorSortError() @@ -71,7 +84,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { after: decoded.status === 'ok' ? decoded.keys : undefined, }) - const items = tables.map(toApiTable) + const items = tables.map((table) => + toApiTable(table, folderPathForId(folderIndex, table.folderId)) + ) const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null return v2CursorList(items, nextCursor, { rateLimit }) @@ -117,17 +132,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { columns: params.schema.columns.map(normalizeColumn), } - const table = await createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, - workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - }, - requestId - ) + const mutation = await withResolvedFolderPathMutation({ + workspaceId: params.workspaceId, + resourceType: 'table', + path: params.folderPath ?? '/', + mutate: (folderId) => + createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + folderId, + }, + requestId + ), + }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const table = mutation.value recordAudit({ workspaceId: params.workspaceId, @@ -141,7 +165,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) + return v2Data( + { table: toApiTable(table, folderPathForId(mutation.index, table.folderId)) }, + { rateLimit, status: 201 } + ) } catch (error) { if (isZodError(error)) return v2ValidationError(error) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index bcc780e23ab..d3cd6dfe6e5 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -53,7 +53,7 @@ export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: Table * exposes, with timestamps serialized to ISO strings. Shared by every v2 table * endpoint so the table payload is identical across the surface. */ -export function toApiTable(table: TableDefinition) { +export function toApiTable(table: TableDefinition, folderPath: string) { return { id: table.id, name: table.name, @@ -63,7 +63,7 @@ export function toApiTable(table: TableDefinition) { }, rowCount: table.rowCount, maxRows: table.maxRows, - folderId: table.folderId ?? null, + folderPath, locks: table.locks, // `jobStatus` is the presence signal — the service leaves the whole group // null when the table is idle. Without this an async import could be diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index 35ab0d287d6..c4caa0ec7d8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -7,8 +7,10 @@ import type { NextRequest } from 'next/server' import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' @@ -57,6 +59,8 @@ export const GET = withRouteHandler( const payload = await buildWorkflowExportPayload(workflowData) if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const folderPath = folderPathForId(folderIndex, workflowData.folderId) recordAudit({ workspaceId: workflowData.workspaceId, @@ -68,14 +72,26 @@ export const GET = withRouteHandler( description: `Exported workflow "${workflowData.name}" via the API`, metadata: { workspaceId: workflowData.workspaceId, - folderId: workflowData.folderId || undefined, + folderPath, blocksCount: Object.keys(payload.state.blocks).length, edgesCount: payload.state.edges.length, }, request, }) - return v2Data(payload, { rateLimit }) + return v2Data( + { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, + folderPath, + }, + }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Workflow export error`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 432027d8cc9..33de9864022 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -17,10 +17,9 @@ const { mockPerformDeleteWorkflow, mockAssertWorkflowMutable, mockAssertFolderMutable, - mockAssertFolderInWorkspace, + mockLoadActiveFolderPathIndex, WorkflowLockedErrorMock, FolderLockedErrorMock, - FolderNotFoundErrorMock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), @@ -29,16 +28,13 @@ const { mockPerformDeleteWorkflow: vi.fn(), mockAssertWorkflowMutable: vi.fn(), mockAssertFolderMutable: vi.fn(), - mockAssertFolderInWorkspace: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), WorkflowLockedErrorMock: class WorkflowLockedError extends Error { status = 423 }, FolderLockedErrorMock: class FolderLockedError extends Error { status = 423 }, - FolderNotFoundErrorMock: class FolderNotFoundError extends Error { - status = 400 - }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -55,10 +51,12 @@ vi.mock('@sim/platform-authz/workflow', () => ({ getActiveWorkflowRecord: mockGetActiveWorkflowRecord, assertWorkflowMutable: mockAssertWorkflowMutable, assertFolderMutable: mockAssertFolderMutable, - assertFolderInWorkspace: mockAssertFolderInWorkspace, WorkflowLockedError: WorkflowLockedErrorMock, FolderLockedError: FolderLockedErrorMock, - FolderNotFoundError: FolderNotFoundErrorMock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, })) vi.mock('@/lib/workflows/input-format', () => ({ @@ -147,7 +145,11 @@ describe('PATCH /api/v2/workflows/[id]', () => { mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) mockAssertWorkflowMutable.mockResolvedValue(undefined) mockAssertFolderMutable.mockResolvedValue(undefined) - mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), + pathById: new Map([['fld-1', '/Locked']]), + idByPath: new Map([['/Locked', 'fld-1']]), + }) mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) }) @@ -200,43 +202,34 @@ describe('PATCH /api/v2/workflows/[id]', () => { it('423s when the destination folder is locked', async () => { mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPatch({ folderId: 'fld-1' }) + const res = await callPatch({ folderPath: '/Locked' }) expect(res.status).toBe(423) expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() }) - it('400s a folder outside the workspace without ever reading its lock state', async () => { - mockAssertFolderInWorkspace.mockRejectedValue( - new FolderNotFoundErrorMock('Target folder not found') - ) - const res = await callPatch({ folderId: 'fld-other-workspace' }) + it('404s a path outside the workspace without ever reading its lock state', async () => { + const res = await callPatch({ folderPath: '/Elsewhere' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - // Containment runs first, so a locked foreign folder cannot be told apart - // from a nonexistent one by its status code. + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockAssertFolderMutable).not.toHaveBeenCalled() expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() }) - it('checks folder containment against the workflow workspace before mutability', async () => { - const order: string[] = [] - mockAssertFolderInWorkspace.mockImplementation(async () => { - order.push('containment') - }) - mockAssertFolderMutable.mockImplementation(async () => { - order.push('mutability') - }) - - await callPatch({ folderId: 'fld-1' }) + it('resolves the canonical path against the workflow workspace before mutability', async () => { + await callPatch({ folderPath: '/Locked' }) - expect(order).toEqual(['containment', 'mutability']) - expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'workspace-1', + 'workflow', + expect.any(Object) + ) + expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') }) it('skips the containment check on a rename that does not move the workflow', async () => { await callPatch({ name: 'Support Agent v2' }) - expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + expect(mockAssertFolderMutable).not.toHaveBeenCalled() }) it('409s when the target name is taken in the destination folder', async () => { @@ -260,7 +253,7 @@ describe('PATCH /api/v2/workflows/[id]', () => { id: 'wf-1', name: 'Support Agent v2', description: 'Handles tickets', - folderId: null, + folderPath: '/', workspaceId: 'workspace-1', isDeployed: true, deployedAt: '2024-01-03T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index a3b22e05dc0..9123fb7b89e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -2,11 +2,9 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { - assertFolderInWorkspace, assertFolderMutable, assertWorkflowMutable, FolderLockedError, - FolderNotFoundError, getActiveWorkflowRecord, WorkflowLockedError, } from '@sim/platform-authz/workflow' @@ -23,9 +21,12 @@ import { } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -70,6 +71,8 @@ export const GET = withRouteHandler( const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const blockRows = await db .select({ id: workflowBlocks.id, @@ -88,7 +91,7 @@ export const GET = withRouteHandler( id: workflowData.id, name: workflowData.name, description: workflowData.description, - folderId: workflowData.folderId, + folderPath: folderPathForId(folderIndex, workflowData.folderId), workspaceId: workflowData.workspaceId, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt?.toISOString() ?? null, @@ -129,7 +132,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { name, description, folderId } = parsed.data.body + const { name, description, folderPath } = parsed.data.body const workflowData = await getActiveWorkflowRecord(id) if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') @@ -143,27 +146,31 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout ) if (access) return v2Error('NOT_FOUND', 'Workflow not found') - /** - * Ownership before lock state: `assertFolderMutable` walks the folder's - * ancestor chain without filtering on workspace, so checking it first would - * let a caller distinguish a locked folder in someone else's workspace - * (423) from one that simply does not exist (400). - */ - if (folderId) await assertFolderInWorkspace(folderId, workflowData.workspaceId) - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, + const mutation = await withFolderTreeLock(workflowData.workspaceId, 'workflow', async (tx) => { + const index = await loadActiveFolderPathIndex(workflowData.workspaceId!, 'workflow', tx) + const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath) + if (folderPath !== undefined && folderId === undefined) return { found: false as const } + + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) + + const result = await performUpdateWorkflow({ + workflowId: id, + userId, + workspaceId: workflowData.workspaceId!, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + return { found: true as const, index, result } }) + if (!mutation.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const { index: folderIndex, result } = mutation if (!result.success || !result.workflow) { return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') @@ -178,7 +185,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout id: updated.id, name: updated.name, description: updated.description, - folderId: updated.folderId, + folderPath: folderPathForId(folderIndex, updated.folderId), workspaceId: updated.workspaceId ?? workflowData.workspaceId, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt?.toISOString() ?? null, @@ -190,7 +197,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout return v2Data(item, { rateLimit }) } catch (error) { - if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { return v2Error('LOCKED', error.message) } @@ -202,7 +208,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } }) -/** DELETE /api/v2/workflows/[id] — Archive a workflow into Recently Deleted. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts new file mode 100644 index 00000000000..696286333ed --- /dev/null +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockLoadActiveFolderPathIndex, + mockListActiveFolderRows, + mockCreateFolderAtPath, + mockRelocateFolderByPath, + mockDeleteFolderByPath, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockListActiveFolderRows: vi.fn(), + mockCreateFolderAtPath: vi.fn(), + mockRelocateFolderByPath: vi.fn(), + mockDeleteFolderByPath: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, + listActiveFolderRows: mockListActiveFolderRows, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPath: mockCreateFolderAtPath, + relocateFolderByPath: mockRelocateFolderByPath, + deleteFolderByPath: mockDeleteFolderByPath, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/workflows/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const FOLDER_ID = 'internal-folder-id' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const folder = { + id: FOLDER_ID, + resourceType: 'workflow' as const, + name: 'Reports', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, +} + +function pathIndex(path = '/Reports') { + return { + rowById: new Map([[FOLDER_ID, folder]]), + pathById: new Map([[FOLDER_ID, path]]), + idByPath: new Map([[path, FOLDER_ID]]), + } +} + +function request(method: string, path: string, body?: Record) { + return new NextRequest(`http://localhost:3000${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) +} + +describe('/api/v2/workflows/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex()) + mockListActiveFolderRows.mockResolvedValue([folder]) + mockCreateFolderAtPath.mockResolvedValue({ + success: true, + folder, + path: '/Reports', + }) + mockRelocateFolderByPath.mockResolvedValue({ + success: true, + folder, + path: '/Reports', + }) + mockDeleteFolderByPath.mockResolvedValue({ + success: true, + path: '/Reports', + deletedItems: { folders: 1, workflows: 2 }, + }) + }) + + it('lists only root children when parentPath is root and never exposes database ids', async () => { + const response = await GET( + request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2F`) + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { + parentId: null, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }) + expect(body.data).toEqual([ + { + name: 'Reports', + path: '/Reports', + parentPath: '/', + locked: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + }) + + it('omits the parent filter to list folders from the whole tree', async () => { + await GET(request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}`)) + + expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { + parentId: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }) + }) + + it('creates a folder from a canonical path and rejects internal ids', async () => { + const created = await POST( + request('POST', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + }) + ) + + expect(created.status).toBe(201) + expect(mockCreateFolderAtPath).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + path: '/Reports', + }) + + const rejected = await POST( + request('POST', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + folderId: FOLDER_ID, + }) + ) + expect(rejected.status).toBe(400) + }) + + it('relocates one folder by source and destination paths', async () => { + mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex('/Archive')) + const response = await PATCH( + request('PATCH', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive', + }) + ) + + expect(response.status).toBe(200) + expect(mockRelocateFolderByPath).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + path: '/Reports', + destinationPath: '/Archive', + }) + }) + + it('requires an explicit recursive delete choice', async () => { + const missing = await DELETE( + request('DELETE', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports`) + ) + expect(missing.status).toBe(400) + expect(mockDeleteFolderByPath).not.toHaveBeenCalled() + + const deleted = await DELETE( + request( + 'DELETE', + `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ) + ) + expect(deleted.status).toBe(200) + expect(await deleted.json()).toEqual({ + data: { + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, workflows: 2 }, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts new file mode 100644 index 00000000000..bb791ec6dce --- /dev/null +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -0,0 +1,186 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateWorkflowFolderContract, + v2DeleteWorkflowFolderContract, + v2ListWorkflowFoldersContract, + v2RelocateWorkflowFolderContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListWorkflowFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'workflow', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, true)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index af66621b16c..5338f9c8cb9 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -10,6 +10,7 @@ import { MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId, withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, @@ -64,25 +65,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, name, description } = parsed.data.body + const { workspaceId, folderPath, name, description } = parsed.data.body logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { userId, - folderId, + folderPath, }) const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const result = await importWorkflowIntoWorkspace({ + const mutation = await withResolvedFolderPathMutation({ workspaceId, - folderId, - name, - description, - workflow: parsed.data.body.workflow, - userId, - requestId, + resourceType: 'workflow', + path: folderPath ?? '/', + mutate: (folderId) => + importWorkflowIntoWorkspace({ + workspaceId, + folderId: folderId ?? undefined, + name, + description, + workflow: parsed.data.body.workflow, + userId, + requestId, + }), }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const result = mutation.value if (!result.success) { return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { @@ -97,7 +106,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { name: result.workflow.name, description: result.workflow.description, workspaceId: result.workflow.workspaceId, - folderId: result.workflow.folderId, + folderPath: folderPathForId(mutation.index, result.workflow.folderId), createdAt: result.workflow.createdAt.toISOString(), updatedAt: result.workflow.updatedAt.toISOString(), }, diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 6b3e0ee67c2..9ab8c6575ae 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -21,21 +21,17 @@ const { mockResolveWorkspaceAccess, mockPerformCreateWorkflow, mockAssertFolderMutable, - mockAssertFolderInWorkspace, + mockLoadActiveFolderPathIndex, FolderLockedErrorMock, - FolderNotFoundErrorMock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockPerformCreateWorkflow: vi.fn(), mockAssertFolderMutable: vi.fn(), - mockAssertFolderInWorkspace: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), FolderLockedErrorMock: class FolderLockedError extends Error { status = 423 }, - FolderNotFoundErrorMock: class FolderNotFoundError extends Error { - status = 400 - }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -49,9 +45,11 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@sim/platform-authz/workflow', () => ({ assertFolderMutable: mockAssertFolderMutable, - assertFolderInWorkspace: mockAssertFolderInWorkspace, FolderLockedError: FolderLockedErrorMock, - FolderNotFoundError: FolderNotFoundErrorMock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, })) vi.mock('@/app/api/v2/lib/gate', () => ({ @@ -111,6 +109,11 @@ describe('GET /api/v2/workflows', () => { resetDbChainMock() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) it('narrows the query with a case-insensitive substring match on the name', async () => { @@ -141,6 +144,19 @@ describe('GET /api/v2/workflows', () => { expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect( + lastConditions().some( + (condition) => + condition.type === 'isNull' && condition.column === schemaMock.workflow.folderId + ) + ).toBe(true) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) @@ -284,7 +300,11 @@ describe('POST /api/v2/workflows', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockAssertFolderMutable.mockResolvedValue(undefined) - mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), + pathById: new Map([['fld-1', '/Locked']]), + idByPath: new Map([['/Locked', 'fld-1']]), + }) mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) }) @@ -338,44 +358,34 @@ describe('POST /api/v2/workflows', () => { it('423s when the destination folder is locked', async () => { mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + const res = await callPost({ ...VALID_BODY, folderPath: '/Locked' }) expect(res.status).toBe(423) expect((await res.json()).error.code).toBe('LOCKED') expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) - it('400s a folder outside the workspace without ever reading its lock state', async () => { - mockAssertFolderInWorkspace.mockRejectedValue( - new FolderNotFoundErrorMock('Target folder not found') - ) - const res = await callPost({ ...VALID_BODY, folderId: 'fld-other-workspace' }) + it('404s a path outside the workspace without ever reading its lock state', async () => { + const res = await callPost({ ...VALID_BODY, folderPath: '/Elsewhere' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - // Containment runs first, so a locked foreign folder cannot be told apart - // from a nonexistent one by its status code. + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockAssertFolderMutable).not.toHaveBeenCalled() expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) - it('checks folder containment before mutability', async () => { - const order: string[] = [] - mockAssertFolderInWorkspace.mockImplementation(async () => { - order.push('containment') - }) - mockAssertFolderMutable.mockImplementation(async () => { - order.push('mutability') - }) - - await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + it('resolves the canonical path before checking mutability', async () => { + await callPost({ ...VALID_BODY, folderPath: '/Locked' }) - expect(order).toEqual(['containment', 'mutability']) - expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'workspace-1', + 'workflow', + expect.any(Object) + ) + expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') }) it('skips the containment check when no folder is supplied', async () => { await callPost(VALID_BODY) - expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) }) @@ -400,7 +410,7 @@ describe('POST /api/v2/workflows', () => { id: 'wf-1', name: 'Support Agent', description: 'Handles tickets', - folderId: null, + folderPath: '/', workspaceId: 'workspace-1', isDeployed: false, deployedAt: null, @@ -417,7 +427,7 @@ describe('POST /api/v2/workflows', () => { workspaceId: 'workspace-1', name: 'Support Agent', description: 'Handles tickets', - folderId: undefined, + folderId: null, }) ) }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 88d9b457264..896280c7be0 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,12 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertFolderInWorkspace, - assertFolderMutable, - FolderLockedError, - FolderNotFoundError, -} from '@sim/platform-authz/workflow' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -30,8 +25,14 @@ import { } from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + withResolvedFolderPathMutation, +} from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, @@ -113,6 +114,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + const folderId = + params.folderPath === undefined + ? undefined + : resolveFolderPathId(folderIndex, params.folderPath) + if (params.folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const sortKey = cursorSortKey(params.sortBy, params.sortOrder) const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] const decoded = decodeSortedCursor(params.cursor, sortKey) @@ -126,7 +136,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const conditions = [ eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt), - params.folderId ? eq(workflow.folderId, params.folderId) : undefined, + params.folderPath === undefined + ? undefined + : folderId === null + ? isNull(workflow.folderId) + : folderId === undefined + ? undefined + : eq(workflow.folderId, folderId), params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, searchFilter(workflow.name, params.search), resumeAfter, @@ -163,7 +179,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: w.id, name: w.name, description: w.description, - folderId: w.folderId, + folderPath: folderPathForId(folderIndex, w.folderId), workspaceId: w.workspaceId ?? params.workspaceId, isDeployed: w.isDeployed, deployedAt: w.deployedAt?.toISOString() ?? null, @@ -203,28 +219,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, name, description, folderId } = parsed.data.body + const { workspaceId, name, description, folderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - /** - * Ownership before lock state: `assertFolderMutable` walks the folder's - * ancestor chain without filtering on workspace, so checking it first would - * let a caller distinguish a locked folder in someone else's workspace - * (423) from one that simply does not exist (400). - */ - if (folderId) await assertFolderInWorkspace(folderId, workspaceId) - await assertFolderMutable(folderId ?? null) - - const result = await performCreateWorkflow({ - userId, + const mutation = await withResolvedFolderPathMutation({ workspaceId, - name, - description, - folderId, - requestId, + resourceType: 'workflow', + path: folderPath ?? '/', + mutate: async (folderId) => { + await assertFolderMutable(folderId) + return performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId, + requestId, + }) + }, }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const result = mutation.value if (!result.success || !result.workflow) { return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') @@ -235,7 +252,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { id: created.id, name: created.name, description: created.description ?? null, - folderId: created.folderId ?? null, + folderPath: folderPathForId(mutation.index, created.folderId), workspaceId: created.workspaceId, isDeployed: false, deployedAt: null, @@ -247,7 +264,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return v2Data(item, { rateLimit, status: 201 }) } catch (error) { - if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) logger.error(`[${requestId}] Workflow create error`, { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index ae78045a287..e6292d6accd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -69,6 +69,7 @@ import { useRenameTable, useTablesList, } from '@/hooks/queries/tables' +import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -886,7 +887,7 @@ export function Tables() { try { await importCsv.mutateAsync({ workspaceId, - folderId: currentFolderId, + folderPath: getCanonicalFolderPath(currentFolderId, folderById), file, onCreated: (createdImportId) => { importId = createdImportId diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 471e1620a93..c734bc01939 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1731,7 +1731,7 @@ export function useRestoreTable() { interface ImportCsvAsyncParams { workspaceId: string /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null + folderPath?: string file: File onCreated?: (importId: string) => void onProgress?: (percent: number) => void @@ -1807,7 +1807,7 @@ export function useImportCsv() { return useMutation({ mutationFn: async ({ workspaceId, - folderId, + folderPath, file, onCreated, onProgress, @@ -1826,7 +1826,7 @@ export function useImportCsv() { 0, TABLE_LIMITS.MAX_TABLE_NAME_LENGTH ), - folderId: folderId ?? undefined, + folderPath, }, file, timezone, diff --git a/apps/sim/hooks/queries/utils/folder-tree.ts b/apps/sim/hooks/queries/utils/folder-tree.ts index 656c23fa4eb..efbcd624243 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.ts @@ -1,3 +1,4 @@ +import { buildFolderPath } from '@/lib/folders/paths' import type { WorkflowFolder } from '@/stores/folders/types' /** @@ -54,6 +55,31 @@ export function getFolderPath( return segments.length > 0 ? segments.join(separator) : null } +/** Returns the canonical public API path for a folder and rejects corrupt trees. */ +export function getCanonicalFolderPath( + folderId: string | null | undefined, + folders: Record | Map +): string { + if (!folderId) return '/' + + const segments: string[] = [] + const visited = new Set() + let currentFolderId: string | null | undefined = folderId + + while (currentFolderId) { + if (visited.has(currentFolderId)) throw new Error('Folder tree contains a cycle') + visited.add(currentFolderId) + + const folder: WorkflowFolder | undefined = + folders instanceof Map ? folders.get(currentFolderId) : folders[currentFolderId] + if (!folder) throw new Error(`Folder ${currentFolderId} was not found`) + segments.unshift(folder.name) + currentFolderId = folder.parentId + } + + return buildFolderPath(segments) +} + /** * Names that appear more than once in the list, so callers can disambiguate * only the entries that actually collide. diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 48839b9214e..296bfef9ca0 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { - folderIdSchema, isCanonicalBase64, workspaceFileIdSchema, workspaceIdSchema, @@ -8,8 +7,14 @@ import { import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -29,14 +34,8 @@ import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' * adds cursor pagination to the list. List and item routes carry the workspace * as a query parameter; upload-session creation carries it in the JSON body. * - * Folders are referenced but not managed here. A file carries `folderId` / - * `folderPath`, and `move` retargets it, but there are deliberately no - * folder-CRUD routes on this surface: file folders already live in the shared - * `folder` table (`resourceType: 'file'`), and the remaining file-specific - * folder machinery is being folded into the generic folder engine. Publishing - * `/api/v2/files/folders/**` would pin a transitional split into a public - * contract; folder management belongs on `/api/v2/folders` once that surface - * serves `resourceType: 'file'`. + * Folder placement is represented only by canonical paths. Database folder ids + * remain an internal storage detail. * * Uploads use a signed stateless control token. The storage provider owns the * multipart part state; completion atomically registers the workspace file. @@ -49,10 +48,8 @@ export const v2FileSchema = z.object({ size: z.number().nonnegative(), type: z.string(), key: z.string(), - /** Containing file folder, or `null` when the file sits at the workspace root. */ - folderId: z.string().nullable(), - /** Slash-joined folder names for {@link v2FileSchema.folderId}; `null` at the root. */ - folderPath: z.string().nullable(), + /** Canonical containing-folder path; `/` means the workspace root. */ + folderPath: v2FolderPathSchema, uploadedBy: z.string(), /** ISO-8601 timestamp. */ uploadedAt: z.string(), @@ -71,7 +68,7 @@ export const v2CreateFileUploadBodySchema = z name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), - folderId: folderIdSchema.optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict() export type V2CreateFileUploadBody = z.input @@ -100,7 +97,6 @@ export const v2CreateFileUploadDataSchema = z .strict() export type V2CreateFileUploadData = z.output -/** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), deleted: z.literal(true), @@ -108,25 +104,12 @@ export const v2DeleteFileResultSchema = z.object({ export type V2DeleteFileResult = z.output -/** Counts of what a cascading archive or restore touched. */ -export const v2FileItemCountsSchema = z.object({ - files: z.number().int(), - folders: z.number().int(), -}) - -export type V2FileItemCounts = z.output - export const v2FileParamsSchema = z.object({ fileId: workspaceFileIdSchema, }) export type V2FileParams = z.output -/** `active` lists live items; `archived` lists Recently Deleted. */ -export const v2FileScopeSchema = z.enum(['active', 'archived']) - -export type V2FileScope = z.output - /** * A file-folder name becomes a path segment, so path separators and dot * segments are rejected rather than normalized. Mirrors @@ -152,7 +135,7 @@ export const v2CreateFileBodySchema = z .min(1, 'contentType cannot be empty') .max(255, 'contentType is too long') .optional(), - folderId: folderIdSchema.optional(), + folderPath: v2FolderPathSchema.optional(), content: z.string().max(70_000_000, 'content is too large').default(''), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), }) @@ -183,20 +166,21 @@ export type V2FileSortBy = (typeof v2FileSortFields)[number] * minted under and rejected if the request's sort has since changed. Filtering, * ordering, and the page slice all happen in the query. */ -export const v2ListFilesQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - scope: v2FileScopeSchema.default('active'), - /** Restrict to one file folder. Omit to list the whole workspace. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), - search: v2SearchSchema, - ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), - limit: z.coerce - .number() - .optional() - .default(100) - .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), - cursor: z.string().min(1).optional(), -}) +export const v2ListFilesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + /** Restrict to one file folder. Omit to list the whole workspace. */ + folderPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), + }) + .strict() export type V2ListFilesQuery = z.output @@ -216,76 +200,77 @@ export const v2RenameFileBodySchema = z export type V2RenameFileBody = z.input -export const v2WorkspaceScopedBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - }) - .strict() - -export type V2WorkspaceScopedBody = z.input - -/** A restore acknowledgement carries no payload beyond the restored id. */ -export const v2RestoreFileResultSchema = z.object({ - id: z.string(), - restored: z.literal(true), -}) - -export type V2RestoreFileResult = z.output - -const fileItemSelectionSchema = { - fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).max(1000).default([]), - folderIds: z.array(z.string().min(1, 'folderIds entries cannot be empty')).max(1000).default([]), +const fileSelectionSchema = { + fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).min(1).max(1000), } export const v2MoveFileItemsBodySchema = z .object({ workspaceId: workspaceIdSchema, - ...fileItemSelectionSchema, - /** Explicit `null` moves the selection to the workspace root. */ - targetFolderId: z.string().min(1, 'targetFolderId cannot be empty').nullable().optional(), + ...fileSelectionSchema, + /** Omission moves the files to the workspace root. */ + targetFolderPath: v2FolderPathSchema.optional(), }) .strict() - .superRefine((body, ctx) => { - if (body.fileIds.length === 0 && body.folderIds.length === 0) { - ctx.addIssue({ - code: 'custom', - path: ['fileIds'], - message: 'At least one of fileIds or folderIds must be non-empty', - }) - } - }) export type V2MoveFileItemsBody = z.input export const v2MoveFileItemsResultSchema = z.object({ - movedItems: v2FileItemCountsSchema, + movedItems: z.object({ files: z.number().int() }), }) export type V2MoveFileItemsResult = z.output -export const v2BulkArchiveFileItemsBodySchema = z +export const v2BulkDeleteFilesBodySchema = z .object({ workspaceId: workspaceIdSchema, - ...fileItemSelectionSchema, + ...fileSelectionSchema, }) .strict() - .superRefine((body, ctx) => { - if (body.fileIds.length === 0 && body.folderIds.length === 0) { - ctx.addIssue({ - code: 'custom', - path: ['fileIds'], - message: 'At least one of fileIds or folderIds must be non-empty', - }) - } - }) -export type V2BulkArchiveFileItemsBody = z.input +export type V2BulkDeleteFilesBody = z.input + +export const v2BulkDeleteFilesResultSchema = z.object({ + deletedItems: z.object({ files: z.number().int() }), +}) + +export type V2BulkDeleteFilesResult = z.output -export const v2BulkArchiveFileItemsResultSchema = z.object({ - deletedItems: v2FileItemCountsSchema, +export const v2FileFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteFileFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), files: z.number().int() }), }) -export type V2BulkArchiveFileItemsResult = z.output +export const v2ListFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileFolderDataSchema) }, +}) + +export const v2RelocateFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileFolderDataSchema) }, +}) + +export const v2DeleteFileFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteFileFolderDataSchema) }, +}) /** * Public share state. Reuses the internal {@link shareRecordSchema}, which is @@ -457,17 +442,6 @@ export const v2DeleteFileContract = defineRouteContract({ }, }) -export const v2RestoreFileContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/files/[fileId]/restore', - params: v2FileParamsSchema, - body: v2WorkspaceScopedBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2RestoreFileResultSchema), - }, -}) - export const v2MoveFileItemsContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/move', @@ -478,13 +452,13 @@ export const v2MoveFileItemsContract = defineRouteContract({ }, }) -export const v2BulkArchiveFileItemsContract = defineRouteContract({ +export const v2BulkDeleteFilesContract = defineRouteContract({ method: 'POST', - path: '/api/v2/files/bulk-archive', - body: v2BulkArchiveFileItemsBodySchema, + path: '/api/v2/files/bulk-delete', + body: v2BulkDeleteFilesBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2BulkArchiveFileItemsResultSchema), + schema: v2DataResponse(v2BulkDeleteFilesResultSchema), }, }) diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts deleted file mode 100644 index e59abdbdab1..00000000000 --- a/apps/sim/lib/api/contracts/v2/folders.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { z } from 'zod' -import { - folderCascadeCountsSchema, - folderResourceTypeSchema, - folderScopeSchema, - servedFolderResourceTypeSchema, -} from '@/lib/api/contracts/folders' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v2CursorListResponse, - v2DataResponse, - v2SearchSchema, - v2SortFields, -} from '@/lib/api/contracts/v2/shared' - -/** - * v2 folder contracts. - * - * One folder engine serves several resource trees (`workflow`, `knowledge_base`, - * `table`), discriminated by `resourceType`. The internal surface defaults that - * field to `workflow` so an old client that never sends it keeps working across - * a deploy; the public surface has no such legacy, and defaulting it would let a - * caller silently file a knowledge-base folder into the workflow tree where the - * Knowledge page can never see it again. So v2 **requires** it on every - * operation, reusing the served enum with its default stripped. - * - * `duplicate`, `restore`, and `reorder` are not part of the public surface. - */ - -/** The served resource types, required rather than defaulted. */ -export const v2FolderResourceTypeSchema = servedFolderResourceTypeSchema.unwrap() -export type V2FolderResourceType = z.output - -/** - * Public folder projection. `userId` (the creator) and `workspaceId` (already - * known to the caller, who supplied it) are internal columns and not exposed. - */ -export const v2FolderSchema = z.object({ - id: z.string(), - resourceType: folderResourceTypeSchema, - name: z.string(), - parentId: z.string().nullable(), - /** Workflow folders only; always `false` for the other resource types. */ - locked: z.boolean(), - sortOrder: z.number(), - createdAt: z.string(), - updatedAt: z.string(), - /** Set when the folder is archived (in Recently Deleted) rather than live. */ - deletedAt: z.string().nullable(), -}) -export type V2Folder = z.output - -/** `{ folder }` payload for single-folder reads and mutations. */ -export const v2FolderDataSchema = z.object({ folder: v2FolderSchema }) -export type V2FolderData = z.output - -/** - * Delete acknowledgement. `deletedItems` reports what the cascade archived - * alongside the folder; only the key matching `resourceType` is populated. - */ -export const v2FolderDeleteDataSchema = z.object({ - id: z.string(), - deleted: z.literal(true), - deletedItems: folderCascadeCountsSchema.optional(), -}) -export type V2FolderDeleteData = z.output - -export const v2FolderParamsSchema = z.object({ - id: nonEmptyIdSchema, -}) -export type V2FolderParams = z.output - -/** Query for the id-keyed reads and the delete. */ -export const v2FolderScopedQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - resourceType: v2FolderResourceTypeSchema, -}) -export type V2FolderScopedQuery = z.output - -/** - * Sortable folder fields. `position` is the tree's manual arrangement (the - * `sort_order` column), kept as the default so a bare list still comes back in - * the order the workspace arranged it. - */ -export const v2FolderSortFields = ['position', 'name', 'createdAt', 'updatedAt'] as const - -export type V2FolderSortBy = (typeof v2FolderSortFields)[number] - -/** - * List query. `search` narrows to folders whose name matches; the result stays - * a flat list either way, so a matching folder is returned without its - * ancestors — reconstruct a tree from `parentId` only on an unsearched list. - */ -export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ - /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ - scope: folderScopeSchema.default('active'), - search: v2SearchSchema, - ...v2SortFields(v2FolderSortFields, { sortBy: 'position', sortOrder: 'asc' }), -}) -export type V2ListFoldersQuery = z.output - -export const v2CreateFolderBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - resourceType: v2FolderResourceTypeSchema, - name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), - /** Explicit `null` creates the folder at the workspace root. */ - parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), - sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), - }) - .strict() -export type V2CreateFolderBody = z.input - -/** Update body. Omitted fields keep their stored values. */ -export const v2UpdateFolderBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - resourceType: v2FolderResourceTypeSchema, - name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), - /** Workflow folders only, and changing it requires workspace `admin`. */ - locked: z.boolean().optional(), - parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), - sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), - }) - .strict() - .superRefine((body, ctx) => { - if ( - body.name === undefined && - body.locked === undefined && - body.parentId === undefined && - body.sortOrder === undefined - ) { - ctx.addIssue({ - code: 'custom', - path: ['name'], - message: 'At least one of name, locked, parentId, or sortOrder is required', - }) - } - }) -export type V2UpdateFolderBody = z.input - -/** - * Folder list. A workspace's folder tree for one resource type is small and - * bounded, so the full set is returned as a single page (`nextCursor` is always - * `null`); the canonical cursor envelope keeps the v2 list surface uniform. - */ -export const v2ListFoldersContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/folders', - query: v2ListFoldersQuerySchema, - response: { - mode: 'json', - schema: v2CursorListResponse(v2FolderSchema), - }, -}) - -export const v2CreateFolderContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/folders', - body: v2CreateFolderBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDataSchema), - }, -}) - -export const v2GetFolderContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/folders/[id]', - params: v2FolderParamsSchema, - query: v2FolderScopedQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDataSchema), - }, -}) - -export const v2UpdateFolderContract = defineRouteContract({ - method: 'PATCH', - path: '/api/v2/folders/[id]', - params: v2FolderParamsSchema, - body: v2UpdateFolderBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDataSchema), - }, -}) - -export const v2DeleteFolderContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v2/folders/[id]', - params: v2FolderParamsSchema, - query: v2FolderScopedQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDeleteDataSchema), - }, -}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 0e7ee1b1967..42e25e51ea0 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -12,13 +12,17 @@ import { v1CreateKnowledgeBaseBodySchema, v1KnowledgeSearchBodySchema, v1KnowledgeWorkspaceQuerySchema, - v1ListKnowledgeBasesQuerySchema, v1ListKnowledgeDocumentsQuerySchema, - v1UpdateKnowledgeBaseBodySchema, } from '@/lib/api/contracts/v1/knowledge' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -54,19 +58,21 @@ import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are * intentionally not exposed on the public surface. */ -export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ - id: true, - name: true, - description: true, - tokenCount: true, - embeddingModel: true, - embeddingDimension: true, - chunkingConfig: true, - docCount: true, - connectorTypes: true, - createdAt: true, - updatedAt: true, -}) +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema + .pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, + }) + .extend({ folderPath: v2FolderPathSchema }) export type V2KnowledgeBase = z.output /** `{ knowledgeBase }` payload for single-KB reads and mutations. */ @@ -238,15 +244,45 @@ export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] * folder filter. v1's own list query stays untouched — it does not implement * these, and advertising a param a route ignores is worse than not having it. */ -export const v2ListKnowledgeBasesQuerySchema = v1ListKnowledgeBasesQuerySchema.extend({ - /** Restrict to one knowledge-base folder. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), - search: v2SearchSchema, - ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), -}) +export const v2ListKnowledgeBasesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + }) + .strict() export type V2ListKnowledgeBasesQuery = z.output +export const v2CreateKnowledgeBaseBodySchema = v1CreateKnowledgeBaseBodySchema + .extend({ folderPath: v2FolderPathSchema.optional() }) + .strict() + +export const v2UpdateKnowledgeBaseBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v1CreateKnowledgeBaseBodySchema.shape.name.optional(), + description: v1CreateKnowledgeBaseBodySchema.shape.description, + chunkingConfig: v1CreateKnowledgeBaseBodySchema.shape.chunkingConfig.optional(), + folderPath: v2FolderPathSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.description === undefined && + body.chunkingConfig === undefined && + body.folderPath === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, chunkingConfig, or folderPath is required', + }) + } + }) + /** * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded * per-workspace list), so today the cursor list is a single full page @@ -267,7 +303,7 @@ export const v2ListKnowledgeBasesContract = defineRouteContract({ export const v2CreateKnowledgeBaseContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge', - body: v1CreateKnowledgeBaseBodySchema, + body: v2CreateKnowledgeBaseBodySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseDataSchema), @@ -289,7 +325,7 @@ export const v2UpdateKnowledgeBaseContract = defineRouteContract({ method: 'PUT', path: '/api/v2/knowledge/[id]', params: knowledgeBaseParamsSchema, - body: v1UpdateKnowledgeBaseBodySchema, + body: v2UpdateKnowledgeBaseBodySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseDataSchema), @@ -307,6 +343,42 @@ export const v2DeleteKnowledgeBaseContract = defineRouteContract({ }, }) +export const v2KnowledgeFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteKnowledgeFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), knowledgeBases: z.number().int() }), +}) + +export const v2ListKnowledgeFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateKnowledgeFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, +}) + +export const v2RelocateKnowledgeFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, +}) + +export const v2DeleteKnowledgeFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteKnowledgeFolderDataSchema) }, +}) + export const v2SearchKnowledgeContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/search', diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 774aceb8794..b5a3d607746 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -5,7 +5,11 @@ import { v1ListLogsQuerySchema, v1LogParamsSchema, } from '@/lib/api/contracts/v1/logs' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2FolderPathSchema, +} from '@/lib/api/contracts/v2/shared' /** * v2 logs contracts. The query schemas are reused verbatim from v1 (the request @@ -61,7 +65,7 @@ export const v2LogDetailSchema = z.object({ id: z.string().nullable(), name: z.string(), description: z.string().nullable(), - folderId: z.string().nullable(), + folderPath: v2FolderPathSchema.nullable(), userId: z.string().nullable(), workspaceId: z.string().nullable(), createdAt: z.string().nullable(), @@ -92,10 +96,29 @@ export const v2ExecutionSchema = z.object({ export type V2Execution = z.output +export const v2ListLogsQuerySchema = v1ListLogsQuerySchema + .omit({ folderIds: true }) + .extend({ + folderPaths: z + .string() + .optional() + .superRefine((value, ctx) => { + if (!value) return + const paths = value.split(',').filter(Boolean) + if ( + paths.length === 0 || + paths.some((path) => !v2FolderPathSchema.safeParse(path).success) + ) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain canonical paths' }) + } + }), + }) + .strict() + export const v2ListLogsContract = defineRouteContract({ method: 'GET', path: '/api/v2/logs', - query: v1ListLogsQuerySchema, + query: v2ListLogsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2LogListItemSchema), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 02a10f692d9..e12fcf50f2c 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,4 +1,6 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' /** * Shared building blocks for the v2 API contract surface. @@ -40,7 +42,7 @@ import { z } from 'zod' * `sortOrder` *column* on workflows and folders) — it is spelled differently * from the `sortOrder` *param* on purpose. * - **Filters** — resource-specific and enumerated, reusing the names already - * on the surface (`scope`, `folderId`, `deployedOnly`, `type`, `providerId`, + * on the surface (`scope`, `folderPath`, `deployedOnly`, `type`, `providerId`, * `resourceType`). No generic filter expression. * * Every one of these is pushed into SQL. No v2 list fetches a full result set @@ -92,6 +94,79 @@ export const v2SortOrderSchema = z.enum(['asc', 'desc']) export type V2SortOrder = z.output +function canonicalFolderPathSchema(parser: (path: string) => string[]) { + return z.string().superRefine((path, ctx) => { + try { + parser(path) + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: + error instanceof FolderPathError ? error.message : 'Path must be a canonical folder path', + }) + } + }) +} + +/** Canonical slash-prefixed folder path. `/` is the workspace root. */ +export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath) +export type V2FolderPath = z.output + +/** Canonical path that identifies a real folder rather than the virtual root. */ +export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRootFolderPath) + +export const v2FolderSchema = z.object({ + name: z.string(), + path: v2NonRootFolderPathSchema, + parentPath: v2FolderPathSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Folder = z.output + +export const v2FolderSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export const v2ListFoldersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + parentPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2FolderSortFields, { sortBy: 'name', sortOrder: 'asc' }), + }) + .strict() + +export const v2CreateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathSchema, + }) + .strict() + +export const v2RelocateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathSchema, + destinationPath: v2NonRootFolderPathSchema, + }) + .strict() + .superRefine((body, ctx) => { + if (body.path === body.destinationPath) { + ctx.addIssue({ + code: 'custom', + path: ['destinationPath'], + message: 'destinationPath must differ from path', + }) + } + }) + +export const v2DeleteFolderQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathSchema, + recursive: z.stringbool(), + }) + .strict() + /** * The `sortBy` + `sortOrder` pair for one resource. `fields` is the closed set * of sortable fields — the value reaches the query as a column, so it can never diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 3add52daf85..8a71da1d539 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { addWorkflowGroupBodySchema, cancelTableRunsBodyBaseSchema, @@ -38,8 +38,14 @@ import { v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -110,8 +116,8 @@ export const v2ApiTableSchema = z.object({ schema: z.object({ columns: z.array(tableColumnSchema) }), rowCount: z.number(), maxRows: z.number(), - /** Owning folder, or `null` when the table sits at the workspace root. */ - folderId: z.string().nullable(), + /** Canonical containing-folder path; `/` means the workspace root. */ + folderPath: v2FolderPathSchema, /** * Governance flags, read-only on the public API. They are enforced on every * write (a locked verb returns 423), but flipping them is a first-party admin @@ -143,7 +149,6 @@ export type V2ApiRow = z.output export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) export type V2TableData = z.output -/** Archive confirmation — the id of the table that was archived. */ export const v2DeleteTableDataSchema = z.object({ id: z.string() }) export type V2DeleteTableData = z.output @@ -210,21 +215,28 @@ export type V2TableSortBy = (typeof v2TableSortFields)[number] * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that * schema and have no list params. */ -export const v2ListTablesQuerySchema = v1ListTablesQuerySchema.extend({ - /** Restrict to one table folder. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), - search: v2SearchSchema, - ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), - limit: z.coerce - .number() - .optional() - .default(100) - .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), - cursor: z.string().min(1).optional(), -}) +export const v2ListTablesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), + }) + .strict() export type V2ListTablesQuery = z.output +export const v2CreateTableBodySchema = v1CreateTableBodySchema + .omit({ folderId: true }) + .extend({ folderPath: v2FolderPathSchema.optional() }) + .strict() + /** * Table list. `listTables` returns every table in the workspace (a small, * bounded per-workspace set), so today the cursor list is a single full page @@ -246,7 +258,7 @@ export const v2ListTablesContract = defineRouteContract({ export const v2CreateTableContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables', - body: v1CreateTableBodySchema, + body: v2CreateTableBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableDataSchema), @@ -266,8 +278,8 @@ export const v2GetTableContract = defineRouteContract({ /** * Table update. Every field is optional but at least one must be present: - * `name` renames and `folderId` moves the table (explicit `null` moves it to - * the workspace root; omission leaves the placement untouched). + * `name` renames and `folderPath` moves the table. Omission leaves placement + * untouched; `/` moves it to the workspace root. * * `locks` is deliberately **not** accepted here, which is why this body is * declared rather than reusing the first-party `updateTableBodySchema`. The @@ -281,11 +293,11 @@ export const v2UpdateTableBodySchema = z .object({ workspaceId: workspaceIdSchema, name: tableNameSchema.optional(), - folderId: folderIdSchema.nullable().optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict() .superRefine((body, ctx) => { - if (body.name === undefined && body.folderId === undefined) { + if (body.name === undefined && body.folderPath === undefined) { ctx.addIssue({ code: 'custom', message: 'Provide a new name or folder', @@ -306,6 +318,42 @@ export const v2UpdateTableContract = defineRouteContract({ }) export type V2UpdateTableBody = z.input +export const v2TableFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteTableFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), tables: z.number().int() }), +}) + +export const v2ListTableFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateTableFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, +}) + +export const v2RelocateTableFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, +}) + +export const v2DeleteTableFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteTableFolderDataSchema) }, +}) + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -543,22 +591,6 @@ export const v2UpsertTableRowContract = defineRouteContract({ export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) export type V2WorkspaceScopedBody = z.input -/** - * Un-archives a table archived by `DELETE /api/v2/tables/[tableId]`. Resolves - * the table with archived rows included, so it is the one table endpoint whose - * target is expected NOT to be active. - */ -export const v2RestoreTableContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/[tableId]/restore', - params: tableIdParamsSchema, - body: v2WorkspaceScopedBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2TableDataSchema), - }, -}) - /** * A saved view: a named preset of `{ filter, sort, column layout }` over a * table. Presentation state only — a view narrows what a reader sees by @@ -953,7 +985,7 @@ export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ .object({ type: z.literal('new'), name: tableNameSchema, - folderId: folderIdSchema.optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict(), z diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 119ae6ba1a0..10da02345c9 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -4,19 +4,25 @@ import { deploymentVersionParamsSchema, deploymentVersionSchema, } from '@/lib/api/contracts/deployments' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + V1_IMPORT_NAME_MAX_LENGTH, v1DeployWorkflowDataSchema, v1ImportWorkflowBodySchema, - v1ImportWorkflowDataSchema, - v1ListWorkflowsQuerySchema, v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -63,10 +69,17 @@ export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor * carries the sort it was minted under and is rejected once that changes. */ -export const v2ListWorkflowsQuerySchema = v1ListWorkflowsQuerySchema.extend({ - search: v2SearchSchema, - ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), -}) +export const v2ListWorkflowsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathSchema.optional(), + deployedOnly: booleanQueryFlagSchema.optional().default(false), + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), + search: v2SearchSchema, + ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), + }) + .strict() export type V2ListWorkflowsQuery = z.output @@ -74,7 +87,7 @@ export const v2WorkflowListItemSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), - folderId: z.string().nullable(), + folderPath: v2FolderPathSchema, workspaceId: z.string(), isDeployed: z.boolean(), deployedAt: z.string().nullable(), @@ -145,8 +158,8 @@ export const v2CreateWorkflowBodySchema = z workspaceId: workspaceIdSchema, name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), description: z.string().max(50_000, 'description is too long').nullable().optional(), - /** Explicit `null` (or omission) creates the workflow at the workspace root. */ - folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + /** Omission creates the workflow at the workspace root. */ + folderPath: v2FolderPathSchema.optional(), }) .strict() export type V2CreateWorkflowBody = z.input @@ -156,24 +169,24 @@ export const v2UpdateWorkflowBodySchema = z .object({ name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), description: z.string().max(50_000, 'description is too long').nullable().optional(), - folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict() .superRefine((body, ctx) => { - if (body.name === undefined && body.description === undefined && body.folderId === undefined) { + if ( + body.name === undefined && + body.description === undefined && + body.folderPath === undefined + ) { ctx.addIssue({ code: 'custom', path: ['name'], - message: 'At least one of name, description, or folderId is required', + message: 'At least one of name, description, or folderPath is required', }) } }) export type V2UpdateWorkflowBody = z.input -/** - * Delete acknowledgement. Deletion archives the workflow (it lands in Recently - * Deleted) rather than dropping its rows, so runs and logs stay attributable. - */ export const v2DeleteWorkflowDataSchema = z.object({ id: z.string(), deleted: z.literal(true), @@ -211,6 +224,45 @@ export const v2DeleteWorkflowContract = defineRouteContract({ }, }) +export const v2WorkflowFolderSchema = v2FolderSchema.extend({ locked: z.boolean() }) +export type V2WorkflowFolder = z.output + +export const v2WorkflowFolderDataSchema = z.object({ folder: v2WorkflowFolderSchema }) + +export const v2DeleteWorkflowFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), workflows: z.number().int() }), +}) + +export const v2ListWorkflowFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowFolderSchema) }, +}) + +export const v2CreateWorkflowFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, +}) + +export const v2RelocateWorkflowFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, +}) + +export const v2DeleteWorkflowFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteWorkflowFolderDataSchema) }, +}) + /** * A deployment version as the public surface sees it: the internal row minus * `createdBy`, which is a raw user id with no public resolution path — @@ -439,27 +491,60 @@ export const v2CancelWorkflowExecutionContract = defineRouteContract({ }, }) -/** - * Export/import reuse the v1 payload and body schemas verbatim — the portable - * envelope must round-trip across both surfaces — with only the response - * envelope upgraded. - */ +export const v2WorkflowExportPayloadSchema = v1WorkflowExportPayloadSchema.extend({ + workflow: v1WorkflowExportPayloadSchema.shape.workflow + .omit({ folderId: true }) + .extend({ folderPath: v2FolderPathSchema }), +}) + +export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema + .omit({ folderId: true, name: true, description: true }) + .extend({ + folderPath: v2FolderPathSchema.optional(), + name: z + .string() + .min(1, 'name cannot be empty') + .max( + V1_IMPORT_NAME_MAX_LENGTH, + `name must be at most ${V1_IMPORT_NAME_MAX_LENGTH} characters` + ) + .optional(), + description: z + .string() + .max( + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + `description must be at most ${V1_IMPORT_DESCRIPTION_MAX_LENGTH} characters` + ) + .optional(), + }) + .strict() + +export const v2ImportWorkflowDataSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + workspaceId: z.string(), + folderPath: v2FolderPathSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + export const v2ExportWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/export', params: workflowIdParamsSchema, response: { mode: 'json', - schema: v2DataResponse(v1WorkflowExportPayloadSchema), + schema: v2DataResponse(v2WorkflowExportPayloadSchema), }, }) export const v2ImportWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/import', - body: v1ImportWorkflowBodySchema, + body: v2ImportWorkflowBodySchema, response: { mode: 'json', - schema: v2DataResponse(v1ImportWorkflowDataSchema), + schema: v2DataResponse(v2ImportWorkflowDataSchema), }, }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 602d71664cb..c0fa82f81c6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -17,6 +17,7 @@ import { encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { generateRequestId } from '@/lib/core/utils/request' +import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { deleteKnowledgeBase, getKnowledgeBases, @@ -34,13 +35,7 @@ import { resolveWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - performCreateFolder, - performDeleteFolder, - performDeleteWorkflow, - performUpdateFolder, - performUpdateWorkflow, -} from '@/lib/workflows/orchestration' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { @@ -503,7 +498,8 @@ function makeWorkflowFolderEnsurer( continue } await assertFolderMutable(parentId) - const created = await performCreateFolder({ + const created = await createFolder({ + resourceType: 'workflow', workspaceId, userId, name: segment, @@ -690,7 +686,8 @@ async function mutateWorkflows( continue } await assertFolderMutable(targetFolderId) - const result = await performUpdateFolder({ + const result = await updateFolder({ + resourceType: 'workflow', folderId: ref.folderId, workspaceId, userId: context.userId, @@ -989,7 +986,12 @@ async function removeWorkflowPath( if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } await assertFolderMutable(folderId) - const result = await performDeleteFolder({ folderId, workspaceId, userId: context.userId }) + const result = await deleteFolder({ + resourceType: 'workflow', + folderId, + workspaceId, + userId: context.userId, + }) if (!result.success) { return { from: path, diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index e8e3a19f06a..8dea1441f48 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -57,7 +57,7 @@ export interface FolderDeleteRejection { /** * Everything that differs between the four folder-bearing resource types, expressed as - * data. The folder engine in `lib/folders/lifecycle.ts` and the cascade in + * data. The folder engine in `lib/folders/orchestration.ts` and the cascade in * `lib/folders/cascade.ts` read this instead of branching on `resourceType`, so * create/update/delete/restore/reorder each exist exactly once and adding a fifth * foldered resource means adding one entry here. diff --git a/apps/sim/lib/folders/locks.ts b/apps/sim/lib/folders/locks.ts new file mode 100644 index 00000000000..4942d3aaa68 --- /dev/null +++ b/apps/sim/lib/folders/locks.ts @@ -0,0 +1,36 @@ +import { db } from '@sim/db' +import { sql } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { DbOrTx } from '@/lib/db/types' + +const FOLDER_MUTATION_LOCK_TIMEOUT_MS = 5_000 + +/** Serializes every writer for one workspace resource-folder tree. */ +export async function acquireFolderMutationLock( + tx: DbOrTx, + workspaceId: string, + resourceType: FolderResourceType +): Promise { + await tx.execute( + sql`select set_config('lock_timeout', ${`${FOLDER_MUTATION_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`resource_folders:${resourceType}:${workspaceId}`}, 0))` + ) +} + +/** + * Keeps path resolution stable while a resource mutation commits against the + * resolved folder. The callback may use `tx` for reads; folder writers for the + * same workspace and resource type cannot proceed until it returns. + */ +export async function withFolderTreeLock( + workspaceId: string, + resourceType: FolderResourceType, + operation: (tx: DbOrTx) => Promise +): Promise { + return db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) + return operation(tx) + }) +} diff --git a/apps/sim/lib/folders/lifecycle.test.ts b/apps/sim/lib/folders/orchestration.test.ts similarity index 88% rename from apps/sim/lib/folders/lifecycle.test.ts rename to apps/sim/lib/folders/orchestration.test.ts index 8da406145db..1a3d1c9fdb2 100644 --- a/apps/sim/lib/folders/lifecycle.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -23,6 +23,7 @@ const { mockRestoreFolderChildren, mockRestoreFolderRows, mockWouldCreateFolderCycle, + mockLoadActiveFolderPathIndex, resourceConfig, } = vi.hoisted(() => ({ mockArchiveFolderCascade: vi.fn(), @@ -35,6 +36,7 @@ const { mockRestoreFolderChildren: vi.fn(), mockRestoreFolderRows: vi.fn(), mockWouldCreateFolderCycle: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), resourceConfig: { current: {} as Record }, })) @@ -58,13 +60,24 @@ vi.mock('@/lib/folders/config', () => ({ vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName })) -vi.mock('@/lib/folders/queries', () => ({ wouldCreateFolderCycle: mockWouldCreateFolderCycle })) +vi.mock('@/lib/folders/queries', () => ({ + wouldCreateFolderCycle: mockWouldCreateFolderCycle, + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' +import { + createFolder, + createFolderAtPath, + deleteFolder, + deleteFolderByPath, + relocateFolderByPath, + restoreFolder, + updateFolder, +} from '@/lib/folders/orchestration' const CHILD_TABLE = { name: 'child_table' } @@ -124,6 +137,11 @@ beforeEach(() => { resetDbChainMock() setConfig() mockWouldCreateFolderCycle.mockResolvedValue(false) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) mockDeduplicateFolderName.mockImplementation( async (_tx: unknown, _ws: string, _parent: string | null, name: string) => name ) @@ -312,6 +330,87 @@ describe('createFolder', () => { }) }) +describe('path-owned folder mutations', () => { + it('creates only the addressed leaf under an existing canonical parent path', async () => { + const parent = folderRow({ id: 'parent-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['parent-1', parent]]), + pathById: new Map([['parent-1', '/Reports']]), + idByPath: new Map([['/Reports', 'parent-1']]), + }) + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + folderRow({ id: 'folder-2', name: 'Q1', parentId: 'parent-1', sortOrder: -1 }), + ]) + + const result = await createFolderAtPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports/Q1', + }) + + expect(result).toMatchObject({ success: true, path: '/Reports/Q1' }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Q1', parentId: 'parent-1' }) + ) + }) + + it('rejects relocating a folder beneath its own descendant before writing', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + destinationPath: '/Reports/Archive', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('requires recursive deletion when the path has descendant folders', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + const child = folderRow({ id: 'folder-2', name: 'Q1', parentId: 'folder-1' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([ + ['folder-1', source], + ['folder-2', child], + ]), + pathById: new Map([ + ['folder-1', '/Reports'], + ['folder-2', '/Reports/Q1'], + ]), + idByPath: new Map([ + ['/Reports', 'folder-1'], + ['/Reports/Q1', 'folder-2'], + ]), + }) + + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + recursive: false, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder is not empty', + errorCode: 'conflict', + }) + expect(mockArchiveFolderCascade).not.toHaveBeenCalled() + }) +}) + describe('updateFolder', () => { const baseUpdate = { resourceType: 'table' as const, diff --git a/apps/sim/lib/folders/lifecycle.ts b/apps/sim/lib/folders/orchestration.ts similarity index 57% rename from apps/sim/lib/folders/lifecycle.ts rename to apps/sim/lib/folders/orchestration.ts index 405c8663797..0514a46a31a 100644 --- a/apps/sim/lib/folders/lifecycle.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -2,11 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { withTransactionRetry } from '@/lib/db/transaction' import type { DbOrTx } from '@/lib/db/types' import { archiveFolderCascade, @@ -17,12 +18,19 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { folderResourceConfig } from '@/lib/folders/config' +import { acquireFolderMutationLock, withFolderTreeLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' -import { wouldCreateFolderCycle } from '@/lib/folders/queries' +import { + type FolderPathIndex, + folderNameFromPath, + parentFolderPath, + requireNonRootFolderPath, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, wouldCreateFolderCycle } from '@/lib/folders/queries' import type { FolderMutationErrorCode } from '@/lib/folders/status' import { notifyFolderResourceChanged } from '@/lib/realtime/notify' -const logger = createLogger('FolderLifecycle') +const logger = createLogger('FolderOrchestration') const DUPLICATE_NAME_ERROR = 'A folder with this name already exists in this location' @@ -60,6 +68,7 @@ export interface DeleteFolderParams { workspaceId: string userId: string folderName?: string + folderPath?: string } export interface DeleteFolderResult { @@ -84,6 +93,271 @@ export interface RestoreFolderResult { restoredItems?: FolderCascadeCountsApi } +export interface FolderPathMutationResult extends FolderMutationResult { + path?: string +} + +export interface DeleteFolderByPathParams { + resourceType: FolderResourceType + workspaceId: string + userId: string + path: string + recursive: boolean +} + +export interface DeleteFolderByPathResult extends DeleteFolderResult { + path?: string +} + +function validatePathLeafName(path: string): string { + const name = folderNameFromPath(path) + if (name.trim() !== name || name.length === 0 || name.length > 255) { + throw new Error('Folder path leaf must be between 1 and 255 characters without outer spaces') + } + return name +} + +function resolveRequiredFolderId(index: FolderPathIndex, path: string): string { + const folderId = index.idByPath.get(path) + if (!folderId) throw new Error('Folder not found') + return folderId +} + +function isEffectivelyLocked(index: FolderPathIndex, id: string) { + let currentId: string | null = id + while (currentId) { + const row = index.rowById.get(currentId) + if (!row) throw new Error('Folder hierarchy references a missing ancestor') + if (row.locked) return true + currentId = row.parentId + } + return false +} + +function pathMutationError(error: unknown): FolderPathMutationResult { + const message = getErrorMessage(error, 'Internal server error') + if (message === 'Folder not found' || message === 'Parent folder not found') { + return { success: false, error: message, errorCode: 'not_found' } + } + if ( + message === DUPLICATE_NAME_ERROR || + message === 'Folder is not empty' || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message === 'Folder is locked') { + return { success: false, error: message, errorCode: 'locked' } + } + if ( + message.includes('Folder path') || + message.includes('root path') || + message.includes('descendant') || + message.includes('canonical folder path') + ) { + return { success: false, error: message, errorCode: 'validation' } + } + logger.error('Folder path mutation failed', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } +} + +/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ +export async function createFolderAtPath( + params: Omit & { path: string } +): Promise { + try { + requireNonRootFolderPath(params.path) + const name = validatePathLeafName(params.path) + const folder = await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + if (index.idByPath.has(params.path)) throw new Error(DUPLICATE_NAME_ERROR) + + const parentPath = parentFolderPath(params.path) + const parentId = parentPath === '/' ? null : index.idByPath.get(parentPath) + if (parentPath !== '/' && !parentId) throw new Error('Parent folder not found') + if ( + parentId && + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, parentId) + ) { + throw new Error('Folder is locked') + } + + const sortOrder = await nextFolderSortOrder( + params.resourceType, + params.workspaceId, + parentId, + tx + ) + const [created] = await tx + .insert(folderTable) + .values({ + id: generateId(), + resourceType: params.resourceType, + name, + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return created + }, + { label: 'create-folder-at-path' } + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, + metadata: { path: params.path, folderResourceType: params.resourceType }, + }) + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + return { success: true, folder, path: params.path } + } catch (error) { + return pathMutationError(error) + } +} + +/** Renames, moves, or both by replacing one canonical path with another. */ +export async function relocateFolderByPath(params: { + resourceType: FolderResourceType + workspaceId: string + userId: string + path: string + destinationPath: string +}): Promise { + try { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + const name = validatePathLeafName(params.destinationPath) + + const folder = await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if (index.idByPath.has(params.destinationPath)) throw new Error(DUPLICATE_NAME_ERROR) + + const destinationParentPath = parentFolderPath(params.destinationPath) + if ( + destinationParentPath === params.path || + destinationParentPath.startsWith(`${params.path}/`) + ) { + throw new Error('Cannot move a folder into one of its descendants') + } + const resolvedParentId = + destinationParentPath === '/' ? null : index.idByPath.get(destinationParentPath) + if (resolvedParentId === undefined) throw new Error('Parent folder not found') + const parentId = resolvedParentId + + const config = folderResourceConfig(params.resourceType) + if ( + config.supportsLocking && + (isEffectivelyLocked(index, folderId) || + (parentId !== null && isEffectivelyLocked(index, parentId))) + ) { + throw new Error('Folder is locked') + } + + const [updated] = await tx + .update(folderTable) + .set({ name, parentId, updatedAt: new Date() }) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + if (!updated) throw new Error('Folder not found') + return updated + }, + { label: 'relocate-folder-by-path' } + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, + metadata: { + sourcePath: params.path, + destinationPath: params.destinationPath, + folderResourceType: params.resourceType, + }, + }) + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + return { success: true, folder, path: params.destinationPath } + } catch (error) { + return pathMutationError(error) + } +} + +/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ +export async function deleteFolderByPath( + params: DeleteFolderByPathParams +): Promise { + try { + requireNonRootFolderPath(params.path) + const result = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if ( + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, folderId) + ) { + throw new Error('Folder is locked') + } + + if (!params.recursive) { + const hasChildFolder = [...index.pathById.values()].some((candidate) => + candidate.startsWith(`${params.path}/`) + ) + const config = folderResourceConfig(params.resourceType) + const [child] = await tx + .select({ id: config.idColumn }) + .from(config.table) + .where( + and( + eq(config.folderIdColumn, folderId), + eq(config.workspaceColumn, params.workspaceId), + isNull(config.deletedColumn), + config.scope + ) + ) + .limit(1) + if (hasChildFolder || child) throw new Error('Folder is not empty') + } + + const row = index.rowById.get(folderId) + if (!row) throw new Error('Folder not found') + return deleteFolderWithoutTreeLock({ + resourceType: params.resourceType, + folderId, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: row.name, + folderPath: params.path, + }) + }) + return { ...result, path: result.success ? params.path : undefined } + } catch (error) { + return pathMutationError(error) + } +} + /** * Verifies that a prospective parent folder exists, belongs to the target workspace, is of * the same `resourceType`, and is not archived. @@ -95,9 +369,10 @@ export interface RestoreFolderResult { async function assertParentFolderInWorkspace( resourceType: FolderResourceType, parentId: string, - workspaceId: string + workspaceId: string, + tx: DbOrTx = db ): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { - const [parent] = await db + const [parent] = await tx .select({ workspaceId: folderTable.workspaceId, archivedAt: folderTable.deletedAt, @@ -177,35 +452,46 @@ export async function createFolder(params: CreateFolderParams): Promise { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + if (parentId) { + const parentError = await assertParentFolderInWorkspace( + params.resourceType, + parentId, + params.workspaceId, + tx + ) + if (parentError) return { parentError } + } - const [folder] = await db - .insert(folderTable) - .values({ - id: folderId, - resourceType: params.resourceType, - name: params.name.trim(), - userId: params.userId, - workspaceId: params.workspaceId, - parentId, - sortOrder, - }) - .returning() + const sortOrder = + params.sortOrder !== undefined + ? params.sortOrder + : await nextFolderSortOrder(params.resourceType, params.workspaceId, parentId, tx) + + const [folder] = await tx + .insert(folderTable) + .values({ + id: folderId, + resourceType: params.resourceType, + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return { folder } + }, + { label: 'create-folder' } + ) + if ('parentError' in outcome) return { success: false, ...outcome.parentError } + const { folder } = outcome logger.info('Created folder', { folderId, @@ -255,28 +541,6 @@ export async function updateFolder(params: UpdateFolderParams): Promise`: the loose type is what // let `color`/`isExpanded` survive an earlier cutover after the create path dropped them. const updates: Partial = { updatedAt: new Date() } @@ -294,18 +558,52 @@ export async function updateFolder(params: UpdateFolderParams): Promise { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + if (params.parentId) { + const parentError = await assertParentFolderInWorkspace( + params.resourceType, + params.parentId, + params.workspaceId, + tx + ) + if (parentError) return { parentError } + + const wouldCreateCycle = await wouldCreateFolderCycle( + params.folderId, + params.parentId, + params.resourceType, + tx + ) + if (wouldCreateCycle) { + return { + parentError: { + error: 'Cannot create circular folder reference', + errorCode: 'validation' as const, + }, + } + } + } + + const [folder] = await tx + .update(folderTable) + .set(updates) + .where( + and( + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + return { folder } + }, + { label: 'update-folder' } + ) + if ('parentError' in outcome) return { success: false, ...outcome.parentError } + const { folder } = outcome if (!folder) { return { success: false, error: 'Folder not found', errorCode: 'not_found' } @@ -340,7 +638,15 @@ export async function updateFolder(params: UpdateFolderParams): Promise { - const { resourceType, folderId, workspaceId, userId, folderName } = params + return withFolderTreeLock(params.workspaceId, params.resourceType, () => + deleteFolderWithoutTreeLock(params) + ) +} + +async function deleteFolderWithoutTreeLock( + params: DeleteFolderParams +): Promise { + const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) const [existing] = await db @@ -387,9 +693,10 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { +async function restoreFolderWithoutTreeLock( + params: RestoreFolderParams +): Promise { const { resourceType, folderId, workspaceId, userId, folderName } = params const config = folderResourceConfig(resourceType) @@ -563,3 +872,10 @@ export async function restoreFolder(params: RestoreFolderParams): Promise { + return withFolderTreeLock(params.workspaceId, params.resourceType, () => + restoreFolderWithoutTreeLock(params) + ) +} diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts new file mode 100644 index 00000000000..0048e480251 --- /dev/null +++ b/apps/sim/lib/folders/paths.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + v2CreateFolderBodySchema, + v2DeleteFolderQuerySchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, +} from '@/lib/api/contracts/v2/shared' +import { + buildFolderPath, + buildFolderPathIndex, + encodeFolderPathSegment, + MAX_FOLDER_PATH_SEGMENTS, + parseFolderPath, + ROOT_FOLDER_PATH, +} from '@/lib/folders/paths' + +describe('canonical folder paths', () => { + it('round-trips exact names without case or Unicode normalization', () => { + const segments = ['Reports', 'Q1 / 100%', 'é', '.', '..'] + const path = buildFolderPath(segments) + + expect(path).toBe('/Reports/Q1%20%2F%20100%25/e%CC%81/%2E/%2E%2E') + expect(parseFolderPath(path)).toEqual(segments) + expect(buildFolderPath([])).toBe(ROOT_FOLDER_PATH) + expect(encodeFolderPathSegment('é')).not.toBe(encodeFolderPathSegment('é')) + }) + + it.each([ + '', + 'Reports', + '/Reports/', + '/Reports//Q1', + '/Reports Q1', + '/R%C3%A9sum%c3%a9', + '/%52eports', + '/.', + '/..', + '/%E0%A4%A', + ])('rejects noncanonical path %s', (path) => { + expect(() => parseFolderPath(path)).toThrow() + }) + + it('builds a bidirectional index and rejects corrupt hierarchies', () => { + const rows = [ + { id: 'a', name: 'Reports', parentId: null }, + { id: 'b', name: 'Q1', parentId: 'a' }, + ] + const index = buildFolderPathIndex(rows) + + expect(index.pathById.get('b')).toBe('/Reports/Q1') + expect(index.idByPath.get('/Reports/Q1')).toBe('b') + expect( + buildFolderPathIndex([ + { id: 'upper', name: 'Reports', parentId: null }, + { id: 'lower', name: 'reports', parentId: null }, + ]).idByPath + ).toEqual( + new Map([ + ['/Reports', 'upper'], + ['/reports', 'lower'], + ]) + ) + expect(() => + buildFolderPathIndex([ + { id: 'a', name: 'Reports', parentId: null }, + { id: 'b', name: 'Reports', parentId: null }, + ]) + ).toThrow('duplicate path') + expect(() => buildFolderPathIndex([{ id: 'a', name: 'Reports', parentId: 'missing' }])).toThrow( + 'missing folder' + ) + expect(() => + buildFolderPathIndex([ + { id: 'a', name: 'A', parentId: 'b' }, + { id: 'b', name: 'B', parentId: 'a' }, + ]) + ).toThrow('cycle') + }) + + it('enforces segment and byte limits', () => { + expect(() => + buildFolderPath(Array.from({ length: MAX_FOLDER_PATH_SEGMENTS + 1 }, () => 'x')) + ).toThrow('segments') + expect(() => buildFolderPath(['x'.repeat(4096)])).toThrow('bytes') + }) + + it('keeps public folder mutations path-only and rejects the virtual root', () => { + expect(v2ListFoldersQuerySchema.parse({ workspaceId: 'workspace-1', parentPath: '/' })).toEqual( + { + workspaceId: 'workspace-1', + parentPath: '/', + sortBy: 'name', + sortOrder: 'asc', + } + ) + expect( + v2CreateFolderBodySchema.safeParse({ workspaceId: 'workspace-1', path: '/' }).success + ).toBe(false) + expect( + v2CreateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/Reports', + folderId: 'internal-id', + }).success + ).toBe(false) + expect( + v2RelocateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/Reports', + destinationPath: '/Reports', + }).success + ).toBe(false) + expect( + v2DeleteFolderQuerySchema.parse({ + workspaceId: 'workspace-1', + path: '/Reports', + recursive: 'true', + }).recursive + ).toBe(true) + expect( + v2DeleteFolderQuerySchema.safeParse({ workspaceId: 'workspace-1', path: '/Reports' }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts new file mode 100644 index 00000000000..9a8524e6f42 --- /dev/null +++ b/apps/sim/lib/folders/paths.ts @@ -0,0 +1,184 @@ +import type { folder } from '@sim/db/schema' + +export const ROOT_FOLDER_PATH = '/' +export const MAX_FOLDER_PATH_SEGMENTS = 64 +export const MAX_FOLDER_PATH_BYTES = 4096 + +type FolderPathRow = Pick + +export class FolderPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'FolderPathError' + } +} + +export interface FolderPathIndex { + rowById: ReadonlyMap + pathById: ReadonlyMap + idByPath: ReadonlyMap +} + +export interface FolderPathView { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +function encodedByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +/** Encodes one stored folder name without normalizing its case or Unicode form. */ +export function encodeFolderPathSegment(name: string): string { + if (name.length === 0) throw new FolderPathError('Folder names cannot be empty') + + if (name === '.') return '%2E' + if (name === '..') return '%2E%2E' + + try { + return encodeURIComponent(name).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) + } catch { + throw new FolderPathError('Folder name contains invalid Unicode') + } +} + +/** Builds the canonical public path for a decoded sequence of folder names. */ +export function buildFolderPath(segments: readonly string[]): string { + if (segments.length === 0) return ROOT_FOLDER_PATH + if (segments.length > MAX_FOLDER_PATH_SEGMENTS) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_SEGMENTS} segments`) + } + + const path = `/${segments.map(encodeFolderPathSegment).join('/')}` + if (encodedByteLength(path) > MAX_FOLDER_PATH_BYTES) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_BYTES} bytes`) + } + return path +} + +/** + * Parses a canonical public folder path. Accepted paths are byte-for-byte canonical: callers + * cannot use alternate escapes, raw reserved characters, or normalization aliases. + */ +export function parseFolderPath(path: string): string[] { + if (path === ROOT_FOLDER_PATH) return [] + if (!path.startsWith('/') || path.endsWith('/') || path.includes('//')) { + throw new FolderPathError('Path must be a canonical folder path') + } + if (encodedByteLength(path) > MAX_FOLDER_PATH_BYTES) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_BYTES} bytes`) + } + + const encodedSegments = path.slice(1).split('/') + if (encodedSegments.length > MAX_FOLDER_PATH_SEGMENTS) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_SEGMENTS} segments`) + } + + return encodedSegments.map((encodedSegment) => { + let decoded: string + try { + decoded = decodeURIComponent(encodedSegment) + } catch { + throw new FolderPathError('Path must be a canonical folder path') + } + + if (encodeFolderPathSegment(decoded) !== encodedSegment) { + throw new FolderPathError('Path must be a canonical folder path') + } + return decoded + }) +} + +export function requireNonRootFolderPath(path: string): string[] { + const segments = parseFolderPath(path) + if (segments.length === 0) throw new FolderPathError('The root path cannot be mutated') + return segments +} + +export function parentFolderPath(path: string): string { + const segments = requireNonRootFolderPath(path) + return buildFolderPath(segments.slice(0, -1)) +} + +export function folderNameFromPath(path: string): string { + const segments = requireNonRootFolderPath(path) + return segments[segments.length - 1] +} + +/** Builds a lossless, fail-fast bidirectional index over one active resource folder tree. */ +export function buildFolderPathIndex( + rows: readonly Row[] +): FolderPathIndex { + const rowById = new Map() + for (const row of rows) { + if (rowById.has(row.id)) throw new FolderPathError(`Duplicate folder id: ${row.id}`) + rowById.set(row.id, row) + } + + const pathById = new Map() + const idByPath = new Map() + const visiting = new Set() + + const resolvePath = (folderId: string): string => { + const resolved = pathById.get(folderId) + if (resolved) return resolved + if (visiting.has(folderId)) throw new FolderPathError('Folder hierarchy contains a cycle') + + const row = rowById.get(folderId) + if (!row) throw new FolderPathError(`Folder hierarchy references missing folder: ${folderId}`) + + visiting.add(folderId) + const parentPath = row.parentId ? resolvePath(row.parentId) : ROOT_FOLDER_PATH + const path = + parentPath === ROOT_FOLDER_PATH + ? `/${encodeFolderPathSegment(row.name)}` + : `${parentPath}/${encodeFolderPathSegment(row.name)}` + visiting.delete(folderId) + + parseFolderPath(path) + const duplicateId = idByPath.get(path) + if (duplicateId && duplicateId !== folderId) { + throw new FolderPathError(`Folder hierarchy contains duplicate path: ${path}`) + } + pathById.set(folderId, path) + idByPath.set(path, folderId) + return path + } + + for (const row of rows) resolvePath(row.id) + + return { rowById, pathById, idByPath } +} + +export function toFolderPathView( + row: Pick, + path: string +): FolderPathView { + return { + name: row.name, + path, + parentPath: parentFolderPath(path), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export function isFolderPathEffectivelyLocked( + index: FolderPathIndex, + folderId: string +): boolean { + let currentId: string | null = folderId + while (currentId) { + const row = index.rowById.get(currentId) + if (!row) throw new FolderPathError('Folder hierarchy references a missing ancestor') + if (row.locked) return true + currentId = row.parentId + } + return false +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 98f616e1a21..7811476e82d 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -2,11 +2,14 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' -import type { V2FolderSortBy } from '@/lib/api/contracts/v2/folders' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import type { DbOrTx } from '@/lib/db/types' +import { buildFolderPathIndex, type FolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' +export type FolderSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' + /** * Normalizes a `folder` row to the `FolderApi` wire shape (timestamps as ISO strings). * @@ -31,7 +34,8 @@ export function toFolderApi(row: typeof folder.$inferSelect): FolderApi { export async function wouldCreateFolderCycle( folderId: string, parentId: string, - resourceType: FolderResourceType + resourceType: FolderResourceType, + tx: DbOrTx = db ): Promise { let currentParentId: string | null = parentId const visited = new Set() @@ -40,7 +44,7 @@ export async function wouldCreateFolderCycle( if (visited.has(currentParentId) || currentParentId === folderId) return true visited.add(currentParentId) - const [parent] = await db + const [parent] = await tx .select({ parentId: folder.parentId }) .from(folder) .where(and(eq(folder.id, currentParentId), eq(folder.resourceType, resourceType))) @@ -148,15 +152,77 @@ const FOLDER_SORTS = { name: [folder.name, folder.createdAt], createdAt: [folder.createdAt], updatedAt: [folder.updatedAt, folder.createdAt], -} satisfies Record +} satisfies Record interface ListFoldersOptions { /** Case-insensitive substring match on the folder name. */ search?: string - sortBy?: V2FolderSortBy + sortBy?: FolderSortBy sortOrder?: V2SortOrder } +interface ListActiveFolderRowsOptions { + parentId?: string | null + search?: string + sortBy?: Exclude + sortOrder?: V2SortOrder +} + +export async function loadActiveFolderPathIndex( + workspaceId: string, + resourceType: FolderResourceType, + tx: DbOrTx = db +): Promise> { + const rows = await tx + .select() + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt) + ) + ) + + return buildFolderPathIndex(rows) +} + +/** Resolves a canonical folder path to its internal id; `/` resolves to the root sentinel. */ +export function resolveFolderPathFromIndex( + index: FolderPathIndex, + path: string +): string | null | undefined { + return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) +} + +export async function listActiveFolderRows( + workspaceId: string, + resourceType: FolderResourceType, + options: ListActiveFolderRowsOptions = {}, + tx: DbOrTx = db +): Promise> { + const parentFilter = + options.parentId === undefined + ? undefined + : options.parentId === null + ? isNull(folder.parentId) + : eq(folder.parentId, options.parentId) + + return tx + .select() + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt), + parentFilter, + searchFilter(folder.name, options.search) + ) + ) + .orderBy(...listOrderBy(FOLDER_SORTS[options.sortBy ?? 'name'], options.sortOrder ?? 'asc')) +} + /** * Shared by `GET /api/folders`, the public v2 list, and the sidebar prefetch so * the query never drifts between them. Search and sort are applied in the diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 6131ae58fd0..0991e17a5f3 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -135,7 +135,8 @@ const KNOWLEDGE_BASE_SORTS = { interface GetKnowledgeBasesOptions { /** Restrict to one knowledge-base folder. */ - folderId?: string + /** `undefined` lists every folder, `null` lists only workspace-root resources. */ + folderId?: string | null /** Case-insensitive substring match on the knowledge base name. */ search?: string sortBy?: V2KnowledgeBaseSortBy @@ -201,7 +202,11 @@ export async function getKnowledgeBases( .where( and( scopeCondition, - folderId ? eq(knowledgeBase.folderId, folderId) : undefined, + folderId === undefined + ? undefined + : folderId === null + ? isNull(knowledgeBase.folderId) + : eq(knowledgeBase.folderId, folderId), searchFilter(knowledgeBase.name, search), workspaceId ? // When filtering by workspace diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index ec7055f9c5f..4be469d5ffe 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import type { FolderResourceType } from '@/lib/api/contracts/folders' import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { restoreFolder } from '@/lib/folders/orchestration' import { getRestorableKnowledgeBase, performRestoreKnowledgeBase, @@ -11,7 +12,7 @@ import { import { performRestoreTable } from '@/lib/table/orchestration' import { getTableById } from '@/lib/table/service' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRestoreFolder, performRestoreWorkflow } from '@/lib/workflows/orchestration' +import { performRestoreWorkflow } from '@/lib/workflows/orchestration' import { getWorkflowById } from '@/lib/workflows/utils' import { performRestoreWorkspaceFile, @@ -168,7 +169,7 @@ export async function performRestoreResource( return { success: false, error: 'Folder not found' } } - const result = await performRestoreFolder({ + const result = await restoreFolder({ folderId: id, workspaceId, userId, diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 2f1e88ef8d1..127058074f5 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -63,10 +63,11 @@ interface CreateTableImportResult { export async function createTableImportResource( body: V2CreateTableImportBody, userId: string, - localOrigin: string + localOrigin: string, + resolvedFolderId?: string | null ): Promise { await assertWorkspaceWrite(userId, body.workspaceId) - await validateTarget(body.workspaceId, body.target) + await validateTarget(body.workspaceId, body.target, resolvedFolderId) const importId = generateId() const options = importOptions(body) @@ -83,7 +84,7 @@ export async function createTableImportResource( fileName: body.source.name, contentType: body.source.contentType, fileSize: body.source.size, - metadata: { tableImport: body }, + metadata: { tableImport: body, tableImportFolderId: resolvedFolderId ?? null }, localOrigin, }) return { record: resourceFromUpload(upload, body), upload } @@ -98,6 +99,7 @@ export async function createTableImportResource( userId, source: body.source, target: body.target, + folderId: resolvedFolderId, options, fileKey: file.key, fileName: file.name, @@ -119,12 +121,22 @@ export async function startUploadedTableImport( userId: upload.userId, }) if (existing) return existing + const storedFolderId = upload.metadata.tableImportFolderId + let folderId: string | null | undefined + if (body.target.type === 'new') { + if (storedFolderId !== null && typeof storedFolderId !== 'string') { + throw new Error('Table import upload is missing its resolved folder target') + } + folderId = storedFolderId + } + await validateTarget(workspaceId, body.target, folderId) return startTableImport({ id: upload.id, workspaceId, userId: upload.userId, source: body.source, target: body.target, + folderId, options: importOptions(body), fileKey: upload.storageKey, fileName: upload.fileName, @@ -252,6 +264,7 @@ interface StartTableImportParams { userId: string source: V2TableImportSource target: V2TableImportTarget + folderId?: string | null options: TableImportJobPayload['options'] fileKey: string fileName: string @@ -278,7 +291,7 @@ async function startTableImport(params: StartTableImportParams): Promise { +async function validateTarget( + workspaceId: string, + target: V2TableImportTarget, + resolvedFolderId?: string | null +): Promise { if (target.type === 'new') { - if (target.folderId && !(await findActiveFolder(target.folderId, workspaceId, 'table'))) { + if (resolvedFolderId && !(await findActiveFolder(resolvedFolderId, workspaceId, 'table'))) { throw new OrchestrationError('not_found', 'Folder not found in this workspace') } return diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index a94feb4b5fd..24925e41549 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -282,7 +282,8 @@ type TableRowSelection = Awaited< interface ListTablesOptions { scope?: TableScope /** Restrict to one table folder. */ - folderId?: string + /** `undefined` lists every folder, `null` lists only workspace-root tables. */ + folderId?: string | null /** Case-insensitive substring match on the table name. */ search?: string sortBy?: V2TableSortBy @@ -320,7 +321,11 @@ export async function listTables( : scope === 'archived' ? isNotNull(userTableDefinitions.archivedAt) : isNull(userTableDefinitions.archivedAt), - folderId ? eq(userTableDefinitions.folderId, folderId) : undefined, + folderId === undefined + ? undefined + : folderId === null + ? isNull(userTableDefinitions.folderId) + : eq(userTableDefinitions.folderId, folderId), searchFilter(userTableDefinitions.name, search) ) ) @@ -363,7 +368,8 @@ async function hydrateTableRows(rows: TableRowSelection[]): Promise -} - export interface WorkspaceFileArchiveResult { folders: number files: number @@ -121,16 +119,8 @@ function fileFolderCondition(folderId?: string | null) { return normalized ? eq(workspaceFiles.folderId, normalized) : isNull(workspaceFiles.folderId) } -async function acquireWorkspaceFileFolderMutationLock( - tx: WorkspaceFileFolderLockTx, - workspaceId: string -) { - await tx.execute( - sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_FILE_FOLDER_LOCK_TIMEOUT_MS}ms`}, true)` - ) - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workspace_file_folders:${workspaceId}`}, 0))` - ) +async function acquireWorkspaceFileFolderMutationLock(tx: DbOrTx, workspaceId: string) { + await acquireFolderMutationLock(tx, workspaceId, FILE_FOLDER_RESOURCE_TYPE) } export function buildWorkspaceFileFolderPathMap( @@ -355,10 +345,26 @@ export async function resolveWorkspaceFileFolderTarget( export async function assertWorkspaceFileFolderTarget( workspaceId: string, - folderId?: string | null + folderId?: string | null, + executor: DbOrTx = db ): Promise { - const folder = await resolveWorkspaceFileFolderTarget(workspaceId, folderId) - return folder?.id ?? null + const normalized = normalizeParentId(folderId) + if (!normalized) return null + + const [folder] = await executor + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, normalized), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (!folder) throw new OrchestrationError('not_found', 'Target folder not found') + return folder.id } export async function createWorkspaceFileFolder(params: { @@ -707,14 +713,33 @@ export async function moveWorkspaceFileItems(params: { fileIds?: string[] folderIds?: string[] targetFolderId?: string | null + targetFolderPath?: string }): Promise<{ movedFiles: number; movedFolders: number }> { const fileIds = Array.from(new Set(params.fileIds ?? [])) const folderIds = Array.from(new Set(params.folderIds ?? [])) - const targetFolderId = normalizeParentId(params.targetFolderId) + if (params.targetFolderId !== undefined && params.targetFolderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify a target folder id or path, not both') + } return db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + let targetFolderId = normalizeParentId(params.targetFolderId) + if (params.targetFolderPath !== undefined) { + try { + parseFolderPath(params.targetFolderPath) + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const resolved = + params.targetFolderPath === '/' ? null : index.idByPath.get(params.targetFolderPath) + if (resolved === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + targetFolderId = resolved + } + if (targetFolderId) { const [target] = await tx .select({ id: folderTable.id }) @@ -1193,3 +1218,206 @@ export async function bulkArchiveWorkspaceFileItems(params: { } }) } + +async function loadActiveFileFolderPathIndex(tx: DbOrTx, workspaceId: string) { + const rows = await tx + .select() + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) + ) + return buildFolderPathIndex(rows) +} + +export interface WorkspaceFileFolderPathMutation { + folder: typeof folderTable.$inferSelect + path: string +} + +/** Creates one file-folder leaf with path resolution inside the file tree's mutation lock. */ +export async function createWorkspaceFileFolderAtPath(params: { + workspaceId: string + userId: string + path: string +}): Promise { + requireNonRootFolderPath(params.path) + const pathName = folderNameFromPath(params.path) + let name: string + try { + name = normalizeWorkspaceFileItemName(pathName, 'Folder') + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + if (name !== pathName) { + throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') + } + + const folder = await db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + if (index.idByPath.has(params.path)) throw new WorkspaceFileFolderConflictError(name) + + const parentPath = parentFolderPath(params.path) + const parentId = parentPath === '/' ? null : index.idByPath.get(parentPath) + if (parentPath !== '/' && !parentId) { + throw new OrchestrationError('not_found', 'Parent folder not found') + } + + const [sortOrderResult] = await tx + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + folderParentCondition(parentId), + isNull(folderTable.deletedAt) + ) + ) + + const [created] = await tx + .insert(folderTable) + .values({ + id: generateId(), + resourceType: FILE_FOLDER_RESOURCE_TYPE, + name, + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder: sortOrderResult?.minSortOrder != null ? sortOrderResult.minSortOrder - 1 : 0, + }) + .returning() + return created + }) + + return { folder, path: params.path } +} + +/** Relocates one file folder while source and destination paths share the same tree lock. */ +export async function relocateWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + destinationPath: string +}): Promise { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + const pathName = folderNameFromPath(params.destinationPath) + let name: string + try { + name = normalizeWorkspaceFileItemName(pathName, 'Folder') + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + if (name !== pathName) { + throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') + } + + const folder = await db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const folderId = index.idByPath.get(params.path) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + if (index.idByPath.has(params.destinationPath)) { + throw new WorkspaceFileFolderConflictError(name) + } + + const destinationParentPath = parentFolderPath(params.destinationPath) + if ( + destinationParentPath === params.path || + destinationParentPath.startsWith(`${params.path}/`) + ) { + throw new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + } + const parentId = + destinationParentPath === '/' ? null : index.idByPath.get(destinationParentPath) + if (destinationParentPath !== '/' && !parentId) { + throw new OrchestrationError('not_found', 'Parent folder not found') + } + + const [updated] = await tx + .update(folderTable) + .set({ name, parentId, updatedAt: new Date() }) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .returning() + if (!updated) throw new OrchestrationError('not_found', 'Folder not found') + return updated + }) + + return { folder, path: params.destinationPath } +} + +/** Deletes a file-folder subtree, or only an empty folder when `recursive` is false. */ +export async function deleteWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + recursive: boolean +}): Promise { + requireNonRootFolderPath(params.path) + const now = new Date() + + return db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const folderId = index.idByPath.get(params.path) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + + const folderIds = [ + folderId, + ...[...index.pathById.entries()] + .filter(([, path]) => path.startsWith(`${params.path}/`)) + .map(([id]) => id), + ] + + if (!params.recursive) { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.folderId, folderId), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + if (folderIds.length > 1 || file) { + throw new OrchestrationError('conflict', 'Folder is not empty') + } + } + + const archivedFiles = await tx + .update(workspaceFiles) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(workspaceFiles.folderId, folderIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .returning({ id: workspaceFiles.id }) + const archivedFolders = await tx + .update(folderTable) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .returning({ id: folderTable.id }) + + return { folders: archivedFolders.length, files: archivedFiles.length } + }) +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index ebd22f89f83..3a728de58fd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -38,6 +38,9 @@ import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestrati import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' +import { acquireFolderMutationLock } from '@/lib/folders/locks' +import { parseFolderPath } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import { @@ -338,13 +341,30 @@ export async function uploadWorkspaceFile( fileBuffer: Buffer, fileName: string, contentType: string, - options?: { folderId?: string | null; exactName?: boolean } + options?: { folderId?: string | null; folderPath?: string; exactName?: boolean } ): Promise { logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`) - const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId) - const folderId = folderTarget?.id ?? null - const folderPath = folderTarget?.path ?? null + if (options?.folderId !== undefined && options.folderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify either folderId or folderPath, not both') + } + + let folderId: string | null + let folderPath: string | null + if (options?.folderPath !== undefined) { + const folderPathSegments = parseFolderPath(options.folderPath) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) + if (resolvedFolderId === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + folderId = resolvedFolderId + folderPath = resolvedFolderId ? folderPathSegments.join('/') : null + } else { + const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId) + folderId = folderTarget?.id ?? null + folderPath = folderTarget?.path ?? null + } const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File') const exactName = options?.exactName ?? false const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -370,7 +390,7 @@ export async function uploadWorkspaceFile( purpose: 'workspace', userId: userId, workspaceId: workspaceId, - ...(folderId ? { folderId } : {}), + ...(folderId && options?.folderPath === undefined ? { folderId } : {}), } const uploadResult = await uploadFile({ @@ -392,12 +412,24 @@ export async function uploadWorkspaceFile( } try { finalized = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, 'file') + let activeFolderId: string | null + if (options?.folderPath !== undefined) { + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file', tx) + const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) + if (resolvedFolderId === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + activeFolderId = resolvedFolderId + } else { + activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) + } const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key: uploadResult.key, userId, workspaceId, - folderId, + folderId: activeFolderId, originalName: uniqueName, contentType, size: fileBuffer.length, @@ -537,7 +569,7 @@ export async function registerUploadedWorkspaceFile(params: { } } - const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId) + const folderId = params.folderId ?? null const storageBillingContext = await resolveStorageBillingContext(workspaceId) for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { @@ -549,12 +581,14 @@ export async function registerUploadedWorkspaceFile(params: { ) const finalized = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, 'file') + const activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key, userId, workspaceId, - folderId, + folderId: activeFolderId, originalName: displayName, contentType, size: verifiedSize, @@ -1019,7 +1053,8 @@ const WORKSPACE_FILE_SORTS = { export interface QueryWorkspaceFilesOptions { scope?: WorkspaceFileScope /** Restrict to one file folder. */ - folderId?: string + /** `undefined` lists every folder, `null` lists only root files. */ + folderId?: string | null /** Case-insensitive substring match on the file name. */ search?: string sortBy: V2FileSortBy @@ -1064,7 +1099,11 @@ export async function queryWorkspaceFiles( const conditions = [ workspaceFileScopeCondition(workspaceId, scope), - folderId ? eq(workspaceFiles.folderId, folderId) : undefined, + folderId === undefined + ? undefined + : folderId === null + ? isNull(workspaceFiles.folderId) + : eq(workspaceFiles.folderId, folderId), searchFilter(workspaceFiles.originalName, search), resumeAfter, ] diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index cb101d5455e..311095ba896 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -10,11 +10,15 @@ const { mockGetWorkspaceWithOwner, mockHasCloudStorage, mockHeadObject, + mockAcquireFolderMutationLock, + mockAssertWorkspaceFileFolderTarget, mockIncrementStorageUsageForBillingContextInTx, + mockLoadActiveFolderPathIndex, mockMaybeNotifyStorageLimitForBillingContext, mockMergeEditIntoLiveFileDoc, mockNotifyWorkspaceFilesChanged, mockResolveStorageBillingContext, + mockResolveFolderPathFromIndex, mockResolveWorkspaceFileFolderTarget, mockUploadFile, } = vi.hoisted(() => ({ @@ -23,11 +27,15 @@ const { mockGetWorkspaceWithOwner: vi.fn(), mockHasCloudStorage: vi.fn(), mockHeadObject: vi.fn(), + mockAcquireFolderMutationLock: vi.fn(), + mockAssertWorkspaceFileFolderTarget: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), mockMergeEditIntoLiveFileDoc: vi.fn(), mockNotifyWorkspaceFilesChanged: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockResolveFolderPathFromIndex: vi.fn(), mockResolveWorkspaceFileFolderTarget: vi.fn(), mockUploadFile: vi.fn(), })) @@ -57,7 +65,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - assertWorkspaceFileFolderTarget: vi.fn(async () => null), + assertWorkspaceFileFolderTarget: mockAssertWorkspaceFileFolderTarget, buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), fileNameExistsInWorkspaceFolder: vi.fn(async () => false), findWorkspaceFileFolderIdByPath: vi.fn(), @@ -67,6 +75,15 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => resolveWorkspaceFileFolderTarget: mockResolveWorkspaceFileFolderTarget, })) +vi.mock('@/lib/folders/locks', () => ({ + acquireFolderMutationLock: mockAcquireFolderMutationLock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, + resolveFolderPathFromIndex: mockResolveFolderPathFromIndex, +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) @@ -112,6 +129,7 @@ describe('workspace file metadata and storage accounting', () => { resetDbChainMock() mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) + mockAssertWorkspaceFileFolderTarget.mockResolvedValue(null) mockHasCloudStorage.mockReturnValue(false) mockHeadObject.mockResolvedValue({ size: FILE_ROW.size }) mockUploadFile.mockResolvedValue({ key: FILE_ROW.key }) @@ -164,6 +182,46 @@ describe('workspace file metadata and storage accounting', () => { expect(mockResolveWorkspaceFileFolderTarget).toHaveBeenCalledOnce() }) + it('re-resolves a canonical folder path under the tree lock before inserting metadata', async () => { + const initialIndex = { version: 'initial' } + const lockedIndex = { version: 'locked' } + const inserted = { ...FILE_ROW, folderId: 'folder-final' } + mockLoadActiveFolderPathIndex + .mockResolvedValueOnce(initialIndex) + .mockResolvedValueOnce(lockedIndex) + mockResolveFolderPathFromIndex + .mockReturnValueOnce('folder-initial') + .mockReturnValueOnce('folder-final') + dbChainMockFns.returning.mockResolvedValueOnce([inserted]) + + await uploadWorkspaceFile( + FILE_ROW.workspaceId, + FILE_ROW.userId, + Buffer.from('hello'), + FILE_ROW.originalName, + FILE_ROW.contentType, + { folderPath: '/Reports', exactName: true } + ) + + expect(mockAcquireFolderMutationLock).toHaveBeenCalledWith( + expect.any(Object), + FILE_ROW.workspaceId, + 'file' + ) + expect(mockLoadActiveFolderPathIndex).toHaveBeenNthCalledWith( + 2, + FILE_ROW.workspaceId, + 'file', + expect.any(Object) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-final' }) + ) + expect(mockAcquireFolderMutationLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.values.mock.invocationCallOrder[0] + ) + }) + it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts deleted file mode 100644 index bc1c12e8c73..00000000000 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { folder as folderTable } from '@sim/db/schema' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' -import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' -import type { FolderMutationErrorCode } from '@/lib/folders/status' - -/** - * Workflow-bound entry points into the generic folder engine in `lib/folders/lifecycle.ts`. - * - * The engine is resourceType-driven and owns the actual writes; these wrappers exist so the - * workflow callers that predate it (the folders API, the copilot workflow tools, the - * resource-restore orchestrator) keep a single, workflow-shaped signature. Everything that - * differs for workflows — the last-workflow delete guard, archiving through the workflow - * lifecycle so deployments and webhooks tear down, and restoring schedules/webhooks/chats — - * is declared as data on the `workflow` entry of `FOLDER_RESOURCES`, not branched on here. - */ - -export interface PerformCreateFolderParams { - userId: string - workspaceId: string - name: string - id?: string - parentId?: string | null - sortOrder?: number -} - -export interface PerformCreateFolderResult { - success: boolean - error?: string - errorCode?: OrchestrationErrorCode - folder?: typeof folderTable.$inferSelect -} - -export interface PerformUpdateFolderParams { - folderId: string - workspaceId: string - userId: string - name?: string - locked?: boolean - parentId?: string | null - sortOrder?: number -} - -export interface PerformUpdateFolderResult extends PerformCreateFolderResult {} - -export interface PerformDeleteFolderParams { - folderId: string - workspaceId: string - userId: string - folderName?: string -} - -export interface PerformDeleteFolderResult { - success: boolean - error?: string - errorCode?: FolderMutationErrorCode - deletedItems?: { folders: number; workflows?: number } -} - -export interface PerformRestoreFolderParams extends PerformDeleteFolderParams { - /** - * Folder tree to restore into. Defaults to `'workflow'` so every existing caller — and the - * copilot tool contract — is unchanged; Recently Deleted and the restore tool pass the - * knowledge-base or table tree explicitly. - */ - resourceType?: FolderResourceType -} - -export interface PerformRestoreFolderResult { - success: boolean - error?: string - errorCode?: OrchestrationErrorCode - restoredItems?: { folders: number; workflows?: number } -} - -export function performCreateFolder( - params: PerformCreateFolderParams -): Promise { - return createFolder({ ...params, resourceType: 'workflow' }) -} - -export function performUpdateFolder( - params: PerformUpdateFolderParams -): Promise { - return updateFolder({ ...params, resourceType: 'workflow' }) -} - -export function performDeleteFolder( - params: PerformDeleteFolderParams -): Promise { - return deleteFolder({ ...params, resourceType: 'workflow' }) -} - -export function performRestoreFolder( - params: PerformRestoreFolderParams -): Promise { - return restoreFolder({ ...params, resourceType: params.resourceType ?? 'workflow' }) -} diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index b47ab417e3f..dc8d99a1d9f 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -9,12 +9,6 @@ export { performFullUndeploy, performRevertToVersion, } from './deploy' -export { - performCreateFolder, - performDeleteFolder, - performRestoreFolder, - performUpdateFolder, -} from './folder-lifecycle' export { performCreateWorkflow, performDeleteWorkflow, diff --git a/apps/sim/lib/workspace-files/orchestration/create.ts b/apps/sim/lib/workspace-files/orchestration/create.ts index c1f70cb0d46..ab1992791bf 100644 --- a/apps/sim/lib/workspace-files/orchestration/create.ts +++ b/apps/sim/lib/workspace-files/orchestration/create.ts @@ -22,6 +22,7 @@ export interface PerformCreateWorkspaceFileParams { name: string contentType: string folderId?: string | null + folderPath?: string content?: Buffer exactName?: boolean actorName?: string @@ -53,6 +54,7 @@ export async function performCreateWorkspaceFile( name, contentType, folderId, + folderPath, content = Buffer.alloc(0), exactName = true, actorName, @@ -71,6 +73,7 @@ export async function performCreateWorkspaceFile( try { const file = await uploadWorkspaceFile(workspaceId, userId, content, name, contentType, { folderId, + folderPath, exactName, }) @@ -104,7 +107,7 @@ export async function performCreateWorkspaceFile( return { success: true, file } } catch (error) { - logger.error('Failed to create workspace file', { error, workspaceId, folderId }) + logger.error('Failed to create workspace file', { error, workspaceId, folderId, folderPath }) if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts index 21c8be4f630..019264ed8d1 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -16,6 +16,9 @@ const { mockRenameWorkspaceFile, mockRestoreWorkspaceFile, mockBulkArchive, + mockCreateWorkspaceFileFolderAtPath, + mockRelocateWorkspaceFileFolderByPath, + mockDeleteWorkspaceFileFolderByPath, } = vi.hoisted(() => ({ mockMoveWorkspaceFileItems: vi.fn(), mockUpdateWorkspaceFileFolder: vi.fn(), @@ -24,6 +27,9 @@ const { mockRenameWorkspaceFile: vi.fn(), mockRestoreWorkspaceFile: vi.fn(), mockBulkArchive: vi.fn(), + mockCreateWorkspaceFileFolderAtPath: vi.fn(), + mockRelocateWorkspaceFileFolderByPath: vi.fn(), + mockDeleteWorkspaceFileFolderByPath: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ @@ -34,6 +40,9 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ renameWorkspaceFile: mockRenameWorkspaceFile, restoreWorkspaceFile: mockRestoreWorkspaceFile, bulkArchiveWorkspaceFileItems: mockBulkArchive, + createWorkspaceFileFolderAtPath: mockCreateWorkspaceFileFolderAtPath, + relocateWorkspaceFileFolderByPath: mockRelocateWorkspaceFileFolderByPath, + deleteWorkspaceFileFolderByPath: mockDeleteWorkspaceFileFolderByPath, moveRenameWorkspaceFile: vi.fn(), FileConflictError: class FileConflictError extends Error {}, WorkspaceFileFolderConflictError: class WorkspaceFileFolderConflictError extends Error {}, @@ -54,7 +63,10 @@ vi.mock('@sim/audit', () => ({ import { OrchestrationError } from '@/lib/core/orchestration/types' import { performCreateWorkspaceFileFolder, + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, performMoveWorkspaceFileItems, + performRelocateWorkspaceFileFolderByPath, performRenameWorkspaceFile, performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, @@ -216,4 +228,69 @@ describe('workspace file orchestration error classification', () => { expect(result.errorCode).toBe('internal') }) + + it('delegates path folder mutations to the existing file manager orchestration', async () => { + const folder = { + id: 'internal-folder-id', + workspaceId: WS, + userId: USER, + name: 'Reports', + parentId: null, + path: 'Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + } + mockCreateWorkspaceFileFolderAtPath.mockResolvedValue({ folder, path: '/Reports' }) + mockRelocateWorkspaceFileFolderByPath.mockResolvedValue({ folder, path: '/Archive' }) + mockDeleteWorkspaceFileFolderByPath.mockResolvedValue({ folders: 1, files: 2 }) + + const created = await performCreateWorkspaceFileFolderAtPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + }) + const relocated = await performRelocateWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + destinationPath: '/Archive', + }) + const deleted = await performDeleteWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Archive', + recursive: true, + }) + + expect(created).toMatchObject({ success: true, path: '/Reports' }) + expect(relocated).toMatchObject({ success: true, path: '/Archive' }) + expect(deleted).toEqual({ success: true, deletedItems: { folders: 1, files: 2 } }) + expect(mockDeleteWorkspaceFileFolderByPath).toHaveBeenCalledWith({ + workspaceId: WS, + userId: USER, + path: '/Archive', + recursive: true, + }) + }) + + it('classifies a non-empty non-recursive folder delete as a conflict', async () => { + mockDeleteWorkspaceFileFolderByPath.mockRejectedValue( + new OrchestrationError('conflict', 'Folder is not empty') + ) + + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + recursive: false, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder is not empty', + errorCode: 'conflict', + }) + }) }) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 8057fb8a0c8..489ca8af9b3 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -2,13 +2,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { FolderPathError } from '@/lib/folders/paths' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, + createWorkspaceFileFolderAtPath, + deleteWorkspaceFileFolderByPath, FileConflictError, moveRenameWorkspaceFile, moveWorkspaceFileItems, + relocateWorkspaceFileFolderByPath, renameWorkspaceFile, restoreWorkspaceFile, restoreWorkspaceFileFolder, @@ -49,6 +53,7 @@ export interface PerformMoveWorkspaceFileItemsParams { fileIds?: string[] folderIds?: string[] targetFolderId?: string | null + targetFolderPath?: string } export interface PerformMoveWorkspaceFileItemsResult { @@ -128,6 +133,112 @@ export interface PerformRestoreWorkspaceFileFolderResult { restoredItems?: WorkspaceFileArchiveResult } +export interface PerformFileFolderPathMutationResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + folder?: WorkspaceFileFolderRecord + path?: string +} + +export interface PerformDeleteFileFolderByPathResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + deletedItems?: WorkspaceFileArchiveResult +} + +function fileFolderPathError(error: unknown): { + error: string + errorCode: OrchestrationErrorCode +} { + if (error instanceof FolderPathError) { + return { error: error.message, errorCode: 'validation' } + } + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { error: toError(error).message, errorCode: 'conflict' } + } + const classified = asOrchestrationError(error) + if (classified) return { error: classified.message, errorCode: classified.code } + return { error: toError(error).message, errorCode: 'internal' } +} + +export async function performCreateWorkspaceFileFolderAtPath(params: { + workspaceId: string + userId: string + path: string +}): Promise { + try { + const result = await createWorkspaceFileFolderAtPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceName: result.folder.name, + description: `Created file folder "${result.folder.name}"`, + metadata: { path: result.path }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, folder: { ...result.folder, path: result.path }, path: result.path } + } catch (error) { + logger.error('Failed to create workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + +export async function performRelocateWorkspaceFileFolderByPath(params: { + workspaceId: string + userId: string + path: string + destinationPath: string +}): Promise { + try { + const result = await relocateWorkspaceFileFolderByPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceName: result.folder.name, + description: `Moved file folder to "${result.path}"`, + metadata: { sourcePath: params.path, destinationPath: result.path }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, folder: { ...result.folder, path: result.path }, path: result.path } + } catch (error) { + logger.error('Failed to relocate workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + +export async function performDeleteWorkspaceFileFolderByPath(params: { + workspaceId: string + userId: string + path: string + recursive: boolean +}): Promise { + try { + const deletedItems = await deleteWorkspaceFileFolderByPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + description: `Deleted file folder "${params.path}"`, + metadata: { path: params.path, affected: deletedItems }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, deletedItems } + } catch (error) { + logger.error('Failed to delete workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { @@ -204,7 +315,14 @@ export async function performDeleteWorkspaceFileItems( export async function performMoveWorkspaceFileItems( params: PerformMoveWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [], targetFolderId } = params + const { + workspaceId, + userId, + fileIds = [], + folderIds = [], + targetFolderId, + targetFolderPath, + } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -220,6 +338,7 @@ export async function performMoveWorkspaceFileItems( fileIds, folderIds, targetFolderId, + targetFolderPath, }) const movedItems = { files: moved.movedFiles, folders: moved.movedFolders } @@ -228,6 +347,7 @@ export async function performMoveWorkspaceFileItems( fileIds, folderIds, targetFolderId, + targetFolderPath, movedItems, }) @@ -237,8 +357,8 @@ export async function performMoveWorkspaceFileItems( actorId: userId, action: AuditAction.FILE_MOVED, resourceType: AuditResourceType.FILE, - description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { fileIds, targetFolderId }, + description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId || (targetFolderPath && targetFolderPath !== '/') ? ' to folder' : ' to root'}`, + metadata: { fileIds, targetFolderId, targetFolderPath }, }) } @@ -249,8 +369,8 @@ export async function performMoveWorkspaceFileItems( action: AuditAction.FOLDER_MOVED, resourceType: AuditResourceType.FOLDER, resourceId: folderIds.length === 1 ? folderIds[0] : undefined, - description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { folderIds, targetFolderId }, + description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}${targetFolderId || (targetFolderPath && targetFolderPath !== '/') ? ' to folder' : ' to root'}`, + metadata: { folderIds, targetFolderId, targetFolderPath }, }) } diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 166870b86b6..1242d5269da 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -13,8 +13,10 @@ export { export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, + type PerformDeleteFileFolderByPathResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, + type PerformFileFolderPathMutationResult, type PerformMoveRenameWorkspaceFileParams, type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, @@ -28,9 +30,12 @@ export { type PerformUpdateWorkspaceFileFolderParams, type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, performDeleteWorkspaceFileItems, performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, + performRelocateWorkspaceFileFolderByPath, performRenameWorkspaceFile, performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, diff --git a/packages/testing/src/mocks/folders-lifecycle.mock.ts b/packages/testing/src/mocks/folders-lifecycle.mock.ts deleted file mode 100644 index 52a3a281144..00000000000 --- a/packages/testing/src/mocks/folders-lifecycle.mock.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { vi } from 'vitest' - -/** - * Controllable mock functions for `@/lib/folders/lifecycle` — the generic, - * resourceType-driven folder engine behind every `/api/folders` route. - * All defaults are bare `vi.fn()` — configure per-test as needed. - * - * @example - * ```ts - * import { foldersLifecycleMockFns } from '@sim/testing' - * - * foldersLifecycleMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder }) - * ``` - */ -export const foldersLifecycleMockFns = { - mockCreateFolder: vi.fn(), - mockUpdateFolder: vi.fn(), - mockDeleteFolder: vi.fn(), - mockRestoreFolder: vi.fn(), -} - -/** - * Static mock module for `@/lib/folders/lifecycle`. - * - * @example - * ```ts - * vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock) - * ``` - */ -export const foldersLifecycleMock = { - createFolder: foldersLifecycleMockFns.mockCreateFolder, - updateFolder: foldersLifecycleMockFns.mockUpdateFolder, - deleteFolder: foldersLifecycleMockFns.mockDeleteFolder, - restoreFolder: foldersLifecycleMockFns.mockRestoreFolder, -} diff --git a/packages/testing/src/mocks/folders-orchestration.mock.ts b/packages/testing/src/mocks/folders-orchestration.mock.ts new file mode 100644 index 00000000000..eec11efad4f --- /dev/null +++ b/packages/testing/src/mocks/folders-orchestration.mock.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/folders/orchestration` — the generic, + * resourceType-driven folder engine behind every `/api/folders` route. + * All defaults are bare `vi.fn()` — configure per-test as needed. + * + * @example + * ```ts + * import { foldersOrchestrationMockFns } from '@sim/testing' + * + * foldersOrchestrationMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder }) + * ``` + */ +export const foldersOrchestrationMockFns = { + mockCreateFolder: vi.fn(), + mockUpdateFolder: vi.fn(), + mockDeleteFolder: vi.fn(), + mockRestoreFolder: vi.fn(), +} + +/** + * Static mock module for `@/lib/folders/orchestration`. + * + * @example + * ```ts + * vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) + * ``` + */ +export const foldersOrchestrationMock = { + createFolder: foldersOrchestrationMockFns.mockCreateFolder, + updateFolder: foldersOrchestrationMockFns.mockUpdateFolder, + deleteFolder: foldersOrchestrationMockFns.mockDeleteFolder, + restoreFolder: foldersOrchestrationMockFns.mockRestoreFolder, +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 7ef622c4d61..f5d7f1c0d7d 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -96,8 +96,10 @@ export { mockNextFetchResponse, setupGlobalFetchMock, } from './fetch.mock' -// Generic folder engine mocks (for @/lib/folders/lifecycle) -export { foldersLifecycleMock, foldersLifecycleMockFns } from './folders-lifecycle.mock' +export { + foldersOrchestrationMock, + foldersOrchestrationMockFns, +} from './folders-orchestration.mock' // Hybrid auth mocks export { hybridAuthMock, hybridAuthMockFns } from './hybrid-auth.mock' // Input validation mocks