diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 1368eb93b30..162de3ad5f9 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -7059,6 +7059,29 @@ export function SimTriggerIcon(props: SVGProps) { ) } +export function SimDeploymentsIcon(props: SVGProps) { + return ( + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( ) { ) } +export function VantaIcon(props: SVGProps) { + const id = useId() + const clipId = `vanta_clip_${id}` + const maskId = `vanta_mask_${id}` + return ( + + + + + + + + + + + + + + + + + + + + + ) +} + export function VercelIcon(props: SVGProps) { return ( = { twilio_voice: TwilioIcon, typeform: TypeformIcon, upstash: UpstashIcon, + vanta: VantaIcon, vercel: VercelIcon, video_generator: VideoIcon, video_generator_v2: VideoIcon, diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index d75b3184cd4..491129e3cdf 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -5,6 +5,9 @@ "cancelExecution", "listWorkflows", "getWorkflow", + "deployWorkflow", + "undeployWorkflow", + "rollbackWorkflow", "getJobStatus" ] } diff --git a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx index 313fd5ceaf2..de38fe8a69a 100644 --- a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx @@ -64,13 +64,20 @@ The service account inherits permissions from the project/space roles you grant Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is: - **Jira (granular):** + **Jira (classic):** ``` read:jira-user read:jira-work write:jira-work ``` + **Jira Service Management (classic):** + ``` + read:servicedesk-request + write:servicedesk-request + manage:servicedesk-customer + ``` + **Confluence (granular):** ``` read:confluence-content.all @@ -82,6 +89,10 @@ The service account inherits permissions from the project/space roles you grant Add more scopes only if you need the corresponding operations (delete, manage webhooks, etc.). The full list of scopes Sim's blocks may use is documented in [Atlassian's developer reference](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/). + + Prefer the classic scopes above over granular equivalents. Atlassian enforces an endpoint's granular scope list as all-or-nothing, so a token built from a partial granular set fails with `Unauthorized; scope does not match` even though each individual scope was granted. The classic scopes each cover their product's endpoints on their own. If your organization only permits granular scopes, include every scope listed for each endpoint in Atlassian's reference — Jira Service Management request operations also require `read:user:jira`. + +
+ +## Usage Instructions + +Deploy, undeploy, and roll back workflows in the current workspace. Promote a previous deployment version to live, list every version, or fetch the deployed workflow state for a specific version. + + + +## Actions + +### `deployments_deploy` + +Deploy a workflow’s current draft state, creating a new deployment version and making it live for API execution. Requires admin permission on the workflow’s workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow to deploy | +| `name` | string | No | Optional label for the new deployment version | +| `description` | string | No | Optional summary of what changed in this version | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the deployed workflow | +| `isDeployed` | boolean | Whether the workflow is now deployed | +| `deployedAt` | string | ISO 8601 timestamp of the deployment \(null if unavailable\) | +| `version` | number | The deployment version that is now active | +| `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule sync still in progress\) | + +### `deployments_undeploy` + +Take a deployed workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Requires admin permission on the workflow’s workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow to undeploy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the undeployed workflow | +| `isDeployed` | boolean | Whether the workflow is still deployed \(false\) | +| `deployedAt` | string | Always null after an undeploy | +| `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule cleanup still in progress\) | + +### `deployments_promote` + +Make a specific deployment version the live one without creating a new version — the same operation as Promote to live in the deploy modal. Useful for rolling back to a known-good version. Also works on an undeployed workflow: it re-deploys the workflow live at that version. Requires admin permission on the workflow’s workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow | +| `version` | number | Yes | The deployment version number to promote to live | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the workflow | +| `isDeployed` | boolean | Whether the workflow is now deployed | +| `deployedAt` | string | ISO 8601 timestamp of the active deployment \(null if unavailable\) | +| `version` | number | The deployment version that is now live | +| `warnings` | array | Non-fatal warnings \(e.g. trigger or schedule sync still in progress\) | + +### `deployments_list_versions` + +List every deployment version of a workflow, newest first, including which version is currently live. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the workflow | +| `versions` | array | Deployment versions, newest first \(id, version, name, description, isActive, createdAt, createdBy, deployedByName\) | + +### `deployments_get_version` + +Fetch a single deployment version of a workflow, including its metadata and the full workflow state snapshot that was deployed. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `workflowId` | string | Yes | ID of the workflow | +| `version` | number | Yes | The deployment version number to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `workflowId` | string | ID of the workflow | +| `version` | number | The deployment version number | +| `name` | string | Version label | +| `description` | string | Version description | +| `isActive` | boolean | Whether this version is currently live | +| `createdAt` | string | When this version was deployed \(ISO 8601\) | +| `deployedState` | json | The full workflow state snapshot \(blocks, edges, loops, parallels, variables\) | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 0ee13b9b63a..e67c39642b8 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -42,6 +42,7 @@ "databricks", "datadog", "daytona", + "deployments", "devin", "discord", "docusign", @@ -208,6 +209,7 @@ "twilio_voice", "typeform", "upstash", + "vanta", "vercel", "wealthbox", "webflow", diff --git a/apps/docs/content/docs/en/integrations/vanta.mdx b/apps/docs/content/docs/en/integrations/vanta.mdx new file mode 100644 index 00000000000..2086268cc47 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/vanta.mdx @@ -0,0 +1,691 @@ +--- +title: Vanta +description: Query compliance status and manage evidence in Vanta +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Vanta](https://www.vanta.com/) is a trust management platform that automates security and compliance for frameworks like SOC 2, ISO 27001, HIPAA, and GDPR. It continuously monitors your infrastructure, people, and vendors through automated tests, and centralizes the evidence auditors need. + +With the Vanta integration in Sim, you can: + +- **Monitor compliance posture**: List frameworks with control, document, and test completion counts, and drill into individual controls and their mapped tests and evidence documents. +- **Triage failing tests**: List automated compliance tests by status, framework, integration, or category, and pull the exact failing resource entities that need remediation. +- **Manage evidence documents**: List and inspect evidence documents, upload evidence files with descriptions and effective dates, download previously uploaded files, and submit document collections for auditor review. +- **Track people and security tasks**: List people with employment status, group membership, and outstanding security tasks (trainings, policy acceptance, background checks, device monitoring). +- **Review policies and vendors**: Check policy approval status and versions, and track vendors with risk levels, contract dates, and security review schedules. +- **Stay on top of vulnerabilities**: List vulnerabilities with severity and SLA deadline filters, review remediation history, and inspect the vulnerable assets behind each finding. +- **Watch device compliance**: List monitored computers with screenlock, disk encryption, password manager, and antivirus check outcomes. +- **Manage risk scenarios**: Query risk register scenarios with likelihood/impact scores, treatment decisions, and review status. + +The integration authenticates with Vanta OAuth client credentials (created under Settings → Developer Console in Vanta) and supports both the commercial (api.vanta.com) and FedRAMP (api.vanta-gov.com) environments. Evidence uploads require credentials granted the `vanta-api.documents:upload` scope. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Vanta into the workflow. Monitor compliance frameworks, controls, and automated tests; find failing test entities; manage evidence documents including file upload, download, and submission; and track people, policies, vendors, monitored computers, vulnerabilities, and risk scenarios. Requires Vanta OAuth client credentials. + + + +## Actions + +### `vanta_list_frameworks` + +List the compliance frameworks (e.g., SOC 2, ISO 27001) available in a Vanta account with completion counts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `frameworks` | array | Frameworks in the Vanta account | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_framework` + +Get a Vanta compliance framework by ID, including its requirement categories and mapped controls + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `frameworkId` | string | Yes | Unique ID of the framework \(e.g., soc2\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `framework` | json | The requested framework with requirement categories | + +### `vanta_list_framework_controls` + +List the controls that belong to a specific Vanta compliance framework + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `frameworkId` | string | Yes | Unique ID of the framework \(e.g., soc2\) | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `controls` | array | Controls belonging to the framework | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_controls` + +List the security controls in a Vanta account, optionally filtered by framework + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `frameworkMatchesAny` | string | No | Comma-separated framework IDs to filter controls by \(e.g., soc2,iso27001\) | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `controls` | array | Controls matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_control` + +Get a Vanta security control by ID, including its status and evidence pass/fail counts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `controlId` | string | Yes | Unique ID of the control | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `control` | json | The requested control with status and evidence counts | + +### `vanta_list_control_tests` + +List the automated tests mapped to a specific Vanta control + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `controlId` | string | Yes | Unique ID of the control | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tests` | array | Tests mapped to the control | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_control_documents` + +List the evidence documents mapped to a specific Vanta control + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `controlId` | string | Yes | Unique ID of the control | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `documents` | array | Documents mapped to the control | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_tests` + +List the automated compliance tests in a Vanta account, with filters for status, framework, integration, control, owner, and category + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `statusFilter` | string | No | Filter by test status: OK, DEACTIVATED, NEEDS_ATTENTION, IN_PROGRESS, INVALID, or NOT_APPLICABLE | +| `frameworkFilter` | string | No | Filter by framework ID \(e.g., soc2\) | +| `integrationFilter` | string | No | Filter by integration ID \(e.g., aws\) | +| `controlFilter` | string | No | Filter by control ID | +| `ownerFilter` | string | No | Filter by owner user ID | +| `categoryFilter` | string | No | Filter by test category \(e.g., ACCOUNTS_ACCESS, COMPUTERS, INFRASTRUCTURE, POLICIES, VULNERABILITY_MANAGEMENT\) | +| `isInRollout` | boolean | No | Filter by whether the test is in rollout | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tests` | array | Tests matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_test` + +Get a Vanta automated compliance test by ID, including its status and remediation info + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `testId` | string | Yes | Unique ID of the test \(e.g., test-aws-cloudtrail-enabled\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `test` | json | The requested test | + +### `vanta_list_test_entities` + +List the failing or deactivated resource entities for a specific Vanta test, useful for finding exactly which resources need remediation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `testId` | string | Yes | Unique ID of the test \(e.g., test-aws-cloudtrail-enabled\) | +| `entityStatus` | string | No | Filter entities by status: FAILING or DEACTIVATED | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entities` | array | Resource entities for the test | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_documents` + +List the evidence documents in a Vanta account, optionally filtered by framework or document status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `frameworkMatchesAny` | string | No | Comma-separated framework IDs to filter documents by \(e.g., soc2,iso27001\) | +| `statusMatchesAny` | string | No | Comma-separated document statuses to filter by: "Needs document", "Needs update", "Not relevant", "OK" | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `documents` | array | Documents matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_document` + +Get a Vanta evidence document by ID, including its renewal schedule and deactivation status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `documentId` | string | Yes | Unique ID of the document | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `document` | json | The requested document | + +### `vanta_list_document_uploads` + +List the files uploaded to a specific Vanta evidence document + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `documentId` | string | Yes | Unique ID of the document | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `uploads` | array | Files uploaded to the document | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_upload_document_file` + +Upload an evidence file to a Vanta document. Requires credentials with the vanta-api.documents:upload scope. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `documentId` | string | Yes | Unique ID of the document to attach the file to | +| `file` | file | No | The evidence file to upload | +| `fileContent` | string | No | Base64-encoded file content \(alternative to file\) | +| `fileName` | string | No | Optional file name override | +| `mimeType` | string | No | MIME type of the file \(e.g., application/pdf\); used when uploading base64 content, since uploaded files already carry their own type | +| `description` | string | No | Description of the uploaded evidence \(e.g., "Q3 access review evidence"\) | +| `effectiveAtDate` | string | No | ISO 8601 date indicating when the document is effective from | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `upload` | json | Metadata of the uploaded file | + +### `vanta_download_document_file` + +Download a file previously uploaded to a Vanta evidence document and store it in execution files + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `documentId` | string | Yes | Unique ID of the document | +| `uploadedFileId` | string | Yes | Unique ID of the uploaded file \(from List Document Uploads\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file stored in execution files | +| `name` | string | Name of the downloaded file | +| `mimeType` | string | MIME type of the downloaded file | +| `size` | number | Size of the downloaded file in bytes | + +### `vanta_submit_document` + +Submit a Vanta document collection for review so uploaded evidence becomes visible to auditors. Requires credentials with write access. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `documentId` | string | Yes | Unique ID of the document to submit | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `documentId` | string | ID of the submitted document | +| `submitted` | boolean | Whether the document collection was submitted | + +### `vanta_list_people` + +List the people tracked in a Vanta account with employment status, group membership, and security task completion + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `emailAndNameFilter` | string | No | Filter people by email address or name | +| `employmentStatus` | string | No | Filter by employment status: UPCOMING, CURRENT, ON_LEAVE, INACTIVE, or FORMER | +| `groupIdsMatchesAny` | string | No | Comma-separated group IDs to filter people by | +| `tasksSummaryStatusMatchesAny` | string | No | Comma-separated task summary statuses to filter by: NONE, DUE_SOON, OVERDUE, COMPLETE, PAUSED, OFFBOARDING_DUE_SOON, OFFBOARDING_OVERDUE, OFFBOARDING_COMPLETE | +| `taskTypeMatchesAny` | string | No | Comma-separated task types to filter by: COMPLETE_TRAININGS, ACCEPT_POLICIES, COMPLETE_CUSTOM_TASKS, COMPLETE_CUSTOM_OFFBOARDING_TASKS, INSTALL_DEVICE_MONITORING, COMPLETE_BACKGROUND_CHECKS | +| `taskStatusMatchesAny` | string | No | Comma-separated task statuses to filter by: COMPLETE, DUE_SOON, OVERDUE, NONE | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `people` | array | People matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_person` + +Get a person tracked in Vanta by ID, including employment, leave, and security task status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `personId` | string | Yes | Unique ID of the person | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `person` | json | The requested person | + +### `vanta_list_policies` + +List the security policies in a Vanta account with approval status and version info + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policies` | array | Policies in the Vanta account | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_policy` + +Get a Vanta security policy by ID, including its approval status and latest approved version documents + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `policyId` | string | Yes | Unique ID of the policy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `policy` | json | The requested policy | + +### `vanta_list_vendors` + +List the vendors tracked in a Vanta account with risk levels, contract dates, and security review schedules + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `name` | string | No | Filter vendors by name | +| `statusMatchesAny` | string | No | Comma-separated vendor statuses to filter by: MANAGED, ARCHIVED, IN_PROCUREMENT | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `vendors` | array | Vendors matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_vendor` + +Get a Vanta vendor by ID, including risk levels, contract details, and authentication info + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `vendorId` | string | Yes | Unique ID of the vendor | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `vendor` | json | The requested vendor | + +### `vanta_list_monitored_computers` + +List the monitored computers in a Vanta account with screenlock, disk encryption, password manager, and antivirus check outcomes + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `complianceStatusFilterMatchesAny` | string | No | Comma-separated compliance issues to filter by: PWM_NOT_INSTALLED, HD_NOT_ENCRYPTED, AV_NOT_INSTALLED, SCREENLOCK_NOT_CONFIGURED, LAST_CHECK_OVER_14_DAYS | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `computers` | array | Monitored computers matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_vulnerabilities` + +List the vulnerabilities detected across a Vanta account with filters for severity, fixability, SLA deadlines, package, and integration + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `q` | string | No | Search query for vulnerabilities | +| `severity` | string | No | Filter by severity: LOW, MEDIUM, HIGH, or CRITICAL | +| `isFixAvailable` | boolean | No | Filter by whether a fix is available | +| `isDeactivated` | boolean | No | Filter by whether vulnerability monitoring is deactivated | +| `includeVulnerabilitiesWithoutSlas` | boolean | No | Include vulnerabilities that have no SLA deadline | +| `packageIdentifier` | string | No | Filter by the affected package identifier | +| `externalVulnerabilityId` | string | No | Filter by external vulnerability ID \(e.g., a CVE identifier\) | +| `integrationId` | string | No | Filter by the integration that detected the vulnerability | +| `vulnerableAssetId` | string | No | Filter by the vulnerable asset ID | +| `slaDeadlineAfterDate` | string | No | Only include vulnerabilities with an SLA deadline after this ISO 8601 date | +| `slaDeadlineBeforeDate` | string | No | Only include vulnerabilities with an SLA deadline before this ISO 8601 date | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `vulnerabilities` | array | Vulnerabilities matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_vulnerability_remediations` + +List remediated vulnerabilities in a Vanta account with detection, SLA deadline, and remediation dates + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `integrationId` | string | No | Filter by the integration that detected the vulnerability | +| `severity` | string | No | Filter by severity: LOW, MEDIUM, HIGH, or CRITICAL | +| `isRemediatedOnTime` | boolean | No | Filter by whether the vulnerability was remediated before its SLA deadline | +| `remediatedAfterDate` | string | No | Only include remediations completed after this ISO 8601 date | +| `remediatedBeforeDate` | string | No | Only include remediations completed before this ISO 8601 date | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `remediations` | array | Vulnerability remediations matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_list_vulnerable_assets` + +List the assets associated with vulnerabilities in a Vanta account (servers, repositories, workstations, and more) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `q` | string | No | Search query for vulnerable assets | +| `integrationId` | string | No | Filter by the integration scanning the asset | +| `assetType` | string | No | Filter by asset type: SERVER, SERVERLESS_FUNCTION, CONTAINER, CONTAINER_REPOSITORY, CONTAINER_REPOSITORY_IMAGE, CODE_REPOSITORY, MANIFEST_FILE, WORKSTATION, or OTHER | +| `assetExternalAccountId` | string | No | Filter by the external account ID the asset belongs to | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assets` | array | Vulnerable assets matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_vulnerable_asset` + +Get a vulnerable asset in Vanta by ID, including the scanners reporting it and per-scanner asset details + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `vulnerableAssetId` | string | Yes | Unique ID of the vulnerable asset | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `asset` | json | The requested vulnerable asset | + +### `vanta_list_risk_scenarios` + +List the risk scenarios in a Vanta risk register with likelihood/impact scores, treatment decisions, and review status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `searchString` | string | No | Search string to filter risk scenarios | +| `includeIgnored` | boolean | No | Include ignored risk scenarios | +| `type` | string | No | Filter by scenario type: "Risk Scenario" or "Enterprise Risk" | +| `ownerMatchesAny` | string | No | Comma-separated owner emails to filter by | +| `categoryMatchesAny` | string | No | Comma-separated risk categories to filter by | +| `ciaCategoryMatchesAny` | string | No | Comma-separated CIA categories to filter by: Confidentiality, Integrity, Availability | +| `treatmentTypeMatchesAny` | string | No | Comma-separated treatments to filter by: Mitigate, Transfer, Avoid, Accept | +| `inherentScoreGroupMatchesAny` | string | No | Comma-separated inherent score groups to filter by: "Very low", Low, Med, High, Critical | +| `residualScoreGroupMatchesAny` | string | No | Comma-separated residual score groups to filter by: "Very low", Low, Med, High, Critical | +| `reviewStatusMatchesAny` | string | No | Comma-separated review statuses to filter by: APPROVED, DRAFT, NOT_REVIEWED, AWAITING_SUBMISSION, PENDING_APPROVAL, REQUESTED_CHANGES | +| `orderBy` | string | No | Field to order results by: description or createdAt | +| `pageSize` | number | No | Maximum number of items per page \(1-100, default 10\) | +| `pageCursor` | string | No | Pagination cursor: pass the endCursor from the previous response to fetch the next page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `riskScenarios` | array | Risk scenarios matching the filters | +| `pageInfo` | json | Cursor pagination info for the returned page; pass endCursor as pageCursor to fetch the next page | + +### `vanta_get_risk_scenario` + +Get a Vanta risk scenario by ID, including its scores, treatment decision, and review status + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Vanta OAuth application client ID | +| `clientSecret` | string | Yes | Vanta OAuth application client secret | +| `region` | string | No | Vanta API region: "us" \(api.vanta.com, default\) or "gov" \(api.vanta-gov.com\) | +| `riskScenarioId` | string | Yes | Unique ID of the risk scenario | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `riskScenario` | json | The requested risk scenario | + + diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index 60199812d6a..b2f713bed40 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -56,6 +56,28 @@ Every time you deploy or update, a new version is recorded in the Versions table **Promote to live** is useful for rolling back — if a new deployment has an issue, promote the previous version to restore the last known-good state instantly. +## Managing Deployments via the API + +Everything above can also be done programmatically. The v1 API exposes deploy, undeploy, and rollback endpoints — useful for CI/CD pipelines that ship a workflow after tests pass, or for reverting to the last known-good version from an incident script. All three require an API key with admin permission on the workflow's workspace. + +```bash +# Deploy the current draft as a new version (body is optional) +curl -X POST https://sim.ai/api/v1/workflows/{workflow-id}/deploy \ + -H "Content-Type: application/json" \ + -H "x-api-key: $SIM_API_KEY" \ + -d '{ "name": "Release 4", "description": "Fixes the agent prompt" }' + +# Undeploy — take the workflow offline +curl -X DELETE https://sim.ai/api/v1/workflows/{workflow-id}/deploy \ + -H "x-api-key: $SIM_API_KEY" + +# Roll back to the previous version (or pass { "version": N } for a specific one) +curl -X POST https://sim.ai/api/v1/workflows/{workflow-id}/rollback \ + -H "x-api-key: $SIM_API_KEY" +``` + +Rollback re-activates an existing deployment version — the same operation as **Promote to live** — and leaves your canvas draft untouched. See the API reference for full request and response details: [Deploy Workflow](/api-reference/workflows/deployWorkflow), [Undeploy Workflow](/api-reference/workflows/undeployWorkflow), and [Rollback Workflow](/api-reference/workflows/rollbackWorkflow). + ## Making API Calls Switch to the **API** tab in the Deploy modal to see ready-to-use code for all three execution modes: @@ -226,8 +248,8 @@ Workflow execution responses are capped by platform request and response limits. "id": "lv_abc123DEF456", "kind": "array", "size": 12582912, - "key": "execution/workspace-id/workflow-id/exec_xyz/large-value-lv_abc123DEF456.json", - "executionId": "exec_xyz", + "key": "execution/workspace-id/workflow-id/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74/large-value-lv_abc123DEF456.json", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "preview": { "length": 25000 } } ``` @@ -254,7 +276,7 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ "success": true, "async": true, "jobId": "run_abc123", - "executionId": "exec_xyz", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "message": "Workflow execution queued", "statusUrl": "https://sim.ai/api/jobs/run_abc123" } diff --git a/apps/docs/content/docs/en/workflows/triggers/sim.mdx b/apps/docs/content/docs/en/workflows/triggers/sim.mdx index c46b54ed5a4..24487e89dac 100644 --- a/apps/docs/content/docs/en/workflows/triggers/sim.mdx +++ b/apps/docs/content/docs/en/workflows/triggers/sim.mdx @@ -18,6 +18,7 @@ Pick one event per Sim trigger block:
  • Execution Error: a watched workflow's run failed
  • Execution Success: a watched workflow's run completed successfully
  • Workflow Deployed: a watched workflow was deployed (including redeploys and version rollbacks)
  • +
  • Workflow Undeployed: a watched workflow was taken offline
  • **Alert conditions** — evaluated as runs complete (failure-based conditions evaluate on failed runs), with a cooldown so they fire at most once per cooldown window: @@ -60,6 +61,7 @@ All events include `event`, `timestamp`, `workflowId`, and `workflowName` (the s
    • version: the deployment version number that was activated
    +

    Workflow Undeployed carries only the base fields.

    diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index 467626104a0..49d6e7ed91b 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -82,7 +82,7 @@ "description": "The unique identifier of the deployed workflow to execute.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -133,7 +133,7 @@ }, "example": { "success": true, - "executionId": "exec_abc123", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "output": { "content": "The weather in Tokyo is sunny, 22\u00b0C." }, @@ -197,7 +197,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -207,7 +207,7 @@ "description": "The unique identifier of the execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } }, { @@ -343,7 +343,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -353,7 +353,7 @@ "description": "The unique identifier of the execution to cancel.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -377,7 +377,7 @@ }, "example": { "success": true, - "executionId": "exec_abc123" + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" } } } @@ -413,7 +413,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -447,8 +447,8 @@ "pausedExecutions": [ { "id": "pe_abc123", - "workflowId": "wf_1a2b3c4d5e", - "executionId": "exec_9f8e7d6c5b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "status": "paused", "totalPauseCount": 1, "resumedCount": 0, @@ -465,11 +465,11 @@ "resumeStatus": "paused", "snapshotReady": true, "resumeLinks": { - "apiUrl": "https://www.sim.ai/api/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b/ctx_xyz789", - "uiUrl": "https://www.sim.ai/resume/wf_1a2b3c4d5e/exec_9f8e7d6c5b", + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "contextId": "ctx_xyz789", - "executionId": "exec_9f8e7d6c5b", - "workflowId": "wf_1a2b3c4d5e" + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "response": { "displayData": { @@ -523,7 +523,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -533,7 +533,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -582,7 +582,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -592,7 +592,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -657,7 +657,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -667,7 +667,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } }, { @@ -724,7 +724,7 @@ "description": "The unique identifier of the workflow.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } }, { @@ -734,7 +734,7 @@ "description": "The execution ID of the paused execution.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } }, { @@ -831,7 +831,7 @@ "value": { "success": true, "status": "completed", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "output": { "result": "Approved and processed" }, @@ -847,7 +847,7 @@ "summary": "Queued behind another resume", "value": { "status": "queued", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "queuePosition": 2, "message": "Resume queued. It will run after current resumes finish." } @@ -856,7 +856,7 @@ "summary": "Execution started (fire and forget)", "value": { "status": "started", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "message": "Resume execution started." } } @@ -875,7 +875,7 @@ "success": true, "async": true, "jobId": "job_4a3b2c1d0e", - "executionId": "exec_new123", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", "message": "Resume execution queued", "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" } @@ -1011,7 +1011,7 @@ "example": { "data": [ { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Weather Bot", "isDeployed": true, "createdAt": "2026-01-15T10:30:00Z", @@ -1057,7 +1057,7 @@ "description": "The unique workflow identifier.", "schema": { "type": "string", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } } ], @@ -1081,7 +1081,7 @@ }, "example": { "data": { - "id": "wf_abc123", + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Weather Bot", "description": "A workflow that fetches weather data", "isDeployed": true, @@ -1104,6 +1104,311 @@ } } }, + "/api/v1/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates any schedules and triggers. Optionally accepts a name and description for the new version. Requires admin permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The resulting deployment state of the workflow.", + "$ref": "#/components/schemas/WorkflowDeployment" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00Z", + "version": 4, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "description": "The workflow is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Requires admin permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The resulting deployment state of the workflow.", + "$ref": "#/components/schemas/WorkflowDeployment" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "description": "The workflow is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/v1/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Requires admin permission on the workflow's workspace. Returns 404 when the workflow does not exist or you do not have access to it.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the previous version.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3, + "maximum": 2147483647 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "description": "The resulting deployment state of the workflow.", + "$ref": "#/components/schemas/WorkflowDeployment" + }, + "limits": { + "$ref": "#/components/schemas/Limits", + "description": "Rate limit and usage information for the current API key." + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00Z", + "version": 3, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "description": "The workflow is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, "/api/jobs/{jobId}": { "get": { "operationId": "getJobStatus", @@ -1358,7 +1663,7 @@ "data": [ { "id": "log_abc123", - "workflowId": "wf_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "level": "info", "trigger": "api", "createdAt": "2026-01-15T10:30:00Z" @@ -1427,8 +1732,8 @@ "example": { "data": { "id": "log_abc123", - "workflowId": "wf_abc123", - "executionId": "exec_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "level": "info", "trigger": "api", "totalDurationMs": 1250, @@ -1474,7 +1779,7 @@ "description": "The unique execution identifier.", "schema": { "type": "string", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], @@ -1553,8 +1858,8 @@ } }, "example": { - "executionId": "exec_abc123", - "workflowId": "wf_abc123", + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "workflowState": {}, "executionMetadata": {} } @@ -1776,7 +2081,7 @@ "actorName": "Jane Doe", "actorEmail": "jane@example.com", "resourceType": "workflow", - "resourceId": "wf_abc123", + "resourceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "metadata": {}, "createdAt": "2026-01-15T10:30:00Z" } @@ -1911,7 +2216,7 @@ "data": { "tables": [ { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2060,7 +2365,7 @@ "success": true, "data": { "table": { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2150,7 +2455,7 @@ "success": true, "data": { "table": { - "id": "tbl_abc123", + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", "name": "contacts", "description": "Customer contacts", "schema": { @@ -2728,7 +3033,7 @@ "data": { "rows": [ { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe" @@ -2896,7 +3201,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe", @@ -3020,7 +3325,7 @@ "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_abc123\", \"row_def456\"]\n }'" + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" } ], "parameters": [ @@ -3079,7 +3384,10 @@ }, "example": { "workspaceId": "wsp_abc123", - "rowIds": ["row_abc123", "row_def456"] + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } @@ -3135,7 +3443,10 @@ "data": { "message": "Rows deleted successfully", "deletedCount": 2, - "deletedRowIds": ["row_abc123", "row_def456"] + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } @@ -3168,7 +3479,7 @@ "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"updates\": [\n { \"rowId\": \"row_abc123\", \"data\": { \"status\": \"active\" } },\n { \"rowId\": \"row_def456\", \"data\": { \"status\": \"archived\" } }\n ]\n }'" + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"updates\": [\n { \"rowId\": \"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"data\": { \"status\": \"active\" } },\n { \"rowId\": \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\", \"data\": { \"status\": \"archived\" } }\n ]\n }'" } ], "parameters": [ @@ -3215,13 +3526,13 @@ "workspaceId": "wsp_abc123", "updates": [ { - "rowId": "row_abc123", + "rowId": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "status": "active" } }, { - "rowId": "row_def456", + "rowId": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", "data": { "status": "archived" } @@ -3306,7 +3617,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Jane Doe" @@ -3418,7 +3729,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "jane@example.com", "name": "Updated Name" @@ -3636,7 +3947,7 @@ "success": true, "data": { "row": { - "id": "row_abc123", + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", "data": { "email": "user@example.com", "name": "John Doe" @@ -3724,7 +4035,7 @@ "data": { "files": [ { - "id": "file_abc123", + "id": "wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", "size": 1024, "type": "text/csv", @@ -3820,7 +4131,7 @@ "success": true, "data": { "file": { - "id": "file_abc123", + "id": "wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", "size": 1024, "type": "text/csv", @@ -3888,7 +4199,7 @@ "description": "The unique identifier of the file.", "schema": { "type": "string", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" } }, { @@ -3987,7 +4298,7 @@ "description": "The unique identifier of the file to delete.", "schema": { "type": "string", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" } }, { @@ -4100,7 +4411,7 @@ "data": { "knowledgeBases": [ { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 5, @@ -4211,7 +4522,7 @@ "success": true, "data": { "knowledgeBase": { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 0, @@ -4298,7 +4609,7 @@ "success": true, "data": { "knowledgeBase": { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Product Docs", "description": "Product documentation and FAQs", "docCount": 5, @@ -4421,7 +4732,7 @@ "success": true, "data": { "knowledgeBase": { - "id": "kb_abc123", + "id": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "name": "Updated Product Docs", "description": "Updated product documentation", "docCount": 5, @@ -4677,8 +4988,8 @@ "data": { "documents": [ { - "id": "doc_abc123", - "knowledgeBaseId": "kb_abc123", + "id": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", + "knowledgeBaseId": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "filename": "Getting Started.pdf", "fileSize": 204800, "mimeType": "application/pdf", @@ -4795,8 +5106,8 @@ "success": true, "data": { "document": { - "id": "doc_abc123", - "knowledgeBaseId": "kb_abc123", + "id": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", + "knowledgeBaseId": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "filename": "Getting Started.pdf", "fileSize": 204800, "mimeType": "application/pdf", @@ -4943,8 +5254,8 @@ "success": true, "data": { "document": { - "id": "doc_abc123", - "knowledgeBaseId": "kb_abc123", + "id": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", + "knowledgeBaseId": "d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f", "filename": "Getting Started.pdf", "fileSize": 204800, "mimeType": "application/pdf", @@ -5122,7 +5433,7 @@ }, "example": { "workspaceId": "wsp_abc123", - "knowledgeBaseIds": ["kb_abc123"], + "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "query": "How do I reset my password?", "topK": 5 } @@ -5180,7 +5491,7 @@ "data": { "results": [ { - "documentId": "doc_abc123", + "documentId": "b6e2a8d4-5c7f-4a1b-8d3e-0f9c1b5a7e29", "documentName": "Getting Started.pdf", "sourceUrl": "https://example.atlassian.net/wiki/spaces/DOCS/pages/12345", "content": "To reset your password, go to Settings > Security.", @@ -5190,7 +5501,7 @@ } ], "query": "How do I reset my password?", - "knowledgeBaseIds": ["kb_abc123"], + "knowledgeBaseIds": ["d2c8f4a6-1b3e-4c5d-9e7f-8a0b2c4d6e1f"], "topK": 5, "totalResults": 1 } @@ -5234,7 +5545,7 @@ "required": true, "schema": { "type": "string", - "example": "tbl_abc123" + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" }, "description": "The unique identifier of the table." }, @@ -5244,7 +5555,7 @@ "required": true, "schema": { "type": "string", - "example": "row_xyz789" + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" }, "description": "The unique identifier of the row." }, @@ -5294,7 +5605,7 @@ "id": { "type": "string", "description": "Unique table identifier.", - "example": "tbl_abc123" + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" }, "name": { "type": "string", @@ -5346,7 +5657,7 @@ "id": { "type": "string", "description": "Unique row identifier.", - "example": "row_xyz789" + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" }, "data": { "type": "object", @@ -5376,7 +5687,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -5393,12 +5704,12 @@ "type": "string", "nullable": true, "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "folder_abc123" + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" }, "workspaceId": { "type": "string", "description": "The workspace this workflow belongs to.", - "example": "ws_xyz789" + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, "isDeployed": { "type": "boolean", @@ -5445,7 +5756,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -5462,12 +5773,12 @@ "type": "string", "nullable": true, "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "folder_abc123" + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" }, "workspaceId": { "type": "string", "description": "The workspace this workflow belongs to.", - "example": "ws_xyz789" + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, "isDeployed": { "type": "boolean", @@ -5524,6 +5835,41 @@ } } }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, "ExecutionResult": { "type": "object", "description": "Result of a synchronous workflow execution.", @@ -5536,7 +5882,7 @@ "executionId": { "type": "string", "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "output": { "type": "object", @@ -5599,7 +5945,7 @@ "executionId": { "type": "string", "description": "Unique execution identifier. Use this to query execution status or cancel.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "message": { "type": "string", @@ -5626,12 +5972,12 @@ "workflowId": { "type": "string", "description": "The workflow that was executed.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "executionId": { "type": "string", "description": "Unique execution identifier for this run.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "level": { "type": "string", @@ -5691,12 +6037,12 @@ "workflowId": { "type": "string", "description": "The workflow that was executed.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "executionId": { "type": "string", "description": "Unique execution identifier for this run.", - "example": "exec_9f8e7d6c5b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "level": { "type": "string", @@ -5732,7 +6078,7 @@ "id": { "type": "string", "description": "Unique workflow identifier.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "name": { "type": "string", @@ -6121,7 +6467,7 @@ "type": "string", "nullable": true, "description": "The workspace where the action occurred.", - "example": "ws_xyz789" + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, "actorId": { "type": "string", @@ -6155,7 +6501,7 @@ "type": "string", "nullable": true, "description": "The unique identifier of the affected resource.", - "example": "wf_1a2b3c4d5e" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "resourceName": { "type": "string", @@ -6279,7 +6625,7 @@ "id": { "type": "string", "description": "Unique file identifier.", - "example": "wf_1709571234_abc1234" + "example": "wf_V1StGXR8z5jdHi6BmyT91" }, "name": { "type": "string", @@ -7090,7 +7436,10 @@ "data": { "message": "Rows updated successfully", "updatedCount": 2, - "updatedRowIds": ["row_abc123", "row_def456"] + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] } } } diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 99c395d644b..47505d2a94c 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,5 +1,5 @@ import { asyncJobs, db } from '@sim/db' -import { userTableDefinitions, workflowExecutionLogs } from '@sim/db/schema' +import { tableJobs, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, lt, sql } from 'drizzle-orm' @@ -8,12 +8,15 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteFile } from '@/lib/uploads/core/storage-service' const logger = createLogger('CleanupStaleExecutions') const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000 const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000) const MAX_INT32 = 2_147_483_647 +/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */ +const TABLE_JOB_RETENTION_HOURS = 24 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -110,33 +113,56 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Mark stale table imports as failed. Imports run detached on the web container and - // are lost if the pod is killed mid-load. `updatedAt` is bumped by progress updates, so - // an `importing` table with no recent update has stalled (not merely slow). Rows are - // left in place (no rollback); the user re-imports. - let staleImportsMarkedFailed = 0 + // Mark stale table jobs (import, export, or delete) as failed. Jobs run detached on the web container + // and are lost if the pod is killed mid-run. `updated_at` is bumped by progress updates, so a + // `running` job with no recent update has stalled (not merely slow). Committed work is left in + // place (no rollback); the user retries. Also prune long-settled terminal jobs so the table + // doesn't grow unbounded (the latest job per table is what list/detail reads surface). + let staleTableJobsMarkedFailed = 0 try { - const staleImports = await db - .update(userTableDefinitions) + const now = new Date() + const staleJobs = await db + .update(tableJobs) .set({ - importStatus: 'failed', - importError: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, - updatedAt: new Date(), + status: 'failed', + error: `Job terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, + completedAt: now, + updatedAt: now, }) + .where(and(eq(tableJobs.status, 'running'), lt(tableJobs.updatedAt, staleThreshold))) + .returning({ id: tableJobs.id }) + + staleTableJobsMarkedFailed = staleJobs.length + if (staleTableJobsMarkedFailed > 0) { + logger.info(`Marked ${staleTableJobsMarkedFailed} stale table jobs as failed`) + } + + const terminalRetention = new Date(Date.now() - TABLE_JOB_RETENTION_HOURS * 60 * 60 * 1000) + const pruned = await db + .delete(tableJobs) .where( and( - eq(userTableDefinitions.importStatus, 'importing'), - lt(userTableDefinitions.updatedAt, staleThreshold) + inArray(tableJobs.status, ['ready', 'failed', 'canceled']), + lt(tableJobs.updatedAt, terminalRetention) ) ) - .returning({ id: userTableDefinitions.id }) - - staleImportsMarkedFailed = staleImports.length - if (staleImportsMarkedFailed > 0) { - logger.info(`Marked ${staleImportsMarkedFailed} stale table imports as failed`) + .returning({ type: tableJobs.type, payload: tableJobs.payload }) + + // Pruned export jobs carry the generated file's storage key — delete the file with the job + // so the exports prefix doesn't accumulate. Best-effort: a miss just orphans one object. + for (const job of pruned) { + if (job.type !== 'export') continue + const resultKey = (job.payload as { resultKey?: string } | null)?.resultKey + if (!resultKey) continue + await deleteFile({ key: resultKey, context: 'workspace' }).catch((err) => { + logger.warn('Failed to delete pruned export file', { + resultKey, + error: toError(err).message, + }) + }) } } catch (error) { - logger.error('Failed to clean up stale table imports:', { + logger.error('Failed to clean up stale table jobs:', { error: toError(error).message, }) } @@ -210,8 +236,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { staleThresholdMinutes: STALE_THRESHOLD_MINUTES, retentionHours: JOB_RETENTION_HOURS, }, - tableImports: { - staleMarkedFailed: staleImportsMarkedFailed, + tableJobs: { + staleMarkedFailed: staleTableJobsMarkedFailed, }, }) } catch (error) { diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 32af9724073..c99e3ff9f6e 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -41,7 +41,7 @@ const emailTemplates = { 'workspace-invitation': () => renderWorkspaceInvitationEmail( 'John Smith', - 'Engineering Team', + ['Engineering Team'], 'https://sim.ai/workspace/invite/abc123' ), diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index d9abf178947..c069e3c950e 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -79,6 +79,15 @@ vi.mock('@sim/db/schema', () => ({ organizationId: 'workspace.organizationId', workspaceMode: 'workspace.workspaceMode', }, + permissions: { + entityId: 'permissions.entityId', + entityType: 'permissions.entityType', + userId: 'permissions.userId', + }, + invitationWorkspaceGrant: { + invitationId: 'invitationWorkspaceGrant.invitationId', + workspaceId: 'invitationWorkspaceGrant.workspaceId', + }, })) vi.mock('drizzle-orm', () => ({ @@ -182,6 +191,175 @@ describe('POST /api/organizations/[id]/invitations', () => { expect(mockCancelPendingInvitation).not.toHaveBeenCalled() }) + it('sends a workspace invitation to an existing member for selected workspaces they lack', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-2', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [{ userId: 'user-2', workspaceId: 'ws-1' }], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['member@example.com'], + workspaceInvitations: [ + { workspaceId: 'ws-1', permission: 'write' }, + { workspaceId: 'ws-2', permission: 'write' }, + ], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(200) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'member@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + grants: [{ workspaceId: 'ws-2', permission: 'write' }], + }) + ) + expect(mockSendInvitationEmail).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'member@example.com', + grants: [{ workspaceId: 'ws-2', permission: 'write' }], + }) + ) + + const body = await response.json() + expect(body.data.invitationsSent).toBe(1) + expect(body.data.invitedEmails).toEqual(['member@example.com']) + expect(body.data.existingMembers).toEqual([]) + }) + + it('returns 400 when an existing member already has access to every selected workspace', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [{ userId: 'user-2', workspaceId: 'ws-1' }], + [], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['member@example.com'], + workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('already has access') + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + + it('invites new emails to the organization and existing members to workspaces in one batch', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['new@example.com', 'member@example.com'], + workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'read' }], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(200) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'organization', + email: 'new@example.com', + grants: [{ workspaceId: 'ws-1', permission: 'read' }], + }) + ) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'member@example.com', + grants: [{ workspaceId: 'ws-1', permission: 'read' }], + }) + ) + + const body = await response.json() + expect(body.data.invitationsSent).toBe(2) + expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com']) + }) + + it('still rejects existing members on the non-batch organization invite path', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + ] + + const response = await POST( + createMockRequest( + 'POST', + { emails: ['member@example.com'] }, + {}, + 'http://localhost/api/organizations/org-1/invitations' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe( + 'Failed to send invitation. User is already a part of the organization.' + ) + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + it('rolls back the pending invitation when email delivery fails', async () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 42f927cd918..de6bee05b77 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -1,8 +1,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { invitation, member, organization, user, workspace } from '@sim/db/schema' +import { + invitation, + invitationWorkspaceGrant, + member, + organization, + permissions, + user, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { inviteOrganizationMembersContract, @@ -188,6 +197,10 @@ export const POST = withRouteHandler( } for (const wsInvitation of workspaceInvitations) { + if (validGrants.some((grant) => grant.workspaceId === wsInvitation.workspaceId)) { + continue + } + const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId) if (!canInvite) { return NextResponse.json( @@ -236,12 +249,15 @@ export const POST = withRouteHandler( } const existingMembers = await db - .select({ userEmail: user.email }) + .select({ userId: member.userId, userEmail: user.email }) .from(member) .innerJoin(user, eq(member.userId, user.id)) .where(eq(member.organizationId, organizationId)) - const existingEmails = existingMembers.map((m) => m.userEmail.toLowerCase()) - const newEmails = processedEmails.filter((email) => !existingEmails.includes(email)) + const memberUserIdByEmail = new Map( + existingMembers.map((m) => [m.userEmail.toLowerCase(), m.userId]) + ) + const newEmails = processedEmails.filter((email) => !memberUserIdByEmail.has(email)) + const memberEmails = processedEmails.filter((email) => memberUserIdByEmail.has(email)) const existingInvitations = await db .select({ email: invitation.email }) @@ -250,19 +266,106 @@ export const POST = withRouteHandler( const pendingEmails = existingInvitations.map((i) => i.email.toLowerCase()) const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email)) - if (emailsToInvite.length === 0) { - const isSingleEmail = processedEmails.length === 1 - const existingMembersEmails = processedEmails.filter((email) => - existingEmails.includes(email) + /** + * Existing organization members are not re-invited to the organization, + * but in batch mode they still receive a workspace invitation covering + * the selected workspaces they don't already have access to (or a + * pending invitation for). The inviter's own email is always treated as + * covered. + */ + const memberWorkspaceInvites: Array<{ email: string; grants: WorkspaceGrantPayload[] }> = [] + const membersAlreadyCovered: string[] = [] + + if (isBatch) { + const inviterEmail = session.user.email?.toLowerCase() ?? null + const eligibleMemberEmails = memberEmails.filter((email) => email !== inviterEmail) + membersAlreadyCovered.push(...memberEmails.filter((email) => email === inviterEmail)) + + const grantWorkspaceIds = validGrants.map((grant) => grant.workspaceId) + const eligibleMemberUserIds = eligibleMemberEmails.map( + (email) => memberUserIdByEmail.get(email) as string ) + + const accessibleRows = + eligibleMemberUserIds.length > 0 + ? await db + .select({ userId: permissions.userId, workspaceId: permissions.entityId }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.userId, eligibleMemberUserIds), + inArray(permissions.entityId, grantWorkspaceIds) + ) + ) + : [] + const accessibleByUserId = new Map>() + for (const row of accessibleRows) { + const workspaceIds = accessibleByUserId.get(row.userId) ?? new Set() + workspaceIds.add(row.workspaceId) + accessibleByUserId.set(row.userId, workspaceIds) + } + + const pendingGrantRows = + eligibleMemberEmails.length > 0 + ? await db + .select({ + email: invitation.email, + workspaceId: invitationWorkspaceGrant.workspaceId, + }) + .from(invitationWorkspaceGrant) + .innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId)) + .where( + and( + inArray(invitationWorkspaceGrant.workspaceId, grantWorkspaceIds), + inArray(invitation.email, eligibleMemberEmails), + eq(invitation.status, 'pending') + ) + ) + : [] + const pendingWorkspaceIdsByEmail = new Map>() + for (const row of pendingGrantRows) { + const email = row.email.toLowerCase() + const workspaceIds = pendingWorkspaceIdsByEmail.get(email) ?? new Set() + workspaceIds.add(row.workspaceId) + pendingWorkspaceIdsByEmail.set(email, workspaceIds) + } + + for (const email of eligibleMemberEmails) { + const memberUserId = memberUserIdByEmail.get(email) as string + const accessibleWorkspaceIds = accessibleByUserId.get(memberUserId) + const pendingWorkspaceIds = pendingWorkspaceIdsByEmail.get(email) + + const grantsNeeded = validGrants.filter( + (grant) => + !accessibleWorkspaceIds?.has(grant.workspaceId) && + !pendingWorkspaceIds?.has(grant.workspaceId) + ) + + if (grantsNeeded.length > 0) { + memberWorkspaceInvites.push({ email, grants: grantsNeeded }) + } else { + membersAlreadyCovered.push(email) + } + } + } else { + membersAlreadyCovered.push(...memberEmails) + } + + if (emailsToInvite.length === 0 && memberWorkspaceInvites.length === 0) { + const isSingleEmail = processedEmails.length === 1 const pendingInvitationEmails = processedEmails.filter((email) => pendingEmails.includes(email) ) if (isSingleEmail) { - if (existingMembersEmails.length > 0) { + if (membersAlreadyCovered.length > 0) { return NextResponse.json( - { error: 'Failed to send invitation. User is already a part of the organization.' }, + { + error: isBatch + ? 'Failed to send invitation. User already has access or a pending invitation to every selected workspace.' + : 'Failed to send invitation. User is already a part of the organization.', + }, { status: 400 } ) } @@ -279,9 +382,11 @@ export const POST = withRouteHandler( return NextResponse.json( { - error: 'All emails are already members or have pending invitations.', + error: isBatch + ? 'All emails are already members with access to the selected workspaces or have pending invitations.' + : 'All emails are already members or have pending invitations.', details: { - existingMembers: existingMembersEmails, + existingMembers: membersAlreadyCovered, pendingInvitations: pendingInvitationEmails, }, }, @@ -291,9 +396,10 @@ export const POST = withRouteHandler( const orgSubscription = await getOrganizationSubscription(organizationId) const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan) - const seatValidation = enforceFixedSeats - ? await validateSeatAvailability(organizationId, emailsToInvite.length) - : null + const seatValidation = + enforceFixedSeats && emailsToInvite.length > 0 + ? await validateSeatAvailability(organizationId, emailsToInvite.length) + : null if (seatValidation && !seatValidation.canInvite) { return NextResponse.json( { @@ -316,88 +422,148 @@ export const POST = withRouteHandler( .limit(1) const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - const sentInvitations: Array<{ id: string; email: string }> = [] + /** + * Organization invitations (new emails, all selected grants) and + * workspace invitations (existing members, only the grants they lack) + * share one create/send/rollback pipeline; they differ only in `kind`, + * grants, and audit treatment. + */ + const pendingSends = [ + ...emailsToInvite.map((email) => ({ + kind: 'organization' as const, + email, + grants: validGrants, + })), + ...memberWorkspaceInvites.map((memberInvite) => ({ + kind: 'workspace' as const, + email: memberInvite.email, + grants: memberInvite.grants, + })), + ] + + const sentInvitations: Array<{ + id: string + email: string + kind: 'organization' | 'workspace' + workspaceIds: string[] + }> = [] const failedInvitations: Array<{ email: string; error: string }> = [] - for (const email of emailsToInvite) { + for (const send of pendingSends) { + const sendRole = send.kind === 'organization' ? role : 'member' try { const { invitationId, token } = await createPendingInvitation({ - kind: 'organization', - email, + kind: send.kind, + email: send.email, inviterId: session.user.id, organizationId, - role, - grants: validGrants, + membershipIntent: 'internal', + role: sendRole, + grants: send.grants, }) const emailResult = await sendInvitationEmail({ invitationId, token, - kind: 'organization', - email, + kind: send.kind, + email: send.email, inviterName, organizationId, - organizationRole: role, - grants: validGrants, + organizationRole: sendRole, + grants: send.grants, }) if (!emailResult.success) { - logger.error('Failed to send organization invitation email', { - email, + logger.error('Failed to send invitation email', { + kind: send.kind, + email: send.email, error: emailResult.error, }) failedInvitations.push({ - email, + email: send.email, error: emailResult.error || 'Unknown email delivery error', }) await cancelPendingInvitation(invitationId) continue } - sentInvitations.push({ id: invitationId, email }) + sentInvitations.push({ + id: invitationId, + email: send.email, + kind: send.kind, + workspaceIds: send.grants.map((grant) => grant.workspaceId), + }) } catch (creationError) { - logger.error('Failed to create organization invitation', { email, error: creationError }) + logger.error('Failed to create invitation', { + kind: send.kind, + email: send.email, + error: creationError, + }) failedInvitations.push({ - email, - error: - creationError instanceof Error - ? creationError.message - : 'Failed to create invitation', + email: send.email, + error: getErrorMessage(creationError, 'Failed to create invitation'), }) } } for (const inv of sentInvitations) { - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: organizationEntry.name, - description: `Invited ${inv.email} to organization as ${role}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: role, - isBatch, - workspaceGrantCount: validGrants.length, - enforcedFixedSeats: enforceFixedSeats, - plan: orgSubscription?.plan ?? null, - }, - request, - }) + if (inv.kind === 'organization') { + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: organizationEntry.name, + description: `Invited ${inv.email} to organization as ${role}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: role, + isBatch, + workspaceGrantCount: validGrants.length, + enforcedFixedSeats: enforceFixedSeats, + plan: orgSubscription?.plan ?? null, + }, + request, + }) + continue + } + + for (const workspaceId of inv.workspaceIds) { + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.MEMBER_INVITED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: inv.email, + description: `Invited existing organization member ${inv.email} to workspace`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + organizationId, + isBatch, + }, + request, + }) + } } - const sentEmails = sentInvitations.map((inv) => inv.email) + const sentOrgInvitations = sentInvitations.filter((inv) => inv.kind === 'organization') + const totalInvitationsSent = sentInvitations.length const responseData = { - invitationsSent: sentInvitations.length, - invitedEmails: sentEmails, + invitationsSent: totalInvitationsSent, + invitedEmails: sentInvitations.map((inv) => inv.email), failedInvitations, - existingMembers: processedEmails.filter((email) => existingEmails.includes(email)), - pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)), + existingMembers: membersAlreadyCovered, + pendingInvitations: processedEmails.filter( + (email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email) + ), invalidEmails: invitationEmails.filter( (email) => !quickValidateEmail(email.trim().toLowerCase()).isValid ), @@ -405,15 +571,15 @@ export const POST = withRouteHandler( ...(seatValidation ? { seatInfo: { - seatsUsed: seatValidation.currentSeats + sentInvitations.length, + seatsUsed: seatValidation.currentSeats + sentOrgInvitations.length, maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - sentInvitations.length, + availableSeats: seatValidation.availableSeats - sentOrgInvitations.length, }, } : {}), } - if (failedInvitations.length > 0 && sentInvitations.length === 0) { + if (failedInvitations.length > 0 && totalInvitationsSent === 0) { return NextResponse.json( { success: false, @@ -430,7 +596,7 @@ export const POST = withRouteHandler( { success: false, error: 'Some invitation emails failed to send.', - message: `${sentInvitations.length} invitation(s) sent, ${failedInvitations.length} failed`, + message: `${totalInvitationsSent} invitation(s) sent, ${failedInvitations.length} failed`, data: responseData, }, { status: 207 } @@ -439,7 +605,7 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, - message: `${sentInvitations.length} invitation(s) sent successfully`, + message: `${totalInvitationsSent} invitation(s) sent successfully`, data: responseData, }) } catch (error) { diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts index be89633d7e9..ce656d6be50 100644 --- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' const logger = createLogger('TableCancelRunsAPI') @@ -32,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(cancelTableRunsContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, scope, rowId } = parsed.data.body + const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -42,7 +42,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined) + const filterError = tableFilterError(filter, table.schema.columns) + if (filterError) return filterError + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { + filter, + excludeRowIds, + }) logger.info( `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${ rowId ? ` rowId=${rowId}` : '' diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 6b87c84f644..7eecb5ee466 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -17,7 +17,7 @@ import { updateColumnConstraints, updateColumnType, } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' const logger = createLogger('TableColumnsAPI') @@ -63,13 +63,17 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } + const msg = rootErrorMessage(error) + if ( + msg.includes('already exists') || + msg.includes('maximum column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } + if (msg === 'Table not found') { + return NextResponse.json({ error: msg }, { status: 404 }) } logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) @@ -146,22 +150,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } + const msg = rootErrorMessage(error) + if (msg.includes('not found') || msg.includes('Table not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Cannot set unique column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) } logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) @@ -211,13 +214,12 @@ export const DELETE = withRouteHandler( return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } + const msg = rootErrorMessage(error) + if (msg.includes('not found') || msg === 'Table not found') { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if (msg.includes('Cannot delete') || msg.includes('last column')) { + return NextResponse.json({ error: msg }, { status: 400 }) } logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index 341f58662b0..00856ae4a1a 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' const logger = createLogger('TableRunColumnAPI') @@ -25,16 +25,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(runColumnContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, groupIds, runMode, rowIds, limit } = parsed.data.body + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body const access = await checkAccess(tableId, auth.userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) + // Validate the filter up front (the dispatcher reuses it) so a bad field fails fast. + const filterError = tableFilterError(filter, access.table.schema.columns) + if (filterError) return filterError + const { dispatchId } = await runWorkflowColumn({ tableId, workspaceId, groupIds, mode: runMode, rowIds, + filter, + excludeRowIds, limit, requestId, triggeredByUserId: auth.userId, diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts new file mode 100644 index 00000000000..9565725c8a6 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { + mockCheckAccess, + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockRunTableDelete, + mockTableFilterError, + mockTasksTrigger, + flags, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockRunTableDelete: vi.fn(), + mockTableFilterError: vi.fn(), + mockTasksTrigger: vi.fn(), + flags: { triggerDev: false }, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('job-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/delete-runner', () => ({ runTableDelete: mockRunTableDelete })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isTriggerDevEnabled() { + return flags.triggerDev + }, +})) +vi.mock('@/background/table-delete', () => ({ tableDeleteTask: { id: 'table-delete' } })) +vi.mock('@trigger.dev/sdk', () => ({ + tasks: { trigger: mockTasksTrigger }, + task: (config: unknown) => config, +})) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + tableFilterError: mockTableFilterError, + } +}) + +import { POST } from '@/app/api/table/[tableId]/delete-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'status', type: 'string' }] }, + metadata: null, + rowCount: 1000, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/delete-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { + workspaceId: 'workspace-1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], +} + +describe('POST /api/table/[tableId]/delete-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockRunTableDelete.mockResolvedValue(undefined) + mockTableFilterError.mockReturnValue(null) + mockTasksTrigger.mockResolvedValue({ id: 'run_1' }) + flags.triggerDev = false + }) + + it('claims the job slot and kicks off the delete worker with filter + exclusions', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'delete', { + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(String), + }) + expect(mockRunTableDelete).toHaveBeenCalledWith( + expect.objectContaining({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + workspaceId: 'workspace-1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(Date), + }) + ) + }) + + it('allows a whole-table delete with no filter', async () => { + const response = await makeRequest({ workspaceId: 'workspace-1' }) + expect(response.status).toBe(200) + expect(mockRunTableDelete).toHaveBeenCalledWith( + expect.objectContaining({ filter: undefined, cutoff: expect.any(Date) }) + ) + }) + + it('returns 409 when a job is already in progress (claim lost)', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 on an invalid filter without claiming the slot', async () => { + mockTableFilterError.mockReturnValue(NextResponse.json({ error: 'bad field' }, { status: 400 })) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 when the table is archived', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ archivedAt: new Date() }) }) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) + + it('routes through trigger.dev (ISO cutoff, tagged) when the flag is on', async () => { + flags.triggerDev = true + const response = await makeRequest(validBody) + + expect(response.status).toBe(200) + expect(mockRunTableDelete).not.toHaveBeenCalled() + expect(mockTasksTrigger).toHaveBeenCalledWith( + 'table-delete', + expect.objectContaining({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(String), + }), + { tags: ['tableId:tbl_1', 'jobId:job-id-xyz'] } + ) + }) + + it('releases the job claim when the trigger.dev dispatch fails (no ghost running job)', async () => { + flags.triggerDev = true + mockTasksTrigger.mockRejectedValueOnce(new Error('trigger.dev unreachable')) + + const response = await makeRequest(validBody) + + expect(response.status).toBe(500) + expect(mockReleaseJobClaim).toHaveBeenCalledWith('tbl_1', 'job-id-xyz') + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts new file mode 100644 index 00000000000..7dcd8c37676 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -0,0 +1,128 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { deleteTableRowsAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/service' +import type { TableDeleteJobPayload } from '@/lib/table/types' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' + +const logger = createLogger('TableDeleteAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/delete-async + * + * Kicks off a background "select all" delete: the client sends the active filter (and an optional + * exclusion set) instead of every row id. Claims the table's single job slot (mutually exclusive + * with imports), captures a `created_at` cutoff so rows inserted while the job runs survive, then + * runs the paginated delete worker detached. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const userId = authResult.userId + + const parsed = await parseRequest(deleteTableRowsAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, filter, excludeRowIds, estimatedCount } = parsed.data.body + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + const { table } = access + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + if (table.archivedAt) { + return NextResponse.json({ error: 'Cannot delete from an archived table' }, { status: 400 }) + } + + // Validate the filter up front so the caller gets immediate feedback (the worker reuses it). + const filterError = tableFilterError(filter, table.schema.columns) + if (filterError) return filterError + + // Rows inserted after this instant are spared (created_at <= cutoff in the worker). + const cutoff = new Date() + + // Atomically claim the job slot — one background job per table, so this also blocks while an + // import is in flight (and vice versa). The scope is persisted to the job's payload so read + // paths can mask the doomed rows while the job runs (see `pendingDeleteMask`). + const jobId = generateId() + const payload: TableDeleteJobPayload = { + filter, + excludeRowIds, + cutoff: cutoff.toISOString(), + // Clamp the client's display estimate to reality so a stale/bogus value + // can't drive counts negative or hide more than the table holds. + ...(estimatedCount != null ? { doomedCount: Math.min(estimatedCount, table.rowCount) } : {}), + } + const claimed = await markTableJobRunning(tableId, jobId, 'delete', payload) + if (!claimed) { + return NextResponse.json( + { error: 'A job is already in progress for this table' }, + { status: 409 } + ) + } + + if (isTriggerDevEnabled) { + // Trigger.dev runs the delete outside the web container (survives deploys) and retries — + // safe: the keyset + cutoff walk just deletes whatever remains. + try { + const [{ tableDeleteTask }, { tasks }] = await Promise.all([ + import('@/background/table-delete'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger( + 'table-delete', + { jobId, tableId, workspaceId, filter, excludeRowIds, cutoff: cutoff.toISOString() }, + { tags: [`tableId:${tableId}`, `jobId:${jobId}`] } + ) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-delete', () => + runTableDelete({ + jobId, + tableId, + workspaceId, + filter, + excludeRowIds, + cutoff, + }).catch(async (error) => { + // No retry machinery on the detached path — fail the job immediately. + await markTableDeleteFailed(tableId, jobId, error) + throw error + }) + ) + } + + logger.info(`[${requestId}] Async row delete started`, { + tableId, + jobId, + hasFilter: Boolean(filter), + excluded: excludeRowIds?.length ?? 0, + }) + return NextResponse.json({ success: true, data: { tableId, jobId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts new file mode 100644 index 00000000000..177e02abf37 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockMarkTableJobRunning, mockRunTableExport } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockRunTableExport: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('job-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ markTableJobRunning: mockMarkTableJobRunning })) +vi.mock('@/lib/table/export-runner', () => ({ runTableExport: mockRunTableExport })) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { POST } from '@/app/api/table/[tableId]/export-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 50000, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { workspaceId: 'workspace-1', format: 'csv' } + +describe('POST /api/table/[tableId]/export-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockRunTableExport.mockResolvedValue(undefined) + }) + + it('claims an export job and kicks off the worker', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'export', { + format: 'csv', + }) + expect(mockRunTableExport).toHaveBeenCalledWith({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + workspaceId: 'workspace-1', + format: 'csv', + }) + }) + + it('defaults the format to csv', async () => { + const response = await makeRequest({ workspaceId: 'workspace-1' }) + expect(response.status).toBe(200) + expect(mockRunTableExport).toHaveBeenCalledWith(expect.objectContaining({ format: 'csv' })) + }) + + it('returns 409 when the claim fails', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableExport).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableExport).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts new file mode 100644 index 00000000000..26ded9b6e1d --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { exportTableAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExportAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/export-async + * + * Kicks off a background export for large tables (small ones stream synchronously via `/export`). + * Export jobs are read-only, so they bypass the one-running-job-per-table gate (the partial-unique + * index excludes `type = 'export'`) — an export can run alongside an import or delete, and the + * delete-mask keeps a mid-delete export consistent with the delete's outcome. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(exportTableAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.body + + const access = await checkAccess(tableId, authResult.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const jobId = generateId() + const jobPayload: TableExportJobPayload = { format } + const claimed = await markTableJobRunning(tableId, jobId, 'export', jobPayload) + if (!claimed) { + // Only possible against another running *export*-typed insert race losing on the pkey, or a + // missing table — the active-job index excludes exports. + return NextResponse.json({ error: 'Failed to start export' }, { status: 409 }) + } + + const payload: TableExportPayload = { jobId, tableId, workspaceId, format } + if (isTriggerDevEnabled) { + try { + const [{ tableExportTask }, { tasks }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-export', payload, { + tags: [`tableId:${tableId}`, `jobId:${jobId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-export', () => runTableExport(payload)) + } + + logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) + return NextResponse.json({ success: true, data: { tableId, jobId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts new file mode 100644 index 00000000000..c3458093e68 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockGetTableJob, mockGeneratePresignedDownloadUrl } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockGeneratePresignedDownloadUrl: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableJob: mockGetTableJob })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { GET } from '@/app/api/table/[tableId]/export/download/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 0, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(query: Record, tableId = 'tbl_1') { + const qs = new URLSearchParams(query).toString() + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export/download?${qs}`) + return GET(req, { params: Promise.resolve({ tableId }) }) +} + +const validQuery = { workspaceId: 'workspace-1', jobId: 'job_1' } + +describe('GET /api/table/[tableId]/export/download', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'ready', + payload: { format: 'csv', resultKey: 'workspace/workspace-1/exports/tbl_1/job_1/people.csv' }, + }) + mockGeneratePresignedDownloadUrl.mockResolvedValue('https://storage.example/signed-url') + }) + + it('resolves a ready export to a presigned URL', async () => { + const response = await makeRequest(validQuery) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ url: 'https://storage.example/signed-url', fileName: 'people.csv' }) + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( + 'workspace/workspace-1/exports/tbl_1/job_1/people.csv', + 'workspace' + ) + }) + + it('404s when the job is missing or not an export', async () => { + mockGetTableJob.mockResolvedValue({ id: 'job_1', type: 'delete', status: 'ready', payload: {} }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(404) + }) + + it('409s when the export is not ready yet', async () => { + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'running', + payload: { format: 'csv' }, + }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(409) + }) + + it('410s when the result file is gone from the payload', async () => { + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'ready', + payload: { format: 'csv' }, + }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(410) + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(401) + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validQuery, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts new file mode 100644 index 00000000000..577c2747b8c --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { exportDownloadContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableJob } from '@/lib/table/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExportDownload') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/table/[tableId]/export/download?jobId=… + * + * Resolves a completed export job to a short-lived presigned URL for the generated file. The job + * must belong to the table, be an export, and be `ready` — the worker stamps `resultKey` onto the + * job payload when the upload lands. + */ +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(exportDownloadContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.query + + const access = await checkAccess(tableId, authResult.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const job = await getTableJob(tableId, jobId) + if (!job || job.type !== 'export') { + return NextResponse.json({ error: 'Export job not found' }, { status: 404 }) + } + if (job.status !== 'ready') { + return NextResponse.json({ error: 'Export is not ready' }, { status: 409 }) + } + const payload = job.payload as TableExportJobPayload | null + if (!payload?.resultKey) { + return NextResponse.json({ error: 'Export file is no longer available' }, { status: 410 }) + } + + const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') + const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` + logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) + return NextResponse.json({ success: true, data: { url, fileName } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts index 18fa93aca80..7ed47fa66e3 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts @@ -16,7 +16,7 @@ vi.mock('@sim/utils/id', () => ({ generateId: vi.fn().mockReturnValue('import-id-xyz'), generateShortId: vi.fn().mockReturnValue('short-id'), })) -vi.mock('@/lib/table/service', () => ({ markTableImporting: mockMarkTableImporting })) +vi.mock('@/lib/table/service', () => ({ markTableJobRunning: mockMarkTableImporting })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: (_label: string, work: () => Promise) => { @@ -92,7 +92,7 @@ describe('POST /api/table/[tableId]/import-async', () => { expect(response.status).toBe(200) expect(data.data).toEqual({ tableId: 'tbl_1', importId: 'import-id-xyz' }) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz', 'import') expect(mockRunTableImport).toHaveBeenCalledWith( expect.objectContaining({ tableId: 'tbl_1', diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 46190cbfb06..f256bf5f35a 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -4,11 +4,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { runTableImport } from '@/lib/table/import-runner' -import { markTableImporting } from '@/lib/table/service' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/service' import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableImportIntoAsync') @@ -56,31 +57,48 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } const delimiter = ext === 'tsv' ? '\t' : ',' - // Atomically claim the table — the single concurrency gate. If another import already holds it, - // this returns false (no overlapping workers writing colliding row positions). + // Atomically claim the table's job slot — the single concurrency gate. If another job (import + // or delete) already holds it, this returns false (no overlapping workers). const importId = generateId() - const claimed = await markTableImporting(tableId, importId) + const claimed = await markTableJobRunning(tableId, importId, 'import') if (!claimed) { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } - runDetached('table-import', () => - runTableImport({ - importId, - tableId, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode, - mapping, - createColumns, - }) - ) + const importPayload: TableImportPayload = { + importId, + tableId, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode, + mapping, + createColumns, + } + if (isTriggerDevEnabled) { + // Trigger.dev runs the import outside the web container, so it survives app deploys. + try { + const [{ tableImportTask }, { tasks }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-import', importPayload, { + tags: [`tableId:${tableId}`, `jobId:${importId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, importId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(importPayload)) + } logger.info(`[${requestId}] Async CSV import into existing table started`, { tableId, diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index ac3e1221924..76650baf4c1 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -55,8 +55,8 @@ vi.mock('@/lib/table/service', () => ({ importAppendRows: mockImportAppendRows, importReplaceRows: mockImportReplaceRows, dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, - markTableImporting: mockMarkTableImporting, - releaseImportClaim: mockReleaseImportClaim, + markTableJobRunning: mockMarkTableImporting, + releaseJobClaim: mockReleaseImportClaim, })) import { POST } from '@/app/api/table/[tableId]/import/route' @@ -184,7 +184,7 @@ describe('POST /api/table/[tableId]/import', () => { it('releases the import claim after a successful write', async () => { const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) expect(response.status).toBe(200) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d', 'import') expect(mockReleaseImportClaim).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index f04827d1ab1..ef57b09aced 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -28,8 +28,8 @@ import { importAppendRows, importReplaceRows, inferColumnType, - markTableImporting, - releaseImportClaim, + markTableJobRunning, + releaseJobClaim, sanitizeName, type TableDefinition, type TableSchema, @@ -128,11 +128,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (table.archivedAt) { return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) } - // Don't run a sync import on top of an in-flight background import — concurrent writers + // Don't run a sync import on top of an in-flight background job — concurrent writers // would insert at colliding row positions. - if (table.importStatus === 'importing') { + if (table.jobStatus === 'running') { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } @@ -253,12 +253,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot // taken before the parse/validation; a background import could claim the table in that window. - // markTableImporting is the single atomic gate (same one the async kickoff uses) — released in + // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in // the finally so a sync import can't write concurrently with a background one (corrupts replace). const syncImportId = generateId() - if (!(await markTableImporting(tableId, syncImportId))) { + if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } @@ -399,6 +399,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } finally { fileStream?.destroy() // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseImportClaim(tableId, claimedImportId).catch(() => {}) + if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts similarity index 61% rename from apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts rename to apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts index d45baae77e2..f1837b42dc7 100644 --- a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts @@ -6,13 +6,19 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockCheckAccess, mockMarkImportCanceled, mockAppendTableEvent } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockMarkImportCanceled: vi.fn(), - mockAppendTableEvent: vi.fn(), -})) +const { mockCheckAccess, mockMarkJobCanceled, mockGetTableJob, mockAppendTableEvent } = vi.hoisted( + () => ({ + mockCheckAccess: vi.fn(), + mockMarkJobCanceled: vi.fn(), + mockGetTableJob: vi.fn(), + mockAppendTableEvent: vi.fn(), + }) +) -vi.mock('@/lib/table/service', () => ({ markImportCanceled: mockMarkImportCanceled })) +vi.mock('@/lib/table/service', () => ({ + markJobCanceled: mockMarkJobCanceled, + getTableJob: mockGetTableJob, +})) vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') @@ -23,14 +29,14 @@ vi.mock('@/app/api/table/utils', async () => { } }) -import { POST } from '@/app/api/table/[tableId]/import/cancel/route' +import { POST } from '@/app/api/table/[tableId]/job/cancel/route' function buildTable(overrides: Partial = {}): TableDefinition { return { id: 'tbl_1', name: 'People', description: null, - schema: { columns: [{ name: 'name', type: 'string' }] }, + schema: { columns: [] }, metadata: null, rowCount: 0, maxRows: 1_000_000, @@ -44,7 +50,7 @@ function buildTable(overrides: Partial = {}): TableDefinition { } function makeRequest(body: unknown, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import/cancel`, { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/job/cancel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -52,9 +58,9 @@ function makeRequest(body: unknown, tableId = 'tbl_1') { return POST(req, { params: Promise.resolve({ tableId }) }) } -const validBody = { workspaceId: 'workspace-1', importId: 'import-id-xyz' } +const validBody = { workspaceId: 'workspace-1', jobId: 'job_1' } -describe('POST /api/table/[tableId]/import/cancel', () => { +describe('POST /api/table/[tableId]/job/cancel', () => { beforeEach(() => { vi.clearAllMocks() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ @@ -63,27 +69,31 @@ describe('POST /api/table/[tableId]/import/cancel', () => { authType: 'session', }) mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockMarkImportCanceled.mockResolvedValue(true) + mockMarkJobCanceled.mockResolvedValue(true) + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'delete', + status: 'running', + payload: null, + }) }) - it('cancels the import and emits a canceled event', async () => { + it('cancels the job and emits a typed cancel event', async () => { const response = await makeRequest(validBody) const data = await response.json() expect(response.status).toBe(200) expect(data.data).toEqual({ canceled: true }) - expect(mockMarkImportCanceled).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'import', status: 'canceled', importId: 'import-id-xyz' }) + expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled', jobId: 'job_1' }) ) }) - it('does not emit an event when nothing was importing', async () => { - mockMarkImportCanceled.mockResolvedValue(false) + it('does not emit an event when nothing was running', async () => { + mockMarkJobCanceled.mockResolvedValue(false) const response = await makeRequest(validBody) const data = await response.json() - - expect(response.status).toBe(200) expect(data.data).toEqual({ canceled: false }) expect(mockAppendTableEvent).not.toHaveBeenCalled() }) @@ -92,19 +102,12 @@ describe('POST /api/table/[tableId]/import/cancel', () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) const response = await makeRequest(validBody) expect(response.status).toBe(401) - expect(mockMarkImportCanceled).not.toHaveBeenCalled() - }) - - it('returns the access error status when access is denied', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const response = await makeRequest(validBody) - expect(response.status).toBe(403) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() }) it('returns 400 on workspace mismatch', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ workspaceId: 'other-ws' }) }) - const response = await makeRequest(validBody) + const response = await makeRequest({ ...validBody, workspaceId: 'other' }) expect(response.status).toBe(400) - expect(mockMarkImportCanceled).not.toHaveBeenCalled() + expect(mockMarkJobCanceled).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts similarity index 55% rename from apps/sim/app/api/table/[tableId]/import/cancel/route.ts rename to apps/sim/app/api/table/[tableId]/job/cancel/route.ts index 62ab7310f47..b4ee3d98346 100644 --- a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts @@ -1,15 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { cancelTableImportContract } from '@/lib/api/contracts/tables' +import { cancelTableJobContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { appendTableEvent } from '@/lib/table/events' -import { markImportCanceled } from '@/lib/table/service' +import { getTableJob, markJobCanceled } from '@/lib/table/service' +import type { TableJobType } from '@/lib/table/types' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableImportCancelAPI') +const logger = createLogger('TableJobCancelAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -19,11 +20,11 @@ interface RouteParams { } /** - * POST /api/table/[tableId]/import/cancel + * POST /api/table/[tableId]/job/cancel * - * Cancels an in-flight async CSV import. Flips the table's import status to `canceled`, which makes - * the detached worker's next ownership check fail so it stops inserting. Committed rows are left in - * place (no rollback) — the user can delete the table. No-op if the import already finished. + * Cancels an in-flight async table job (import or delete). Flips the table's job status to + * `canceled`, which makes the detached worker's next ownership check fail so it stops. Committed + * work (inserted/deleted rows) is left in place (no rollback). No-op if the job already finished. */ export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() @@ -33,10 +34,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const parsed = await parseRequest(cancelTableImportContract, request, { params }) + const parsed = await parseRequest(cancelTableJobContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, importId } = parsed.data.body + const { workspaceId, jobId } = parsed.data.body const access = await checkAccess(tableId, authResult.userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) @@ -44,11 +45,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const canceled = await markImportCanceled(tableId, importId) + // Resolve the job's actual type (from its own row — the table-level derivation excludes + // exports) so the cancel event carries the right `type`. + const job = await getTableJob(tableId, jobId) + const type = (job?.type ?? 'import') as TableJobType + + const canceled = await markJobCanceled(tableId, jobId) if (canceled) { - void appendTableEvent({ kind: 'import', tableId, importId, status: 'canceled' }) + void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) } - logger.info(`[${requestId}] Import cancel requested`, { tableId, importId, canceled }) + logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) return NextResponse.json({ success: true, data: { canceled } }) }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index c0b018f854e..0d185a74784 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -68,10 +68,11 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab table.updatedAt instanceof Date ? table.updatedAt.toISOString() : String(table.updatedAt), - importStatus: table.importStatus ?? null, - importId: table.importId ?? null, - importError: table.importError ?? null, - importRowsProcessed: table.importRowsProcessed ?? 0, + jobStatus: table.jobStatus ?? null, + jobId: table.jobId ?? null, + jobType: table.jobType ?? null, + jobError: table.jobError ?? null, + jobRowsProcessed: table.jobRowsProcessed ?? 0, }, }, }) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 18486c370f6..b1865223f83 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -16,7 +16,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { deleteRow, updateRow } from '@/lib/table' import { rowWireTranslators } from '@/app/api/table/row-wire' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + rootErrorMessage, + rowWriteErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableRowAPI') @@ -167,21 +172,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) + if (rootErrorMessage(error) === 'Row not found') { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 31708805ad2..372cd758041 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { type BatchInsertTableRowsBodyInput, @@ -14,7 +13,7 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation import { type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import type { Filter, RowData, Sort, TableRowsCursor, TableSchema } from '@/lib/table' import { batchInsertRows, batchUpdateRows, @@ -29,7 +28,7 @@ import { import { queryRows } from '@/lib/table/service' import { TableQueryValidationError } from '@/lib/table/sql' import { rowWireTranslators } from '@/app/api/table/row-wire' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableRowsAPI') @@ -98,18 +97,8 @@ async function handleBatchInsert( }, }) } catch (error) { - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') || - errorMessage.match(/^Row \d+:/) - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch inserting rows:`, error) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) @@ -197,17 +186,8 @@ export const POST = withRouteHandler( return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error inserting row:`, error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) @@ -231,12 +211,14 @@ export const GET = withRouteHandler( const workspaceId = searchParams.get('workspaceId') const filterParam = searchParams.get('filter') const sortParam = searchParams.get('sort') + const afterParam = searchParams.get('after') const limit = searchParams.get('limit') const offset = searchParams.get('offset') const includeTotalParam = searchParams.get('includeTotal') let filter: Record | undefined let sort: Sort | undefined + let after: TableRowsCursor | undefined try { if (filterParam) { @@ -245,14 +227,18 @@ export const GET = withRouteHandler( if (sortParam) { sort = JSON.parse(sortParam) as Sort } + if (afterParam) { + after = JSON.parse(afterParam) as TableRowsCursor + } } catch { - return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) + return NextResponse.json({ error: 'Invalid filter, sort, or after JSON' }, { status: 400 }) } const validated = tableRowsQuerySchema.parse({ workspaceId, filter, sort, + after, limit, offset, includeTotal: includeTotalParam, @@ -278,6 +264,7 @@ export const GET = withRouteHandler( sort: validated.sort ? wire.sortIn(validated.sort) : undefined, limit: validated.limit, offset: validated.offset, + after: validated.after, includeTotal: validated.includeTotal, }, requestId @@ -403,18 +390,8 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating rows by filter:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) @@ -506,11 +483,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error deleting rows:`, error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) @@ -575,22 +549,8 @@ export const PATCH = withRouteHandler( return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be valid') || - errorMessage.includes('must be string') || - errorMessage.includes('must be number') || - errorMessage.includes('must be boolean') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Rows not found') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch updating rows:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index c8d9184e8c3..bc97623ef9a 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { upsertTableRowContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' @@ -10,7 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' import { rowWireTranslators } from '@/app/api/table/row-wire' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableUpsertAPI') @@ -80,19 +79,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error upserting row:`, error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts index 8ecdd2a923a..eaaf90597cc 100644 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -84,7 +84,7 @@ describe('POST /api/table/import-async', () => { expect(response.status).toBe(200) expect(data.data).toEqual({ tableId: 'tbl_async', importId: 'import-id-123' }) expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ importStatus: 'importing', importId: 'import-id-123' }), + expect.objectContaining({ jobStatus: 'running', jobType: 'import', jobId: 'import-id-123' }), expect.any(String) ) expect(mockRunTableImport).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 239268053e7..f10b822b6e3 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -4,19 +4,22 @@ import { type NextRequest, NextResponse } from 'next/server' import { importTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, + deleteTable, getWorkspaceTableLimits, listTables, + releaseJobClaim, sanitizeName, TABLE_LIMITS, TableConflictError, } from '@/lib/table' -import { runTableImport } from '@/lib/table/import-runner' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableImportAsync') @@ -83,8 +86,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, maxRows: planLimits.maxRowsPerTable, maxTables: planLimits.maxTables, - importStatus: 'importing', - importId, + jobStatus: 'running', + jobType: 'import', + jobId: importId, }, requestId ) @@ -98,18 +102,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw error } - runDetached('table-import', () => - runTableImport({ - importId, - tableId: table.id, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode: 'create', - }) - ) + const importPayload: TableImportPayload = { + importId, + tableId: table.id, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode: 'create', + } + if (isTriggerDevEnabled) { + // Trigger.dev runs the import outside the web container, so it survives app deploys. + try { + const [{ tableImportTask }, { tasks }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-import', importPayload, { + tags: [`tableId:${table.id}`, `jobId:${importId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot — nor, in create mode, the placeholder + // table itself: the user never saw it, so archive it back out of the + // workspace (no hard-delete surface exists; archived is invisible). + await releaseJobClaim(table.id, importId).catch(() => {}) + await deleteTable(table.id, requestId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(importPayload)) + } captureServerEvent( userId, diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts new file mode 100644 index 00000000000..912d769c39f --- /dev/null +++ b/apps/sim/app/api/table/jobs/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { listTableJobsContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkspaceExportJobs } from '@/lib/table/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('TableJobsAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * GET /api/table/jobs?workspaceId=…&type=export + * + * Lists a workspace's export jobs (running + recently finished) for the header tray. Exports are + * excluded from the table-level job derivation, so the tray reads them here. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(listTableJobsContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + + const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const jobs = await listWorkspaceExportJobs(workspaceId) + logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId }) + return NextResponse.json({ success: true, data: { jobs } }) +}) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index ed41a7813d6..94aa8c45b4c 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -217,10 +217,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : t.archivedAt ? String(t.archivedAt) : null, - importStatus: t.importStatus ?? null, - importId: t.importId ?? null, - importError: t.importError ?? null, - importRowsProcessed: t.importRowsProcessed ?? 0, + jobStatus: t.jobStatus ?? null, + jobId: t.jobId ?? null, + jobType: t.jobType ?? null, + jobError: t.jobError ?? null, + jobRowsProcessed: t.jobRowsProcessed ?? 0, } }) diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts new file mode 100644 index 00000000000..df1a05e7c73 --- /dev/null +++ b/apps/sim/app/api/table/utils.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' + +/** Mimics drizzle's DrizzleQueryError: message is the failed SQL, real error on `cause`. */ +function wrapLikeDrizzle(cause: Error): Error { + return new Error('Failed query: insert into "user_table_rows" ...', { cause }) +} + +describe('rootErrorMessage', () => { + it('returns the message of a plain error', () => { + expect(rootErrorMessage(new Error('Schema validation failed: bad'))).toBe( + 'Schema validation failed: bad' + ) + }) + + it('unwraps the cause chain to the deepest error', () => { + const root = new Error('Maximum row limit (10000) reached for table tbl_abc') + expect(rootErrorMessage(wrapLikeDrizzle(root))).toBe(root.message) + }) + + it('stringifies non-Error values', () => { + expect(rootErrorMessage('boom')).toBe('boom') + }) +}) + +describe('rowWriteErrorResponse', () => { + it('rewrites the DB row-limit trigger error into a friendly 400', async () => { + const error = wrapLikeDrizzle( + new Error('Maximum row limit (10000) reached for table tbl_2b15ec29647040e7b8eb5d2949f556cf') + ) + const response = rowWriteErrorResponse(error) + expect(response?.status).toBe(400) + const body = await response?.json() + expect(body.error).toBe('Row limit exceeded — this table is capped at 10,000 rows') + }) + + it('passes known validation messages through as 400', async () => { + const response = rowWriteErrorResponse(new Error('Value for column "email" must be unique')) + expect(response?.status).toBe(400) + const body = await response?.json() + expect(body.error).toBe('Value for column "email" must be unique') + }) + + it('matches per-row batch validation messages', () => { + expect(rowWriteErrorResponse(new Error('Row 3: name is required'))?.status).toBe(400) + }) + + it('returns null for unknown errors so callers keep their generic 500', () => { + expect(rowWriteErrorResponse(new Error('connection refused'))).toBeNull() + expect(rowWriteErrorResponse(wrapLikeDrizzle(new Error('deadlock detected')))).toBeNull() + }) +}) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 41a66e85bb3..c8dde913132 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { createTableColumnBodySchema, @@ -6,12 +7,97 @@ import { updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' import type { MultipartError } from '@/lib/core/utils/multipart' -import type { ColumnDefinition, TableDefinition } from '@/lib/table' -import { getTableById } from '@/lib/table' +import type { ColumnDefinition, Filter, TableDefinition } from '@/lib/table' +import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' +import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** + * Validates a `filter` against the table's column schema, returning a 400 response on a bad field + * (or `null` when the filter is valid or absent). Shared by the routes that accept a filter + * (`delete-async`, `columns/run`) so a bad field fails fast with a clear message. + */ +export function tableFilterError( + filter: Filter | undefined, + columns: ColumnDefinition[] +): NextResponse | null { + if (!filter) return null + try { + buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, columns) + return null + } catch (error) { + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } +} + const logger = createLogger('TableUtils') +/** + * Deepest `Error` message in the cause chain. Drizzle wraps DB errors (e.g. the + * row-limit trigger's RAISE) in a `DrizzleQueryError` whose own message is just + * the failed SQL — substring classification must look at the root cause. + */ +export function rootErrorMessage(error: unknown): string { + let current: unknown = error + while (current instanceof Error && current.cause instanceof Error) { + current = current.cause + } + return toError(current).message +} + +/** + * Known user-facing row-write failures (service validation + the DB row-limit + * trigger). Anything outside this list stays a generic 500 — unknown errors can + * carry SQL/internals that don't belong in a toast. + */ +const ROW_WRITE_ERROR_PATTERNS = [ + 'row limit', + 'Insufficient capacity', + 'Schema validation', + 'must be unique', + 'must be valid', + 'must be string', + 'must be number', + 'must be boolean', + 'unique column', + 'Unique constraint violation', + 'Row size exceeds', + 'conflictTarget', + 'Upsert requires', + 'Rows not found', + 'Filter is required', +] as const + +/** + * Maps a known user-facing row-write failure to a 400 carrying the real message + * (so client toasts can show the actual reason); `null` when the error is + * unrecognized and the caller should log it and return its generic 500. + */ +export function rowWriteErrorResponse(error: unknown): NextResponse | null { + const message = rootErrorMessage(error) + + // Trigger message reads `Maximum row limit (N) reached for table tbl_...` — + // rewrite it for the toast instead of leaking the internal table id. + const limitMatch = message.match(/Maximum row limit \((\d+)\) reached/) + if (limitMatch) { + return NextResponse.json( + { + error: `Row limit exceeded — this table is capped at ${Number(limitMatch[1]).toLocaleString('en-US')} rows`, + }, + { status: 400 } + ) + } + + if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { + return NextResponse.json({ error: message }, { status: 400 }) + } + + return null +} + /** * Next.js buffers the request body for the proxy and silently truncates it past this * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts new file mode 100644 index 00000000000..d5731724e49 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -0,0 +1,85 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsDeployContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performFullDeploy } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsDeployContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId, name, description } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') + if (!access.ok) return access.response + + await assertWorkflowMutable(workflowId) + + logger.info(`[${requestId}] Deploying workflow ${workflowId} via deployments tool`, { + userId: auth.userId, + }) + + const result = await performFullDeploy({ + workflowId, + userId: auth.userId, + workflowName: access.workflow.name || undefined, + versionName: name, + versionDescription: description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + return deploymentToolError( + result.error || 'Failed to deploy workflow', + statusForOrchestrationError(result.errorCode) + ) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return deploymentToolError(error.message, error.status) + } + logger.error(`[${requestId}] Deployment tool deploy error`, { error }) + return deploymentToolError('Failed to deploy workflow', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts new file mode 100644 index 00000000000..01a296fc2fa --- /dev/null +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -0,0 +1,85 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsPromoteContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsPromoteAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsPromoteContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId, version } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') + if (!access.ok) return access.response + + await assertWorkflowMutable(workflowId) + + logger.info( + `[${requestId}] Promoting workflow ${workflowId} to version ${version} via deployments tool`, + { userId: auth.userId } + ) + + const result = await performActivateVersion({ + workflowId, + version, + userId: auth.userId, + workflow: access.workflow as Record, + requestId, + request, + }) + + if (!result.success) { + return deploymentToolError( + result.error || 'Failed to promote deployment version', + statusForOrchestrationError(result.errorCode) + ) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version, + warnings: result.warnings ?? [], + }, + }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return deploymentToolError(error.message, error.status) + } + logger.error(`[${requestId}] Deployment tool promote error`, { error }) + return deploymentToolError('Failed to promote deployment version', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/routes.test.ts b/apps/sim/app/api/tools/deployments/routes.test.ts new file mode 100644 index 00000000000..2c77a0b2e20 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/routes.test.ts @@ -0,0 +1,355 @@ +/** + * @vitest-environment node + * + * Tests for the deployment tool routes under /api/tools/deployments — verifies + * session/internal auth, workspace permission enforcement, and the mapping of + * orchestration results to tool responses. + */ +import { createMockRequest, hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing' +import { WorkflowLockedError } from '@sim/workflow-authz' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockEnforceUserRateLimit, + mockPerformFullDeploy, + mockPerformFullUndeploy, + mockPerformActivateVersion, + mockListWorkflowVersions, + mockGetWorkflowDeploymentVersion, +} = vi.hoisted(() => ({ + mockEnforceUserRateLimit: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), + mockPerformActivateVersion: vi.fn(), + mockListWorkflowVersions: vi.fn(), + mockGetWorkflowDeploymentVersion: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserRateLimit: mockEnforceUserRateLimit, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, + performActivateVersion: mockPerformActivateVersion, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: mockListWorkflowVersions, + getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, +})) + +import { POST as deployPost } from '@/app/api/tools/deployments/deploy/route' +import { POST as promotePost } from '@/app/api/tools/deployments/promote/route' +import { POST as undeployPost } from '@/app/api/tools/deployments/undeploy/route' +import { GET as getVersionGet } from '@/app/api/tools/deployments/version/route' +import { GET as listVersionsGet } from '@/app/api/tools/deployments/versions/route' + +const WORKFLOW_ID = 'wf-1' +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + workspaceId: 'ws-1', + isDeployed: true, +} + +function authorized() { + return { allowed: true, status: 200, workflow: WORKFLOW_RECORD, workspacePermission: 'admin' } +} + +function makePost(path: string, body: unknown) { + return createMockRequest('POST', body, {}, `http://localhost:3000/api/tools/deployments/${path}`) +} + +function makeGet(path: string, query: string) { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/tools/deployments/${path}?${query}` + ) +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockEnforceUserRateLimit.mockResolvedValue(null) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue(authorized()) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) +}) + +describe('POST /api/tools/deployments/deploy', () => { + beforeEach(() => { + mockPerformFullDeploy.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + version: 4, + }) + }) + + it('rejects unauthenticated requests', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) + + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(401) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('requires admin permission on the workflow workspace', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Access denied', + workflow: WORKFLOW_RECORD, + workspacePermission: 'write', + }) + + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(403) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'admin', + }) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('deploys and returns the new version', async () => { + const response = await deployPost( + makePost('deploy', { + workflowId: WORKFLOW_ID, + workspaceId: 'ws-1', + name: 'Release 4', + description: 'Fixes the agent prompt', + }) + ) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + versionName: 'Release 4', + versionDescription: 'Fixes the agent prompt', + }) + ) + + const body = await response.json() + expect(body).toEqual({ + success: true, + output: { + workflowId: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 4, + warnings: [], + }, + }) + }) + + it('returns 423 when the workflow is locked', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) + + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(423) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('rejects a request without a workflowId', async () => { + const response = await deployPost(makePost('deploy', { workspaceId: 'ws-1' })) + + expect(response.status).toBe(400) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow belongs to a different workspace', async () => { + const response = await deployPost( + makePost('deploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-other' }) + ) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body.error).toBe('Workflow not found in this workspace') + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/tools/deployments/undeploy', () => { + beforeEach(() => { + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + }) + + it('returns 400 when the workflow is not deployed', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + ...authorized(), + workflow: { ...WORKFLOW_RECORD, isDeployed: false }, + }) + + const response = await undeployPost( + makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(400) + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) + + it('undeploys a deployed workflow', async () => { + const response = await undeployPost( + makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(200) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.output).toEqual({ + workflowId: WORKFLOW_ID, + isDeployed: false, + deployedAt: null, + warnings: [], + }) + }) +}) + +describe('POST /api/tools/deployments/promote', () => { + beforeEach(() => { + mockPerformActivateVersion.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + }) + }) + + it('promotes the given version to live', async () => { + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 3 }) + ) + + expect(response.status).toBe(200) + expect(mockPerformActivateVersion).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, version: 3, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.output).toEqual({ + workflowId: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 3, + warnings: [], + }) + }) + + it('rejects a missing version', async () => { + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' }) + ) + + expect(response.status).toBe(400) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('maps a missing target version to 404', async () => { + mockPerformActivateVersion.mockResolvedValue({ + success: false, + error: 'Deployment version not found', + errorCode: 'not_found', + }) + + const response = await promotePost( + makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 99 }) + ) + + expect(response.status).toBe(404) + }) +}) + +describe('GET /api/tools/deployments/versions', () => { + it('lists deployment versions with read permission', async () => { + const versions = [ + { + id: 'v-2', + version: 2, + name: null, + description: null, + isActive: true, + createdAt: '2026-06-12T00:00:00.000Z', + createdBy: 'user-1', + deployedByName: 'Waleed', + }, + ] + mockListWorkflowVersions.mockResolvedValue({ versions }) + + const response = await listVersionsGet( + makeGet('versions', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1`) + ) + + expect(response.status).toBe(200) + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'read', + }) + + const body = await response.json() + expect(body.output).toEqual({ workflowId: WORKFLOW_ID, versions }) + }) +}) + +describe('GET /api/tools/deployments/version', () => { + it('returns version metadata and the deployed state', async () => { + mockGetWorkflowDeploymentVersion.mockResolvedValue({ + id: 'v-3', + version: 3, + name: 'Release 3', + description: null, + isActive: false, + createdAt: '2026-06-12T00:00:00.000Z', + state: { blocks: {}, edges: [] }, + }) + + const response = await getVersionGet( + makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=3`) + ) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.output).toEqual({ + workflowId: WORKFLOW_ID, + version: 3, + name: 'Release 3', + description: null, + isActive: false, + createdAt: '2026-06-12T00:00:00.000Z', + deployedState: { blocks: {}, edges: [] }, + }) + }) + + it('returns 404 when the version does not exist', async () => { + mockGetWorkflowDeploymentVersion.mockResolvedValue(null) + + const response = await getVersionGet( + makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=9`) + ) + + expect(response.status).toBe(404) + }) +}) diff --git a/apps/sim/app/api/tools/deployments/undeploy/route.ts b/apps/sim/app/api/tools/deployments/undeploy/route.ts new file mode 100644 index 00000000000..631c02a50d7 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/undeploy/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsUndeployContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performFullUndeploy } from '@/lib/workflows/orchestration' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsUndeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsUndeployContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId } = parsed.data.body + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'admin') + if (!access.ok) return access.response + + if (!access.workflow.isDeployed) { + return deploymentToolError('Workflow is not deployed', 400) + } + + await assertWorkflowMutable(workflowId) + + logger.info(`[${requestId}] Undeploying workflow ${workflowId} via deployments tool`, { + userId: auth.userId, + }) + + const result = await performFullUndeploy({ + workflowId, + userId: auth.userId, + requestId, + }) + + if (!result.success) { + return deploymentToolError(result.error || 'Failed to undeploy workflow', 500) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return deploymentToolError(error.message, error.status) + } + logger.error(`[${requestId}] Deployment tool undeploy error`, { error }) + return deploymentToolError('Failed to undeploy workflow', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/utils.ts b/apps/sim/app/api/tools/deployments/utils.ts new file mode 100644 index 00000000000..b82b4fcdf88 --- /dev/null +++ b/apps/sim/app/api/tools/deployments/utils.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { + authorizeWorkflowByWorkspacePermission, + type WorkflowWorkspaceAuthorizationResult, +} from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { enforceUserRateLimit } from '@/lib/core/rate-limiter' + +const logger = createLogger('DeploymentToolsAPI') + +export type AuthorizedDeploymentWorkflow = NonNullable< + WorkflowWorkspaceAuthorizationResult['workflow'] +> + +/** Standard error body for deployment tool routes, matching the generic tool response shape. */ +export function deploymentToolError(error: string, status: number): NextResponse { + return NextResponse.json({ success: false, error }, { status }) +} + +/** + * Authenticates a deployment tool request via session or internal token (API + * keys are rejected) and applies per-user rate limiting. Runs before request + * parsing, so it must not read the body. + */ +export async function authenticateDeploymentToolRequest( + request: NextRequest, + requestId: string +): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized deployment tool request`, { error: auth.error }) + return { + ok: false, + response: deploymentToolError(auth.error || 'Authentication required', 401), + } + } + + const rateLimited = await enforceUserRateLimit('deployment-tools', auth.userId) + if (rateLimited) return { ok: false, response: rateLimited } + + return { ok: true, userId: auth.userId } +} + +/** + * Verifies the user holds the required workspace permission on the target + * workflow and that the workflow belongs to the calling workspace. Deployment + * mutations require `admin`, reads require `read`, matching the UI deploy + * routes. The workspace binding keeps workflow-driven executions (schedules, + * webhooks) from reaching into other workspaces the actor administers. + */ +export async function authorizeDeploymentWorkflow( + userId: string, + workflowId: string, + workspaceId: string, + action: 'read' | 'admin' +): Promise< + { ok: true; workflow: AuthorizedDeploymentWorkflow } | { ok: false; response: NextResponse } +> { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action, + }) + + if (!authorization.allowed || !authorization.workflow) { + return { + ok: false, + response: deploymentToolError(authorization.message || 'Access denied', authorization.status), + } + } + + if (authorization.workflow.workspaceId !== workspaceId) { + return { + ok: false, + response: deploymentToolError('Workflow not found in this workspace', 404), + } + } + + return { ok: true, workflow: authorization.workflow } +} diff --git a/apps/sim/app/api/tools/deployments/version/route.ts b/apps/sim/app/api/tools/deployments/version/route.ts new file mode 100644 index 00000000000..86e56cbaabc --- /dev/null +++ b/apps/sim/app/api/tools/deployments/version/route.ts @@ -0,0 +1,63 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsGetVersionContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsGetVersionAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsGetVersionContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId, version } = parsed.data.query + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') + if (!access.ok) return access.response + + const row = await getWorkflowDeploymentVersion(workflowId, version) + if (!row) { + return deploymentToolError('Deployment version not found', 404) + } + + return NextResponse.json({ + success: true, + output: { + workflowId, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt, + deployedState: row.state, + }, + }) + } catch (error: unknown) { + logger.error(`[${requestId}] Deployment tool get version error`, { error }) + return deploymentToolError('Failed to get deployment version', 500) + } +}) diff --git a/apps/sim/app/api/tools/deployments/versions/route.ts b/apps/sim/app/api/tools/deployments/versions/route.ts new file mode 100644 index 00000000000..42abd9eb9ea --- /dev/null +++ b/apps/sim/app/api/tools/deployments/versions/route.ts @@ -0,0 +1,52 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { deploymentsListVersionsContract } from '@/lib/api/contracts/tools/deployments' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' +import { + authenticateDeploymentToolRequest, + authorizeDeploymentWorkflow, + deploymentToolError, +} from '@/app/api/tools/deployments/utils' + +const logger = createLogger('DeploymentsListVersionsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const auth = await authenticateDeploymentToolRequest(request, requestId) + if (!auth.ok) return auth.response + + const parsed = await parseRequest( + deploymentsListVersionsContract, + request, + {}, + { + validationErrorResponse: (error) => + deploymentToolError(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { workflowId, workspaceId } = parsed.data.query + + const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read') + if (!access.ok) return access.response + + const { versions } = await listWorkflowVersions(workflowId) + + return NextResponse.json({ + success: true, + output: { workflowId, versions }, + }) + } catch (error: unknown) { + logger.error(`[${requestId}] Deployment tool list versions error`, { error }) + return deploymentToolError('Failed to list deployment versions', 500) + } +}) diff --git a/apps/sim/app/api/tools/vanta/download/route.ts b/apps/sim/app/api/tools/vanta/download/route.ts new file mode 100644 index 00000000000..48a23ac94ae --- /dev/null +++ b/apps/sim/app/api/tools/vanta/download/route.ts @@ -0,0 +1,160 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { vantaDownloadContract } from '@/lib/api/contracts/tools/vanta' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + buildVantaUrl, + extractVantaError, + fetchVantaWithAuth, + getVantaBaseUrl, + VANTA_READ_SCOPE, +} from '@/tools/vanta/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('VantaDownloadAPI') + +const MAX_DOWNLOAD_SIZE_BYTES = 100 * 1024 * 1024 + +function downloadSizeError(bytes: number): NextResponse { + const sizeMB = (bytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { success: false, error: `File size (${sizeMB}MB) exceeds download limit of 100MB` }, + { status: 400 } + ) +} + +/** + * Reads a response body incrementally, aborting as soon as the accumulated + * size exceeds the limit so oversized files are never fully buffered. + * Returns null when the limit is exceeded. + */ +async function readBodyWithLimit(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader() + if (!reader) { + const buffer = Buffer.from(await response.arrayBuffer()) + return buffer.length > maxBytes ? null : buffer + } + + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel() + return null + } + chunks.push(value) + } + return Buffer.concat(chunks) +} + +/** + * Extracts the filename from a Content-Disposition header, if present. + */ +function getFileNameFromContentDisposition(header: string | null): string | null { + if (!header) return null + const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match) { + try { + return decodeURIComponent(utf8Match[1]) + } catch { + return null + } + } + const plainMatch = header.match(/filename="?([^";]+)"?/i) + return plainMatch ? plainMatch[1] : null +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Vanta download attempt`, { + error: authResult.error || 'Unauthorized', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(vantaDownloadContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const mediaUrl = buildVantaUrl( + getVantaBaseUrl(params.region), + `/documents/${encodeURIComponent(params.documentId)}/uploads/${encodeURIComponent(params.uploadedFileId)}/media` + ) + + logger.info(`[${requestId}] Downloading Vanta document file`, { + documentId: params.documentId, + uploadedFileId: params.uploadedFileId, + }) + + const response = await fetchVantaWithAuth( + { + clientId: params.clientId, + clientSecret: params.clientSecret, + region: params.region, + scope: VANTA_READ_SCOPE, + }, + (accessToken) => + fetch(mediaUrl, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }) + ) + + if (!response.ok) { + const errorData: unknown = await response.json().catch(() => null) + const message = extractVantaError(errorData, 'Failed to download Vanta document file') + logger.error(`[${requestId}] Vanta download failed`, { status: response.status, message }) + return NextResponse.json({ success: false, error: message }, { status: response.status }) + } + + const contentLength = Number(response.headers.get('content-length')) + if (Number.isFinite(contentLength) && contentLength > MAX_DOWNLOAD_SIZE_BYTES) { + return downloadSizeError(contentLength) + } + + const buffer = await readBodyWithLimit(response, MAX_DOWNLOAD_SIZE_BYTES) + if (buffer === null) { + return NextResponse.json( + { success: false, error: 'File exceeds download limit of 100MB' }, + { status: 400 } + ) + } + + const mimeType = response.headers.get('content-type') || 'application/octet-stream' + const name = + getFileNameFromContentDisposition(response.headers.get('content-disposition')) || + `vanta-document-file-${params.uploadedFileId}` + + logger.info(`[${requestId}] Vanta download successful`, { name, size: buffer.length }) + + return NextResponse.json({ + success: true, + output: { + file: { name, mimeType, data: buffer.toString('base64'), size: buffer.length }, + name, + mimeType, + size: buffer.length, + }, + }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Vanta download failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/vanta/query/route.ts b/apps/sim/app/api/tools/vanta/query/route.ts new file mode 100644 index 00000000000..102c20af00f --- /dev/null +++ b/apps/sim/app/api/tools/vanta/query/route.ts @@ -0,0 +1,423 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import type { VantaQueryBody } from '@/lib/api/contracts/tools/vanta' +import { vantaQueryContract } from '@/lib/api/contracts/tools/vanta' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + asVantaRecord, + buildVantaUrl, + extractVantaError, + fetchVantaWithAuth, + getVantaBaseUrl, + getVantaListResults, + normalizeVantaControl, + normalizeVantaControlDetail, + normalizeVantaDocument, + normalizeVantaDocumentDetail, + normalizeVantaFramework, + normalizeVantaFrameworkDetail, + normalizeVantaMonitoredComputer, + normalizeVantaPerson, + normalizeVantaPolicy, + normalizeVantaRiskScenario, + normalizeVantaTest, + normalizeVantaTestEntity, + normalizeVantaUploadedFile, + normalizeVantaVendor, + normalizeVantaVulnerability, + normalizeVantaVulnerabilityRemediation, + normalizeVantaVulnerableAsset, + splitVantaCommaList, + VANTA_READ_SCOPE, + VANTA_WRITE_SCOPE, +} from '@/tools/vanta/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('VantaQueryAPI') + +interface VantaApiRequest { + method: 'GET' | 'POST' + url: string +} + +/** + * Maps a validated query operation to the Vanta API request it performs. + */ +function buildVantaApiRequest(baseUrl: string, params: VantaQueryBody): VantaApiRequest { + const id = encodeURIComponent + + switch (params.operation) { + case 'vanta_list_frameworks': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/frameworks', { + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_framework': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/frameworks/${id(params.frameworkId)}`) } + case 'vanta_list_framework_controls': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/frameworks/${id(params.frameworkId)}/controls`, { + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_controls': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/controls', { + frameworkMatchesAny: splitVantaCommaList(params.frameworkMatchesAny), + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_control': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/controls/${id(params.controlId)}`) } + case 'vanta_list_control_tests': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/controls/${id(params.controlId)}/tests`, { + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_control_documents': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/controls/${id(params.controlId)}/documents`, { + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_tests': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/tests', { + statusFilter: params.statusFilter, + frameworkFilter: params.frameworkFilter, + integrationFilter: params.integrationFilter, + controlFilter: params.controlFilter, + ownerFilter: params.ownerFilter, + categoryFilter: params.categoryFilter, + isInRollout: params.isInRollout, + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_test': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/tests/${id(params.testId)}`) } + case 'vanta_list_test_entities': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/tests/${id(params.testId)}/entities`, { + entityStatus: params.entityStatus, + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_documents': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/documents', { + frameworkMatchesAny: splitVantaCommaList(params.frameworkMatchesAny), + statusMatchesAny: splitVantaCommaList(params.statusMatchesAny), + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_document': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/documents/${id(params.documentId)}`) } + case 'vanta_list_document_uploads': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/documents/${id(params.documentId)}/uploads`, { + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_submit_document': + return { + method: 'POST', + url: buildVantaUrl(baseUrl, `/documents/${id(params.documentId)}/submit`), + } + case 'vanta_list_people': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/people', { + emailAndNameFilter: params.emailAndNameFilter, + employmentStatus: params.employmentStatus, + groupIdsMatchesAny: splitVantaCommaList(params.groupIdsMatchesAny), + tasksSummaryStatusMatchesAny: splitVantaCommaList(params.tasksSummaryStatusMatchesAny), + taskTypeMatchesAny: splitVantaCommaList(params.taskTypeMatchesAny), + taskStatusMatchesAny: splitVantaCommaList(params.taskStatusMatchesAny), + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_person': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/people/${id(params.personId)}`) } + case 'vanta_list_policies': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/policies', { + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_policy': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/policies/${id(params.policyId)}`) } + case 'vanta_list_vendors': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/vendors', { + name: params.name, + statusMatchesAny: splitVantaCommaList(params.statusMatchesAny), + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_vendor': + return { method: 'GET', url: buildVantaUrl(baseUrl, `/vendors/${id(params.vendorId)}`) } + case 'vanta_list_monitored_computers': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/monitored-computers', { + complianceStatusFilterMatchesAny: splitVantaCommaList( + params.complianceStatusFilterMatchesAny + ), + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_vulnerabilities': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/vulnerabilities', { + q: params.q, + severity: params.severity, + isFixAvailable: params.isFixAvailable, + isDeactivated: params.isDeactivated, + includeVulnerabilitiesWithoutSlas: params.includeVulnerabilitiesWithoutSlas, + packageIdentifier: params.packageIdentifier, + externalVulnerabilityId: params.externalVulnerabilityId, + integrationId: params.integrationId, + vulnerableAssetId: params.vulnerableAssetId, + slaDeadlineAfterDate: params.slaDeadlineAfterDate, + slaDeadlineBeforeDate: params.slaDeadlineBeforeDate, + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_vulnerability_remediations': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/vulnerability-remediations', { + integrationId: params.integrationId, + severity: params.severity, + isRemediatedOnTime: params.isRemediatedOnTime, + remediatedAfterDate: params.remediatedAfterDate, + remediatedBeforeDate: params.remediatedBeforeDate, + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_list_vulnerable_assets': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/vulnerable-assets', { + q: params.q, + integrationId: params.integrationId, + assetType: params.assetType, + assetExternalAccountId: params.assetExternalAccountId, + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_vulnerable_asset': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/vulnerable-assets/${id(params.vulnerableAssetId)}`), + } + case 'vanta_list_risk_scenarios': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, '/risk-scenarios', { + searchString: params.searchString, + includeIgnored: params.includeIgnored, + type: params.type, + ownerMatchesAny: splitVantaCommaList(params.ownerMatchesAny), + categoryMatchesAny: splitVantaCommaList(params.categoryMatchesAny), + ciaCategoryMatchesAny: splitVantaCommaList(params.ciaCategoryMatchesAny), + treatmentTypeMatchesAny: splitVantaCommaList(params.treatmentTypeMatchesAny), + inherentScoreGroupMatchesAny: splitVantaCommaList(params.inherentScoreGroupMatchesAny), + residualScoreGroupMatchesAny: splitVantaCommaList(params.residualScoreGroupMatchesAny), + reviewStatusMatchesAny: splitVantaCommaList(params.reviewStatusMatchesAny), + orderBy: params.orderBy, + pageSize: params.pageSize, + pageCursor: params.pageCursor, + }), + } + case 'vanta_get_risk_scenario': + return { + method: 'GET', + url: buildVantaUrl(baseUrl, `/risk-scenarios/${id(params.riskScenarioId)}`), + } + } +} + +/** + * Normalizes a successful Vanta API response body into the operation's + * documented output shape. + */ +function buildVantaOutput(params: VantaQueryBody, data: unknown): Record { + switch (params.operation) { + case 'vanta_list_frameworks': { + const { data: items, pageInfo } = getVantaListResults(data) + return { frameworks: items.map(normalizeVantaFramework), pageInfo } + } + case 'vanta_get_framework': + return { framework: normalizeVantaFrameworkDetail(asVantaRecord(data)) } + case 'vanta_list_framework_controls': + case 'vanta_list_controls': { + const { data: items, pageInfo } = getVantaListResults(data) + return { controls: items.map(normalizeVantaControl), pageInfo } + } + case 'vanta_get_control': + return { control: normalizeVantaControlDetail(asVantaRecord(data)) } + case 'vanta_list_control_tests': + case 'vanta_list_tests': { + const { data: items, pageInfo } = getVantaListResults(data) + return { tests: items.map(normalizeVantaTest), pageInfo } + } + case 'vanta_get_test': + return { test: normalizeVantaTest(asVantaRecord(data)) } + case 'vanta_list_test_entities': { + const { data: items, pageInfo } = getVantaListResults(data) + return { entities: items.map(normalizeVantaTestEntity), pageInfo } + } + case 'vanta_list_control_documents': + case 'vanta_list_documents': { + const { data: items, pageInfo } = getVantaListResults(data) + return { documents: items.map(normalizeVantaDocument), pageInfo } + } + case 'vanta_get_document': + return { document: normalizeVantaDocumentDetail(asVantaRecord(data)) } + case 'vanta_list_document_uploads': { + const { data: items, pageInfo } = getVantaListResults(data) + return { uploads: items.map(normalizeVantaUploadedFile), pageInfo } + } + case 'vanta_submit_document': + return { documentId: params.documentId, submitted: true } + case 'vanta_list_people': { + const { data: items, pageInfo } = getVantaListResults(data) + return { people: items.map(normalizeVantaPerson), pageInfo } + } + case 'vanta_get_person': + return { person: normalizeVantaPerson(asVantaRecord(data)) } + case 'vanta_list_policies': { + const { data: items, pageInfo } = getVantaListResults(data) + return { policies: items.map(normalizeVantaPolicy), pageInfo } + } + case 'vanta_get_policy': + return { policy: normalizeVantaPolicy(asVantaRecord(data)) } + case 'vanta_list_vendors': { + const { data: items, pageInfo } = getVantaListResults(data) + return { vendors: items.map(normalizeVantaVendor), pageInfo } + } + case 'vanta_get_vendor': + return { vendor: normalizeVantaVendor(asVantaRecord(data)) } + case 'vanta_list_monitored_computers': { + const { data: items, pageInfo } = getVantaListResults(data) + return { computers: items.map(normalizeVantaMonitoredComputer), pageInfo } + } + case 'vanta_list_vulnerabilities': { + const { data: items, pageInfo } = getVantaListResults(data) + return { vulnerabilities: items.map(normalizeVantaVulnerability), pageInfo } + } + case 'vanta_list_vulnerability_remediations': { + const { data: items, pageInfo } = getVantaListResults(data) + return { remediations: items.map(normalizeVantaVulnerabilityRemediation), pageInfo } + } + case 'vanta_list_vulnerable_assets': { + const { data: items, pageInfo } = getVantaListResults(data) + return { assets: items.map(normalizeVantaVulnerableAsset), pageInfo } + } + case 'vanta_get_vulnerable_asset': + return { asset: normalizeVantaVulnerableAsset(asVantaRecord(data)) } + case 'vanta_list_risk_scenarios': { + const { data: items, pageInfo } = getVantaListResults(data) + return { riskScenarios: items.map(normalizeVantaRiskScenario), pageInfo } + } + case 'vanta_get_risk_scenario': + return { riskScenario: normalizeVantaRiskScenario(asVantaRecord(data)) } + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Vanta query attempt`, { + error: authResult.error || 'Unauthorized', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(vantaQueryContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const baseUrl = getVantaBaseUrl(params.region) + const scope = + params.operation === 'vanta_submit_document' ? VANTA_WRITE_SCOPE : VANTA_READ_SCOPE + + logger.info(`[${requestId}] Vanta query request`, { operation: params.operation }) + + const apiRequest = buildVantaApiRequest(baseUrl, params) + const response = await fetchVantaWithAuth( + { + clientId: params.clientId, + clientSecret: params.clientSecret, + region: params.region, + scope, + }, + (accessToken) => + fetch(apiRequest.url, { + method: apiRequest.method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + ) + + if (!response.ok) { + const errorData: unknown = await response.json().catch(() => null) + return NextResponse.json( + { success: false, error: extractVantaError(errorData, 'Vanta request failed') }, + { status: response.status } + ) + } + + const data: unknown = response.status === 204 ? null : await response.json().catch(() => null) + return NextResponse.json({ success: true, output: buildVantaOutput(params, data) }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Vanta query failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/vanta/upload/route.ts b/apps/sim/app/api/tools/vanta/upload/route.ts new file mode 100644 index 00000000000..a76cc848c01 --- /dev/null +++ b/apps/sim/app/api/tools/vanta/upload/route.ts @@ -0,0 +1,145 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { vantaUploadContract } from '@/lib/api/contracts/tools/vanta' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + asVantaRecord, + buildVantaUrl, + extractVantaError, + fetchVantaWithAuth, + getVantaBaseUrl, + normalizeVantaUploadedFile, + VANTA_DOCUMENT_UPLOAD_SCOPE, +} from '@/tools/vanta/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('VantaUploadAPI') + +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024 + +function uploadSizeError(bytes: number): NextResponse { + const sizeMB = (bytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { success: false, error: `File size (${sizeMB}MB) exceeds upload limit of 100MB` }, + { status: 400 } + ) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Vanta upload attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(vantaUploadContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + let fileBuffer: Buffer + let fileName: string + let mimeType: string + + if (params.file) { + const userFiles = processFilesToUserFiles([params.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + + const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + + if (userFile.size > MAX_UPLOAD_SIZE_BYTES) { + return uploadSizeError(userFile.size) + } + + fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) + fileName = params.fileName || userFile.name + mimeType = userFile.type || params.mimeType || 'application/octet-stream' + } else if (params.fileContent) { + fileBuffer = Buffer.from(params.fileContent, 'base64') + fileName = params.fileName || 'file' + mimeType = params.mimeType || 'application/octet-stream' + } else { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + if (fileBuffer.length > MAX_UPLOAD_SIZE_BYTES) { + return uploadSizeError(fileBuffer.length) + } + + logger.info(`[${requestId}] Uploading file to Vanta document`, { + documentId: params.documentId, + fileName, + size: fileBuffer.length, + }) + + const uploadUrl = buildVantaUrl( + getVantaBaseUrl(params.region), + `/documents/${encodeURIComponent(params.documentId)}/uploads` + ) + const response = await fetchVantaWithAuth( + { + clientId: params.clientId, + clientSecret: params.clientSecret, + region: params.region, + scope: VANTA_DOCUMENT_UPLOAD_SCOPE, + }, + (accessToken) => { + const formData = new FormData() + formData.append( + 'file', + new Blob([new Uint8Array(fileBuffer)], { type: mimeType }), + fileName + ) + if (params.description) { + formData.append('description', params.description) + } + if (params.effectiveAtDate) { + formData.append('effectiveAtDate', params.effectiveAtDate) + } + return fetch(uploadUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body: formData, + cache: 'no-store', + }) + } + ) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + const message = extractVantaError(data, 'Failed to upload file to Vanta document') + logger.error(`[${requestId}] Vanta upload failed`, { status: response.status, message }) + return NextResponse.json({ success: false, error: message }, { status: response.status }) + } + + logger.info(`[${requestId}] Vanta upload successful`, { documentId: params.documentId }) + + return NextResponse.json({ + success: true, + output: { upload: normalizeVantaUploadedFile(asVantaRecord(data)) }, + }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Vanta upload failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts index fb5789356dd..6744ab589b4 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -41,7 +41,7 @@ export const GET = withRouteHandler( isActive: v.isActive, createdAt: v.createdAt.toISOString(), createdBy: v.createdBy, - deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null), + deployedByName: v.deployedByName, })) logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 6472cbb1f76..94cacfd27f8 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -16,6 +16,8 @@ export type V1Endpoint = | 'logs-detail' | 'workflows' | 'workflow-detail' + | 'workflow-deploy' + | 'workflow-rollback' | 'audit-logs' | 'tables' | 'table-detail' @@ -190,6 +192,9 @@ export async function checkWorkspaceScope( return null } +/** Orders workspace permission levels for at-least comparisons. */ +const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const + /** * Validates workspace-scoped API key bounds and the user's workspace permission. * Returns null on success, NextResponse on failure. @@ -198,16 +203,13 @@ export async function validateWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, - level: 'read' | 'write' = 'read' + level: keyof typeof PERMISSION_RANK = 'read' ): Promise { const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - if (level === 'write' && permission === 'read') { + if (permission === null || PERMISSION_RANK[permission] < PERMISSION_RANK[level]) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } return null diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index ecceb41b1e2..bce536fc9fb 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { type V1BatchInsertTableRowsBody, @@ -34,7 +33,7 @@ import { } from '@/lib/table' import { queryRows } from '@/lib/table/service' import { TableQueryValidationError } from '@/lib/table/sql' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -104,18 +103,8 @@ async function handleBatchInsert( }, }) } catch (error) { - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') || - errorMessage.match(/^Row \d+:/) - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch inserting rows:`, error) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) @@ -287,17 +276,8 @@ export const POST = withRouteHandler( const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error inserting row:`, error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) @@ -381,18 +361,8 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating rows by filter:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) @@ -478,11 +448,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error deleting rows:`, error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts new file mode 100644 index 00000000000..3dead585727 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,270 @@ +/** + * @vitest-environment node + * + * Tests for POST/DELETE /api/v1/workflows/[id]/deploy — verifies auth, + * workspace admin permission enforcement, optional body handling, and the + * mapping of orchestration results to v1 API responses. + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { WorkflowLockedError } from '@sim/workflow-authz' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockValidateWorkspaceAccess, + mockPerformFullDeploy, + mockPerformFullUndeploy, + mockCaptureServerEvent, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), + mockPerformFullDeploy: vi.fn(), + mockPerformFullUndeploy: vi.fn(), + mockCaptureServerEvent: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + createRateLimitResponse: vi.fn(() => + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ), + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performFullDeploy: mockPerformFullDeploy, + performFullUndeploy: mockPerformFullUndeploy, +})) + +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn().mockResolvedValue({}), + createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })), +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +import { DELETE, POST } from '@/app/api/v1/workflows/[id]/deploy/route' + +const WORKFLOW_ID = 'wf-1' +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + workspaceId: 'ws-1', + isDeployed: true, +} + +function makeContext(id = WORKFLOW_ID) { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(method: string, body?: unknown) { + return createMockRequest( + method, + body, + {}, + `http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy` + ) +} + +describe('POST /api/v1/workflows/[id]/deploy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformFullDeploy.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + version: 4, + warnings: undefined, + }) + }) + + it('rejects unauthenticated requests', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' }) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(401) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(404) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('masks missing admin permission as 404', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(404) + expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ allowed: true }), + 'user-1', + 'ws-1', + 'admin' + ) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('rejects a malformed JSON body', async () => { + const request = new NextRequest( + new URL(`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy`), + { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: '{"name": "Release 4"', + } + ) + + const response = await POST(request, makeContext()) + + expect(response.status).toBe(400) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('rejects invalid version metadata', async () => { + const response = await POST(makeRequest('POST', { name: '' }), makeContext()) + + expect(response.status).toBe(400) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) + + it('deploys without a request body', async () => { + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + versionName: undefined, + versionDescription: undefined, + }) + ) + + const body = await response.json() + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 4, + warnings: [], + }) + }) + + it('passes version metadata through to the deploy orchestration', async () => { + const response = await POST( + makeRequest('POST', { name: 'Release 4', description: 'Fixes the agent prompt' }), + makeContext() + ) + + expect(response.status).toBe(200) + expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect.objectContaining({ + versionName: 'Release 4', + versionDescription: 'Fixes the agent prompt', + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'workflow_deployed', + expect.objectContaining({ workflow_id: WORKFLOW_ID }), + expect.anything() + ) + }) + + it('maps validation failures from the orchestration to 400', async () => { + mockPerformFullDeploy.mockResolvedValue({ + success: false, + error: 'Invalid schedule configuration', + errorCode: 'validation', + }) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid schedule configuration') + }) + + it('returns 423 when the workflow is locked', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) + + const response = await POST(makeRequest('POST'), makeContext()) + + expect(response.status).toBe(423) + expect(mockPerformFullDeploy).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v1/workflows/[id]/deploy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformFullUndeploy.mockResolvedValue({ success: true }) + }) + + it('returns 400 when the workflow is not deployed', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({ + ...WORKFLOW_RECORD, + isDeployed: false, + }) + + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Workflow is not deployed') + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) + + it('undeploys a deployed workflow', async () => { + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformFullUndeploy).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: false, + deployedAt: null, + warnings: [], + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'workflow_undeployed', + expect.objectContaining({ workflow_id: WORKFLOW_ID }), + expect.anything() + ) + }) + + it('masks missing admin permission as 404', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await DELETE(makeRequest('DELETE'), makeContext()) + + expect(response.status).toBe(404) + expect(mockPerformFullUndeploy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..34982534db9 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -0,0 +1,184 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { + v1DeployWorkflowBodySchema, + v1DeployWorkflowContract, + v1UndeployWorkflowContract, +} from '@/lib/api/contracts/v1/workflows' +import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' + +const logger = createLogger('V1WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1DeployWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) return rawBody.response + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) { + return validationErrorResponse(body.error) + } + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return target.response + const { workflow, workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v1 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflow.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + return NextResponse.json( + { error: result.error || 'Failed to deploy workflow' }, + { status: statusForOrchestrationError(result.errorCode) } + ) + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse( + { + data: { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow deploy error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1UndeployWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return target.response + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v1 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return NextResponse.json( + { error: result.error || 'Failed to undeploy workflow' }, + { status: 500 } + ) + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse( + { + data: { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow undeploy error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts new file mode 100644 index 00000000000..c1f085faf02 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + * + * Tests for POST /api/v1/workflows/[id]/rollback — verifies target version + * resolution (previous version by default, explicit version when provided) + * and the mapping of activation results to v1 API responses. + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { WorkflowLockedError } from '@sim/workflow-authz' +import { NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockValidateWorkspaceAccess, + mockPerformActivateVersion, + mockFindPreviousDeploymentVersion, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockValidateWorkspaceAccess: vi.fn(), + mockPerformActivateVersion: vi.fn(), + mockFindPreviousDeploymentVersion: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + findPreviousDeploymentVersion: mockFindPreviousDeploymentVersion, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + createRateLimitResponse: vi.fn(() => + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ), + validateWorkspaceAccess: mockValidateWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performActivateVersion: mockPerformActivateVersion, +})) + +vi.mock('@/app/api/v1/logs/meta', () => ({ + getUserLimits: vi.fn().mockResolvedValue({}), + createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })), +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), +})) + +import { POST } from '@/app/api/v1/workflows/[id]/rollback/route' + +const WORKFLOW_ID = 'wf-1' +const WORKFLOW_RECORD = { + id: WORKFLOW_ID, + name: 'My Workflow', + workspaceId: 'ws-1', + isDeployed: true, +} + +function makeContext(id = WORKFLOW_ID) { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(body?: unknown) { + return createMockRequest( + 'POST', + body, + {}, + `http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/rollback` + ) +} + +describe('POST /api/v1/workflows/[id]/rollback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' }) + mockValidateWorkspaceAccess.mockResolvedValue(null) + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformActivateVersion.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-06-12T00:00:00Z'), + }) + }) + + it('rejects unauthenticated requests', async () => { + mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(401) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 423 when the workflow is locked', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError()) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(423) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('rolls back to the previous version when no version is given', async () => { + mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: true, version: 4 }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformActivateVersion).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, version: 4, userId: 'user-1' }) + ) + + const body = await response.json() + expect(body.data).toEqual({ + id: WORKFLOW_ID, + isDeployed: true, + deployedAt: '2026-06-12T00:00:00.000Z', + version: 4, + warnings: [], + }) + }) + + it('returns 400 when the workflow is not deployed, even with an explicit version', async () => { + workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({ + ...WORKFLOW_RECORD, + isDeployed: false, + }) + + const response = await POST(makeRequest({ version: 2 }), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Workflow is not deployed') + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('rolls back to an explicit version when provided', async () => { + const response = await POST(makeRequest({ version: 2 }), makeContext()) + + expect(response.status).toBe(200) + expect(mockPerformActivateVersion).toHaveBeenCalledWith(expect.objectContaining({ version: 2 })) + expect(mockFindPreviousDeploymentVersion).not.toHaveBeenCalled() + }) + + it('rejects a non-integer version', async () => { + const response = await POST(makeRequest({ version: 1.5 }), makeContext()) + + expect(response.status).toBe(400) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when there is no active deployment to roll back from', async () => { + mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: false, reason: 'no_active_version' }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Workflow has no active deployment to roll back from') + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when there is no previous version to roll back to', async () => { + mockFindPreviousDeploymentVersion.mockResolvedValue({ + ok: false, + reason: 'no_previous_version', + }) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('No previous deployment version to roll back to') + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) + + it('maps a missing target version to 404', async () => { + mockPerformActivateVersion.mockResolvedValue({ + success: false, + error: 'Deployment version not found', + errorCode: 'not_found', + }) + + const response = await POST(makeRequest({ version: 99 }), makeContext()) + + expect(response.status).toBe(404) + }) + + it('masks missing admin permission as 404', async () => { + mockValidateWorkspaceAccess.mockResolvedValue( + NextResponse.json({ error: 'Access denied' }, { status: 403 }) + ) + + const response = await POST(makeRequest(), makeContext()) + + expect(response.status).toBe(404) + expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ allowed: true }), + 'user-1', + 'ws-1', + 'admin' + ) + expect(mockPerformActivateVersion).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..c35933d2773 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -0,0 +1,128 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { type NextRequest, NextResponse } from 'next/server' +import { + v1RollbackWorkflowBodySchema, + v1RollbackWorkflowContract, +} from '@/lib/api/contracts/v1/workflows' +import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' + +const logger = createLogger('V1WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const parsed = await parseRequest(v1RollbackWorkflowContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) return rawBody.response + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) { + return validationErrorResponse(body.error) + } + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return target.response + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 }) + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return NextResponse.json({ error: message }, { status: 400 }) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v1 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + workflow: workflow as Record, + requestId, + request, + }) + + if (!result.success) { + return NextResponse.json( + { error: result.error || 'Failed to roll back workflow' }, + { status: statusForOrchestrationError(result.errorCode) } + ) + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + const limits = await getUserLimits(userId) + const apiResponse = createApiResponse( + { + data: { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + }, + limits, + rateLimit + ) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Workflow rollback error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts new file mode 100644 index 00000000000..92e321f1538 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -0,0 +1,39 @@ +import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/workflow-authz' +import { NextResponse } from 'next/server' +import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' + +function workflowNotFoundResponse(): NextResponse { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) +} + +/** + * Resolves the target workflow for a v1 deployment mutation: loads the active + * record and verifies the caller's admin permission on its workspace. Access + * failures are masked as 404, matching the v1 workflow read surface so + * unauthorized callers cannot probe workflow existence. + */ +export async function resolveV1DeploymentWorkflow( + rateLimit: RateLimitResult, + userId: string, + workflowId: string +): Promise< + | { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string } + | { ok: false; response: NextResponse } +> { + const workflow = await getActiveWorkflowRecord(workflowId) + if (!workflow?.workspaceId) { + return { ok: false, response: workflowNotFoundResponse() } + } + + const accessError = await validateWorkspaceAccess( + rateLimit, + userId, + workflow.workspaceId, + 'admin' + ) + if (accessError) { + return { ok: false, response: workflowNotFoundResponse() } + } + + return { ok: true, workflow, workspaceId: workflow.workspaceId } +} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 23d574efc41..0355519c16b 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, @@ -106,9 +107,10 @@ export const POST = withRouteHandler( }) if (!result.success) { - const status = - result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 - return createErrorResponse(result.error || 'Failed to deploy workflow', status) + return createErrorResponse( + result.error || 'Failed to deploy workflow', + statusForOrchestrationError(result.errorCode) + ) } logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index c4cbd1acf63..751171b0b3b 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -8,7 +8,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' +import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { + getWorkflowDeploymentVersion, + updateDeploymentVersionMetadata, +} from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -37,17 +41,7 @@ export const GET = withRouteHandler( return createErrorResponse('Invalid version', 400) } - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .limit(1) - + const row = await getWorkflowDeploymentVersion(id, versionNum) if (!row?.state) { return createErrorResponse('Deployment version not found', 404) } @@ -110,15 +104,9 @@ export const PATCH = withRouteHandler( }) if (!activateResult.success) { - const status = - activateResult.errorCode === 'not_found' - ? 404 - : activateResult.errorCode === 'validation' - ? 400 - : 500 return createErrorResponse( activateResult.error || 'Failed to activate deployment', - status + statusForOrchestrationError(activateResult.errorCode) ) } diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 905908daecb..4a1a9998ca6 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -1,11 +1,10 @@ -import { db, user, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -27,25 +26,10 @@ export const GET = withRouteHandler( return createErrorResponse(error.message, error.status) } - const rawVersions = await db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedBy: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, id)) - .orderBy(desc(workflowDeploymentVersion.version)) - - const versions = rawVersions.map((v) => ({ - ...v, - deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null), + const { versions: rows } = await listWorkflowVersions(id) + const versions = rows.map(({ deployedByName, ...version }) => ({ + ...version, + deployedBy: deployedByName, })) return createSuccessResponse({ versions }) diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index fe041cff403..26b6d8b9765 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -152,7 +152,7 @@ export default function PlaygroundPage() { const [dateRangeEnd, setDateRangeEnd] = useState('') const [tagItems, setTagItems] = useState([ { value: 'user@example.com', isValid: true }, - { value: 'invalid-email', isValid: false }, + { value: 'invalid-email', isValid: false, error: 'Invalid email format' }, ]) const toggleDarkMode = () => { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 375e1c1dada..5ca4538f5fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -128,8 +128,11 @@ export const ResourceHeader = memo(function ResourceHeader({ : -1 return ( -
    -
    +
    +
    {hasBreadcrumbs ? ( breadcrumbs.map((crumb, i) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index ce52af4fbf4..4d0d4b8583d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -16,11 +16,14 @@ const DOCX_ZOOM_MIN = 25 const DOCX_ZOOM_MAX = 400 const DOCX_ZOOM_STEP = 20 const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005 +const DOCX_RESIZE_DEBOUNCE_MS = 150 /** * Fit the rendered docx pages to the host container width using a CSS scale. * The library renders `
    ` at the document's natural page - * width (in cm), which overflows narrow panels. + * width (in cm), which overflows narrow panels. 100% zoom means fit-to-width — + * pages upscale past their natural print size in wide panels (CSS zoom of HTML + * stays crisp), matching the PDF preview's semantics. */ function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) { const wrapper = host.querySelector('.docx-wrapper') @@ -48,7 +51,7 @@ function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercen Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight) const naturalWrapperWidth = naturalPageWidth + horizontalPadding const available = viewport.clientWidth - const fitScale = Math.min(1, available / naturalWrapperWidth) + const fitScale = available / naturalWrapperWidth const scale = fitScale * (zoomPercent / 100) const scaledWrapperWidth = naturalWrapperWidth * scale @@ -95,12 +98,25 @@ export const DocxPreview = memo(function DocxPreview({ fitDocxToContainer(container, scrollContainer, zoomPercentRef.current) }, []) + /** + * Resize refits are debounced: each one re-queries the rendered pages and + * recomputes the fit scale, so per-tick refits during a panel-divider drag + * would thrash layout continuously (the initial fit is applied directly by + * the render path, not this observer). Mirrors the PDF preview's debounce. + */ useEffect(() => { const scrollContainer = scrollContainerRef.current if (!scrollContainer) return - const observer = new ResizeObserver(() => applyPostRenderStyling()) + let debounce: ReturnType | undefined + const observer = new ResizeObserver(() => { + clearTimeout(debounce) + debounce = setTimeout(() => applyPostRenderStyling(), DOCX_RESIZE_DEBOUNCE_MS) + }) observer.observe(scrollContainer) - return () => observer.disconnect() + return () => { + clearTimeout(debounce) + observer.disconnect() + } }, [applyPostRenderStyling]) const applyZoomAt = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 2989806a928..f20d1762ccf 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { Music } from 'lucide-react' import dynamic from 'next/dynamic' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -102,11 +103,11 @@ export function FileViewer({ } if (category === 'audio-previewable') { - return + return } if (category === 'video-previewable') { - return + return } if (category === 'docx-previewable') { @@ -176,12 +177,21 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) { return { fileData, isLoading, error, blobUrl, replaceBlobUrl } } -const AudioPreview = memo(function AudioPreview({ +const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const + +/** + * Shared blob-backed preview for audio and video files — the fetch, blob-URL + * lifecycle, and error/loading handling are identical; only the rendered + * player differs. + */ +const MediaPreview = memo(function MediaPreview({ file, workspaceId, + kind, }: { file: WorkspaceFileRecord workspaceId: string + kind: 'audio' | 'video' }) { const { fileData, @@ -193,55 +203,31 @@ const AudioPreview = memo(function AudioPreview({ useEffect(() => { if (!fileData) return - replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'audio/mpeg' }))) - }, [file.type, fileData, replaceBlobUrl]) + replaceBlobUrl( + URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] })) + ) + }, [file.type, fileData, kind, replaceBlobUrl]) const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null) - if (error) return + if (error) return if (isLoading && !blobUrl) { return } - return ( -
    -
    -
    🎵
    -

    {file.name}

    + if (kind === 'audio') { + return ( +
    +
    + +

    {file.name}

    +
    + {blobUrl && ( + // biome-ignore lint/a11y/useMediaCaption: audio from workspace files +
    - {blobUrl && ( - // biome-ignore lint/a11y/useMediaCaption: audio from workspace files -
    - ) -}) - -const VideoPreview = memo(function VideoPreview({ - file, - workspaceId, -}: { - file: WorkspaceFileRecord - workspaceId: string -}) { - const { - fileData, - isLoading, - error: fetchError, - blobUrl, - replaceBlobUrl, - } = useBlobUrl(workspaceId, file.id, file.key) - - useEffect(() => { - if (!fileData) return - replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'video/mp4' }))) - }, [file.type, fileData, replaceBlobUrl]) - - const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null) - if (error) return - - if (isLoading && !blobUrl) { - return + ) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index 2dea41ff560..13802b174e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -28,8 +28,8 @@ const PDF_ZOOM_MIN = 0.5 const PDF_ZOOM_MAX = 3 const PDF_ZOOM_DEFAULT = 1 const PDF_ZOOM_STEP = 1.25 -const PDF_PAGE_MAX_WIDTH = 816 const PDF_VIEWER_PADDING = 24 +const PDF_RESIZE_DEBOUNCE_MS = 150 export type PdfDocumentSource = | { kind: 'url'; url: string } @@ -66,25 +66,53 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P const [loadError, setLoadError] = useState(null) const sourceValue = source.kind === 'url' ? source.url : source.buffer + /** + * The buffer copy (`slice(0)`) is load-bearing: pdf.js transfers — and + * detaches — the ArrayBuffer it receives to its worker, so handing over the + * caller's buffer would leave it unusable on the next render or remount. + */ const file = useMemo( () => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }), [sourceValue] ) + /** + * The first non-zero measurement applies immediately so the document renders + * without delay (a hidden container reports zero width and must not consume + * the immediate slot); subsequent ones (panel-divider drags) are debounced + * because every pageWidth change makes pdf.js re-rasterise all page canvases + * — per-tick updates during a drag would re-render the whole document + * continuously. + */ useEffect(() => { const container = containerRef.current if (!container) return + let hasMeasured = false + let debounce: ReturnType | undefined const observer = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width) + const { width } = entry.contentRect + if (!hasMeasured) { + if (width <= 0) return + hasMeasured = true + setContainerWidth(width) + return + } + clearTimeout(debounce) + debounce = setTimeout(() => setContainerWidth(width), PDF_RESIZE_DEBOUNCE_MS) }) observer.observe(container) - return () => observer.disconnect() + return () => { + clearTimeout(debounce) + observer.disconnect() + } }, []) - const pageWidth = - containerWidth > 0 - ? Math.min(containerWidth - 2 * PDF_VIEWER_PADDING, PDF_PAGE_MAX_WIDTH) - : undefined + /** + * 100% zoom fits the page to the panel width (pdf.js re-renders the canvas + * at the target width, so upscaling past the page's natural print size + * stays crisp). Matches the DOCX preview's fit-to-width semantics. + */ + const pageWidth = containerWidth > 0 ? containerWidth - 2 * PDF_VIEWER_PADDING : undefined pageWidthRef.current = pageWidth const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx index b161d2f8bce..005218e7aff 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx @@ -1,5 +1,5 @@ import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react' -import { Button } from '@/components/emcn' +import { Chip } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' interface PreviewNavigationControls { @@ -31,14 +31,14 @@ export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarPr return (
    -
    +
    {navigation && }
    -
    {zoom && }
    +
    {zoom && }
    ) } @@ -54,29 +54,21 @@ function PreviewNavigationControls({ }: PreviewNavigationControls) { return ( <> - - + /> + {total > 0 ? `${current} / ${total}` : '0 / 0'} - + /> ) } @@ -92,39 +84,13 @@ function PreviewZoomControls({ return ( <> {onReset && ( - + )} - - - {label} - - + + {label} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index acc20afbc9c..3962aaf038d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -4,8 +4,7 @@ import { memo, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { WorkBook } from 'xlsx' -import { Button } from '@/components/emcn' -import { cn } from '@/lib/core/utils/cn' +import { Chip } from '@/components/emcn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { DataTable } from './data-table' import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared' @@ -115,23 +114,17 @@ export const XlsxPreview = memo(function XlsxPreview({ return (
    -
    -
    +
    +
    {sheetNames.map((name, i) => ( - + ))}
    diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index b96ef517aa5..7796b8d6ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1565,7 +1565,10 @@ export function Files() { }, [router, workspaceId]) const loadingBreadcrumbs = useMemo( - () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }], + (): BreadcrumbItem[] => [ + { label: 'Files', onClick: handleNavigateToFiles }, + { label: '…', terminal: true }, + ], [handleNavigateToFiles] ) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 2868326b928..f16dbf2b10c 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -66,8 +66,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration (c) => (c.type === 'oauth' || c.type === 'service_account') && c.providerId && - getServiceConfigByProviderId(c.providerId)?.name.toLowerCase() === - oauthService.serviceName.toLowerCase() + getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId ) }, [credentials, oauthService]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index fd783238de2..34a485c1a6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -25,6 +25,15 @@ export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID +/** Sim setup guides for each provider, docked bottom-left of each modal. */ +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function openDocs(url: string): void { + window.open(url, '_blank', 'noopener,noreferrer') +} + /** * Atlassian site domain hint — surfaced inline when the user types something * that doesn't look like `.atlassian.net`. @@ -278,6 +287,10 @@ function GoogleServiceAccountModal({ onOpenChange(false)} + secondaryAction={{ + label: 'Setup guide', + onClick: () => openDocs(GOOGLE_SERVICE_ACCOUNT_DOCS_URL), + }} primaryAction={{ label: isPending ? 'Adding...' : 'Add service account', onClick: handleSubmit, @@ -414,6 +427,10 @@ function AtlassianServiceAccountModal({ onOpenChange(false)} + secondaryAction={{ + label: 'Setup guide', + onClick: () => openDocs(ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL), + }} primaryAction={{ label: isPending ? 'Adding...' : 'Add service account', onClick: handleSubmit, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 82d9a0dfc30..4a8f70c916c 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -15,7 +15,7 @@ import { } from '@/components/emcn' import { ArrowLeft } from '@/components/emcn/icons' import { writeOAuthReturnContext } from '@/lib/credentials/client-state' -import { INTEGRATIONS } from '@/lib/integrations' +import { INTEGRATIONS, resolveOAuthServiceForIntegration } from '@/lib/integrations' import { getServiceConfigByProviderId } from '@/lib/oauth' import { AddPeopleModal, @@ -98,15 +98,20 @@ export function ConnectedCredentialDetail({ }, [credential]) /** - * Resolve the integration block type from the OAuth service name so the - * header tile can render with the same brand background used by the rows on - * the integrations list page. + * Resolve the integration block type from the credential's OAuth service so + * the header tile can render with the same brand background used by the rows + * on the integrations list page. Several integrations can share one service + * (e.g. Jira and Jira Service Management); the one named after the service + * is preferred since it is the service's canonical integration. */ const integrationBlockType = useMemo(() => { - const name = serviceConfig?.name.toLowerCase() - if (!name) return '' - const match = INTEGRATIONS.find((i) => i.name.toLowerCase() === name) - return match?.type ?? '' + if (!serviceConfig) return '' + const candidates = INTEGRATIONS.filter( + (i) => resolveOAuthServiceForIntegration(i)?.providerId === serviceConfig.providerId + ) + const serviceName = serviceConfig.name.toLowerCase() + const canonical = candidates.find((i) => i.name.toLowerCase() === serviceName) + return (canonical ?? candidates[0])?.type ?? '' }, [serviceConfig]) const handleReconnectOAuth = async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts index 4f280474ca3..92d91b2cd0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts @@ -4,5 +4,4 @@ export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal' export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal' export { getDocumentIcon } from './icons' export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu' -export { KnowledgeHeader } from './knowledge-header' export { KnowledgeListContextMenu } from './knowledge-list-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/index.ts deleted file mode 100644 index 39838545172..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { KnowledgeHeader } from './knowledge-header' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx deleted file mode 100644 index 7d1e623ee1d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx +++ /dev/null @@ -1,204 +0,0 @@ -'use client' - -import { useState } from 'react' -import { createLogger } from '@sim/logger' -import { AlertTriangle, LibraryBig, MoreHorizontal } from 'lucide-react' -import Link from 'next/link' -import { - Button, - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Tooltip, -} from '@/components/emcn' -import { ChevronDown } from '@/components/emcn/icons' -import { Trash } from '@/components/emcn/icons/trash' -import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants' -import { useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' -import { useWorkspacesQuery } from '@/hooks/queries/workspace' - -const logger = createLogger('KnowledgeHeader') - -interface BreadcrumbItem { - label: string - href?: string - id?: string -} - -const HEADER_STYLES = { - container: 'flex items-center justify-between px-6 pt-3.5 pb-6', - breadcrumbs: 'flex items-center gap-2', - icon: 'size-[18px] text-[var(--text-icon)] transition-colors', - link: 'group flex items-center gap-2 font-medium text-sm text-[var(--text-body)] transition-colors hover-hover:text-[var(--text-secondary)]', - label: 'font-medium text-sm text-[var(--text-body)]', - separator: 'text-[var(--text-icon)]', - actionsContainer: 'flex items-center gap-2', -} as const - -interface KnowledgeHeaderOptions { - knowledgeBaseId?: string - currentWorkspaceId?: string | null - onWorkspaceChange?: (workspaceId: string | null) => void | Promise - onDeleteKnowledgeBase?: () => void -} - -interface KnowledgeHeaderProps { - breadcrumbs: BreadcrumbItem[] - options?: KnowledgeHeaderOptions -} - -export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) { - const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false) - const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false) - - const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery( - !!options?.knowledgeBaseId - ) - const workspaces = allWorkspaces.filter( - (ws) => ws.permissions === 'write' || ws.permissions === 'admin' - ) - - const updateKnowledgeBase = useUpdateKnowledgeBase() - - const handleWorkspaceChange = async (workspaceId: string | null) => { - if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return - - setIsWorkspaceMenuOpen(false) - - updateKnowledgeBase.mutate( - { - knowledgeBaseId: options.knowledgeBaseId, - updates: { workspaceId }, - }, - { - onSuccess: () => { - logger.info( - `Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}` - ) - options.onWorkspaceChange?.(workspaceId) - }, - onError: (err) => { - logger.error('Error updating workspace:', err) - }, - } - ) - } - - const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId) - const hasWorkspace = !!options?.currentWorkspaceId - - return ( -
    -
    - {breadcrumbs.map((breadcrumb, index) => { - const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}` - - return ( -
    - {index === 0 && } - - {breadcrumb.href ? ( - - {breadcrumb.label} - - ) : ( - {breadcrumb.label} - )} - - {index < breadcrumbs.length - 1 && /} -
    - ) - })} -
    - - {/* Actions Area */} - {options && ( -
    - {/* Workspace Selector */} - {options.knowledgeBaseId && ( -
    - {/* Warning icon for unassigned knowledge bases */} - {!hasWorkspace && ( - - - - - Not assigned to workspace - - )} - - {/* Workspace selector dropdown */} - - - - - - handleWorkspaceChange(null)} - > - No workspace - - - {workspaces.map((workspace) => ( - handleWorkspaceChange(workspace.id)} - > - {workspace.name} - - ))} - - {workspaces.length === 0 && !isLoadingWorkspaces && ( - - - No workspaces with write access - - - )} - - -
    - )} - - {/* Actions Menu */} - {options.onDeleteKnowledgeBase && ( - - - - - - options.onDeleteKnowledgeBase?.()}> - - Delete Knowledge Base - - - - )} -
    - )} -
    - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index 70de049d16f..ae9a8d157c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -134,7 +134,14 @@ export function CredentialSets() { return false } - setEmailItems((prev) => [...prev, { value: normalized, isValid }]) + setEmailItems((prev) => [ + ...prev, + { + value: normalized, + isValid, + error: isValid ? undefined : (validation.reason ?? 'Invalid email format'), + }, + ]) if (isValid) { setEmailError(null) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx index 813d31c34c5..121be256edc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx @@ -29,21 +29,31 @@ interface OrganizationInviteModalProps { organizationId: string /** Workspaces the inviter can grant access to. */ workspaces: Array<{ id: string; name: string }> - /** Emails that already belong to the organization (rejected as duplicates). */ - existingEmails?: string[] + /** Emails of external collaborators (rejected — they cannot join the organization). */ + externalEmails?: string[] + /** + * Non-member emails with a pending invitation (rejected as duplicates). + * Member emails are always allowed — they receive workspace invitations for + * the selected workspaces they aren't in yet, deduped per workspace by the + * server — so the parent excludes them from this list. + */ + pendingEmails?: string[] } /** * Organization-level invite modal: enter emails, pick one or more workspaces to * grant access to, choose a role applied to every selected workspace, and send - * through the organization invite path. + * through the organization invite path. Emails of existing organization + * members are accepted — the server sends them workspace-only invitations for + * the selected workspaces they don't already have access to. */ export function OrganizationInviteModal({ open, onOpenChange, organizationId, workspaces, - existingEmails = [], + externalEmails = [], + pendingEmails = [], }: OrganizationInviteModalProps) { const [emails, setEmails] = useState([]) const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState([]) @@ -59,9 +69,14 @@ export function OrganizationInviteModal({ [workspaces] ) - const existingEmailSet = useMemo( - () => new Set(existingEmails.map((email) => email.toLowerCase())), - [existingEmails] + const externalEmailSet = useMemo( + () => new Set(externalEmails.map((email) => email.toLowerCase())), + [externalEmails] + ) + + const pendingEmailSet = useMemo( + () => new Set(pendingEmails.map((email) => email.toLowerCase())), + [pendingEmails] ) const validateEmail = useCallback( @@ -69,12 +84,15 @@ export function OrganizationInviteModal({ if (session?.user?.email && session.user.email.toLowerCase() === email) { return 'You cannot invite yourself' } - if (existingEmailSet.has(email)) { - return `${email} is already in this organization` + if (externalEmailSet.has(email)) { + return `${email} belongs to another organization and can't be invited. Invite them to individual workspaces from the Teammates tab.` + } + if (pendingEmailSet.has(email)) { + return `${email} already has a pending invitation` } return null }, - [session?.user?.email, existingEmailSet] + [session?.user?.email, externalEmailSet, pendingEmailSet] ) const handleEmailsChange = useCallback((next: string[]) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index bdddff104e2..7088804bc38 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -98,10 +98,29 @@ export function TeamManagement() { } : null - const existingEmails = useMemo(() => { - const memberEmails = (roster?.members ?? []).map((member) => member.email) - const pendingEmails = (roster?.pendingInvitations ?? []).map((invitation) => invitation.email) - return [...memberEmails, ...pendingEmails] + const externalEmails = useMemo( + () => + (roster?.members ?? []) + .filter((member) => member.role === 'external') + .map((member) => member.email), + [roster] + ) + + /** + * Pending invitations for emails that already belong to a member are + * excluded: members can always be re-invited to additional workspaces (the + * server dedupes per workspace), so only non-member pending emails are + * blocked in the invite modal. + */ + const pendingEmails = useMemo(() => { + const memberEmailSet = new Set( + (roster?.members ?? []) + .filter((member) => member.role !== 'external') + .map((member) => member.email.toLowerCase()) + ) + return (roster?.pendingInvitations ?? []) + .map((invitation) => invitation.email) + .filter((email) => !memberEmailSet.has(email.toLowerCase())) }, [roster]) useEffect(() => { @@ -344,7 +363,8 @@ export function TeamManagement() { onOpenChange={setInviteModalOpen} organizationId={displayOrganization.id} workspaces={roster?.workspaces ?? []} - existingEmails={existingEmails} + externalEmails={externalEmails} + pendingEmails={pendingEmails} /> -
    +

    Configure column

    @@ -317,16 +315,16 @@ export function EnrichmentConfig({ } > - setOutputNames((prev) => ({ ...prev, [output.id]: e.target.value })) } spellCheck={false} autoComplete='off' - className={cn(outErr && 'border-[var(--text-error)]')} + error={Boolean(outErr)} /> - {outErr &&

    {outErr}

    } + {outErr && } ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index a2575b4b43f..6b50989f05f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Input } from '@/components/emcn' +import { Button, ChipInput } from '@/components/emcn' import { Search, X } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition, WorkflowGroup } from '@/lib/table' @@ -72,16 +72,17 @@ function EnrichmentsSidebarBody({ if (editGroup && !editEnrichment) { return (
    -
    +

    Enrichment

    - +

    @@ -117,30 +118,28 @@ function EnrichmentsSidebarBody({ return (

    -
    +

    Enrichments

    - +
    -
    - - setQuery(e.target.value)} - placeholder='Search' - spellCheck={false} - autoComplete='off' - className='pl-7' - /> -
    + setQuery(e.target.value)} + placeholder='Search' + spellCheck={false} + autoComplete='off' + />
    diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index 34b5f41f5fa..02d4710b130 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -4,6 +4,7 @@ export * from './enrichments-sidebar' export * from './new-column-dropdown' export * from './row-modal' export * from './run-status-control' +export * from './sidebar-fields' export * from './table-action-bar' export * from './table-filter' export * from './table-grid' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/index.ts new file mode 100644 index 00000000000..b8175522f14 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/index.ts @@ -0,0 +1 @@ +export { FieldError, RequiredLabel } from './sidebar-fields' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx new file mode 100644 index 00000000000..d08ad20bbfc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx @@ -0,0 +1,30 @@ +'use client' + +import type React from 'react' +import { Label } from '@/components/emcn' + +/** + * Field label with a trailing required marker, matching the sidebar field + * rhythm shared by the column-config and workflow sidebars. + */ +export function RequiredLabel({ + htmlFor, + children, +}: { + htmlFor?: string + children: React.ReactNode +}) { + return ( + + ) +} + +/** + * Inline validation error rendered under a sidebar field. + */ +export function FieldError({ message }: { message: string }) { + return

    {message}

    +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx index c29a140d29c..eff97d3e890 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -1,5 +1,6 @@ 'use client' +import type React from 'react' import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' import { Button, Tooltip } from '@/components/emcn' import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons' @@ -98,71 +99,35 @@ export function TableActionBar({
    {showPlay && ( - - - - - {playLabel} - + + + )} {showRefresh && ( - - - - - {refreshLabel} - + + + )} {runningCount > 0 && ( - - - - - {stopLabel} - + + + )} {onViewExecution && ( - - - - - View execution - + + + )}
    @@ -172,3 +137,34 @@ export function TableActionBar({ ) } + +interface ActionIconButtonProps { + /** Tooltip text, also used as the button's accessible label. */ + label: string + onClick: () => void + disabled: boolean + children: React.ReactNode +} + +/** + * Tooltip-wrapped icon button sharing the action bar's brand-hover chrome, + * so the chrome string lives in one place. + */ +function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) { + return ( + + + + + {label} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 7b0737f6fd1..9d9800bffe3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -2,24 +2,13 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { generateShortId } from '@sim/utils/id' -import { X } from 'lucide-react' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/emcn' -import { ChevronDown, Plus } from '@/components/emcn/icons' +import { Button, ChipDropdown, ChipInput } from '@/components/emcn' +import { Plus, X } from '@/components/emcn/icons' import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' -const OPERATOR_LABELS = Object.fromEntries( - COMPARISON_OPERATORS.map((op) => [op.value, op.label]) -) as Record - interface TableFilterProps { columns: ColumnDefinition[] filter: Filter | null @@ -150,6 +139,14 @@ const FilterRuleRow = memo(function FilterRuleRow({ onApply, onToggleLogical, }: FilterRuleRowProps) { + // Keep a stale column id selectable/visible (e.g. after the column was + // removed) instead of falling back to the placeholder while the rule still + // filters on it. + const columnOptions = + rule.column && !columns.some((col) => col.value === rule.column) + ? [...columns, { value: rule.column, label: rule.column }] + : columns + return (
    {isFirst ? ( @@ -163,67 +160,49 @@ const FilterRuleRow = memo(function FilterRuleRow({ )} - - - - - - {columns.map((col) => ( - onUpdate(rule.id, 'column', col.value)} - > - {col.label} - - ))} - - - - - - - - - {COMPARISON_OPERATORS.map((op) => ( - onUpdate(rule.id, 'operator', op.value)} - > - {op.label} - - ))} - - + onUpdate(rule.id, 'column', value)} + placeholder='Column' + align='start' + matchTriggerWidth={false} + className='min-w-[100px]' + /> + + onUpdate(rule.id, 'operator', value)} + placeholder='Operator' + align='start' + matchTriggerWidth={false} + className='min-w-[90px]' + /> {VALUELESS_OPERATORS.has(rule.operator) ? (
    ) : ( - onUpdate(rule.id, 'value', e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onApply() }} placeholder='Enter a value' - className='h-[30px] flex-1 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] dark:bg-[var(--surface-4)]' + className='flex-1' /> )} - +
    ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx index f499a41633e..d610d99bc60 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx @@ -40,7 +40,6 @@ export function ExpandedCellPopover({ const rootRef = useRef(null) const textareaRef = useRef(null) const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null) - const [draftValue, setDraftValue] = useState('') const target = useMemo(() => { if (!expandedCell) return null @@ -75,7 +74,6 @@ export function ExpandedCellPopover({ setRect(null) return } - setDraftValue(isEditable ? formatValueForInput(target.value, target.column.type) : '') const selector = `[data-table-scroll] [data-row-id="${target.row.id}"][data-col="${target.colIndex}"]` const el = document.querySelector(selector) if (!el) { @@ -86,7 +84,7 @@ export function ExpandedCellPopover({ setRect({ top: r.top, left: r.left, width: r.width }) // Focus textarea on open so typing works immediately. requestAnimationFrame(() => textareaRef.current?.focus()) - }, [expandedCell, target, isEditable]) + }, [expandedCell, target]) const onCloseEvent = useEffectEvent(onClose) @@ -136,23 +134,6 @@ export function ExpandedCellPopover({ ? Math.max(VIEWPORT_PAD, window.innerHeight - EXPANDED_CELL_HEIGHT - VIEWPORT_PAD) : rect.top - const handleSave = () => { - if (!isEditable) return - // `displayToStorage` only normalizes dates — it returns null for anything else. - // Fall back to the raw draft for non-date columns, matching the inline editor. - const raw = displayToStorage(draftValue) ?? draftValue - const cleaned = cleanCellValue(raw, target.column) - onSave(target.row.id, target.column.key, cleaned, 'blur') - onClose() - } - - const handleTextareaKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSave() - } - } - return (
    {isEditable ? ( - <> -