diff --git a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx index 1a048777552..8d481b0d62f 100644 --- a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx @@ -66,11 +66,48 @@ import { Callout } from 'fumadocs-ui/components/callout' | `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` | | `COPILOT_API_KEY` | API key for copilot features | | `ADMIN_API_KEY` | Admin API key for GitOps operations | -| `RESEND_API_KEY` | Email service for notifications | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | | `DISABLE_REGISTRATION` | Set to `true` to disable new user signups | +## Email Providers + +Configure one provider — the mailer auto-detects in priority order: **Resend → AWS SES → SMTP → Azure Communication Services**. If none are configured, emails are logged to the console instead. + +| Variable | Description | +|----------|-------------| +| `FROM_EMAIL_ADDRESS` | Sender address (e.g. `Sim `). Falls back to `noreply@EMAIL_DOMAIN`. | +| `EMAIL_DOMAIN` | Default domain when `FROM_EMAIL_ADDRESS` is unset | +| `EMAIL_VERIFICATION_ENABLED` | Set to `true` to require email verification on signup | + +**Resend** + +| Variable | Description | +|----------|-------------| +| `RESEND_API_KEY` | API key from [resend.com](https://resend.com) | + +**AWS SES** + +| Variable | Description | +|----------|-------------| +| `AWS_SES_REGION` | AWS region for SES (e.g. `us-east-1`). Credentials are resolved through the standard AWS SDK provider chain (env vars, IRSA, ECS/EC2 instance role, SSO). | + +**SMTP** (works with MailHog, Postfix, SendGrid SMTP, etc.) + +| Variable | Description | +|----------|-------------| +| `SMTP_HOST` | SMTP server hostname | +| `SMTP_PORT` | `465` for implicit TLS, `587` for STARTTLS, `25` for plain | +| `SMTP_USER` | Optional — omit for unauthenticated relays | +| `SMTP_PASS` | Optional — omit for unauthenticated relays | +| `SMTP_SECURE` | Set to `true` to force TLS on connect; auto-true on port 465 | + +**Azure Communication Services** + +| Variable | Description | +|----------|-------------| +| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string | + ## Example .env ```bash diff --git a/apps/docs/content/docs/en/triggers/hubspot.mdx b/apps/docs/content/docs/en/triggers/hubspot.mdx index 886f39e8e63..3e8e1c6708c 100644 --- a/apps/docs/content/docs/en/triggers/hubspot.mdx +++ b/apps/docs/content/docs/en/triggers/hubspot.mdx @@ -10,1135 +10,40 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" color="#FF7A59" /> -HubSpot provides 27 triggers for automating workflows based on events. +HubSpot provides 1 trigger for automating workflows based on events. -## Triggers - -### HubSpot Company Created - -Trigger workflow when a new company is created in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Company Deleted - -Trigger workflow when a company is deleted in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Company Merged - -Trigger workflow when companies are merged in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing merge event details | -| ↳ `objectId` | number | HubSpot object ID \(winning/primary record\) | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `mergedObjectIds` | array | IDs of the objects that were merged into the primary record | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_contact_merged\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | - - ---- - -### HubSpot Company Property Changed - -Trigger workflow when any property of a company is updated in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | -| `propertyName` | string | No | Optional: Filter to only trigger when a specific property changes. Leave empty to trigger on any property change. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Company Restored - -Trigger workflow when a deleted company is restored in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Contact Created - -Trigger workflow when a new contact is created in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Contact Deleted - -Trigger workflow when a contact is deleted in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Contact Merged - -Trigger workflow when contacts are merged in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing merge event details | -| ↳ `objectId` | number | HubSpot object ID \(winning/primary record\) | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `mergedObjectIds` | array | IDs of the objects that were merged into the primary record | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_contact_merged\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | - - ---- - -### HubSpot Contact Privacy Deleted - -Trigger workflow when a contact is deleted for privacy compliance (GDPR, CCPA, etc.) in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Contact Property Changed - -Trigger workflow when any property of a contact is updated in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | -| `propertyName` | string | No | Optional: Filter to only trigger when a specific property changes. Leave empty to trigger on any property change. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Contact Restored - -Trigger workflow when a deleted contact is restored in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Conversation Creation - -Trigger workflow when a new conversation is created in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Conversation Deletion - -Trigger workflow when a conversation is deleted in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- +All triggers below are **polling-based** — they check for new data on a schedule rather than receiving push notifications. -### HubSpot Conversation New Message - -Trigger workflow when a new message is added to a conversation in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Conversation Privacy Deletion - -Trigger workflow when a conversation is deleted for privacy compliance (GDPR, CCPA, etc.) in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Conversation Property Changed - -Trigger workflow when any property of a conversation is updated in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | -| `propertyName` | string | No | Optional: Filter to only trigger when a specific property changes. Leave empty to trigger on any property change. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Deal Created - -Trigger workflow when a new deal is created in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Deal Deleted - -Trigger workflow when a deal is deleted in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Deal Merged - -Trigger workflow when deals are merged in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing merge event details | -| ↳ `objectId` | number | HubSpot object ID \(winning/primary record\) | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `mergedObjectIds` | array | IDs of the objects that were merged into the primary record | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_contact_merged\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | - - ---- - -### HubSpot Deal Property Changed - -Trigger workflow when any property of a deal is updated in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | -| `propertyName` | string | No | Optional: Filter to only trigger when a specific property changes. Leave empty to trigger on any property change. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Deal Restored - -Trigger workflow when a deleted deal is restored in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Ticket Created - -Trigger workflow when a new ticket is created in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Ticket Deleted - -Trigger workflow when a ticket is deleted in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Ticket Merged - -Trigger workflow when tickets are merged in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing merge event details | -| ↳ `objectId` | number | HubSpot object ID \(winning/primary record\) | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `mergedObjectIds` | array | IDs of the objects that were merged into the primary record | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_contact_merged\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | - - ---- - -### HubSpot Ticket Property Changed - -Trigger workflow when any property of a ticket is updated in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | -| `propertyName` | string | No | Optional: Filter to only trigger when a specific property changes. Leave empty to trigger on any property change. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- - -### HubSpot Ticket Restored - -Trigger workflow when a deleted ticket is restored in HubSpot - -#### Configuration - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | - - ---- +## Triggers -### HubSpot Webhook (All Events) +### HubSpot Trigger -Trigger workflow on any HubSpot webhook event +Triggers when a HubSpot record (contact, company, deal, ticket, or custom object) is created or updated #### Configuration | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `clientSecret` | string | Yes | Found in your HubSpot app settings under Auth tab | -| `appId` | string | Yes | Found in your HubSpot app settings. Used to identify your app. | -| `developerApiKey` | string | Yes | Used for making API calls to HubSpot. Found in your HubSpot app settings. | +| `triggerCredentials` | string | Yes | Connect a HubSpot account so Sim can poll your CRM on your behalf. | +| `objectType` | string | Yes | Which HubSpot CRM object to watch. Pick | +| `customObjectTypeId` | string | Yes | HubSpot custom object type ID \(e.g. | +| `eventType` | string | Yes | Created fires once per new record. Updated fires whenever the record changes \(and on creation\). | +| `properties` | string | No | Comma- or newline-separated list of HubSpot property names to include on each record. Leave empty to use sensible defaults. Sim always includes the timestamp properties Sim needs internally, regardless of this list. | +| `filterPropertyName` | string | No | Only emit records where this property equals the value below. Leave both fields empty to emit every change. | +| `filterPropertyValue` | string | No | Value the filter property must match \(exact match, case-sensitive\). | +| `maxRecordsPerPoll` | string | No | Cap on records emitted per poll \(default 50, max 1000\). Excess rolls over to the next poll. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `payload` | array | Full webhook payload array from HubSpot containing event details | -| ↳ `objectId` | number | HubSpot object ID | -| ↳ `subscriptionType` | string | Type of subscription event | -| ↳ `portalId` | number | HubSpot portal ID | -| ↳ `occurredAt` | number | Timestamp when event occurred \(ms\) | -| ↳ `attemptNumber` | number | Webhook delivery attempt number | -| ↳ `eventId` | number | Event ID | -| ↳ `changeSource` | string | Source of the change | -| ↳ `propertyName` | string | Property name \(for propertyChange events\) | -| ↳ `propertyValue` | string | New property value \(for propertyChange events\) | -| `provider` | string | Provider name \(hubspot\) | -| `providerConfig` | object | Provider configuration | -| ↳ `appId` | string | HubSpot App ID | -| ↳ `clientId` | string | HubSpot Client ID | -| ↳ `triggerId` | string | Trigger ID \(e.g., hubspot_company_created\) | -| ↳ `clientSecret` | string | HubSpot Client Secret | -| ↳ `developerApiKey` | string | HubSpot Developer API Key | -| ↳ `curlSetWebhookUrl` | string | curl command to set webhook URL | -| ↳ `curlCreateSubscription` | string | curl command to create subscription | -| ↳ `webhookUrlDisplay` | string | Webhook URL display value | -| ↳ `propertyName` | string | Optional property name filter \(for property change triggers\) | +| `objectType` | string | HubSpot object type that fired the trigger \(contact, company, deal, ticket, or custom object type ID\) | +| `eventType` | string | Event type that fired the trigger \(created or updated\) | +| `objectId` | string | HubSpot ID of the affected record | +| `occurredAt` | string | ISO timestamp of the create or update on the record \(sourced from the relevant HubSpot timestamp property\) | +| `properties` | json | HubSpot properties returned for the record \(object of property name to value\) | +| `createdAt` | string | ISO timestamp when the record was created in HubSpot | +| `updatedAt` | string | ISO timestamp when the record was last updated in HubSpot | +| `archived` | boolean | Whether the record is archived | +| `timestamp` | string | ISO timestamp when Sim emitted the event | diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f554797ea1e..95c5115cb2b 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -19,8 +19,30 @@ INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to gen API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt api keys # Email Provider (Optional) -# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails - # If left commented out, emails will be logged to console instead +# Configure ONE provider — the mailer auto-detects in priority order: +# Resend → AWS SES → SMTP → Azure Communication Services. If none are +# configured, emails are logged to console instead. +# +# Resend +# RESEND_API_KEY= # API key from https://resend.com +# +# AWS SES (credentials resolved via the standard AWS provider chain: +# env vars, shared config, ECS/EKS task role, EC2 instance profile, SSO) +# AWS_SES_REGION=us-east-1 +# +# SMTP (works with MailHog locally: host=localhost port=1025, no auth) +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 # 465 = implicit TLS, 587 = STARTTLS, 25 = plain +# SMTP_USER= # Optional — omit for unauthenticated relays +# SMTP_PASS= # Optional — omit for unauthenticated relays +# SMTP_SECURE= # Set "true" to force TLS on connect; auto-true on port 465 +# +# Azure Communication Services +# AZURE_ACS_CONNECTION_STRING= +# +# Shared sender configuration +# FROM_EMAIL_ADDRESS="Sim " +# EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset # Local AI Models (Optional) # OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 7f67d37673a..78fdecf2975 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ +import { redisConfigMock, redisConfigMockFns } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/oauth/oauth', () => ({ @@ -11,7 +12,10 @@ vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, })) +vi.mock('@/lib/core/config/redis', () => redisConfigMock) + import { db } from '@sim/db' +import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight' import { refreshOAuthToken } from '@/lib/oauth' import { getCredential, @@ -49,6 +53,10 @@ function mockUpdateChain() { describe('OAuth Utils', () => { beforeEach(() => { vi.clearAllMocks() + __resetCoalesceLocallyForTests() + redisConfigMockFns.mockGetRedisClient.mockReturnValue(null) + redisConfigMockFns.mockAcquireLock.mockResolvedValue(true) + redisConfigMockFns.mockReleaseLock.mockResolvedValue(true) }) afterEach(() => { @@ -107,6 +115,7 @@ describe('OAuth Utils', () => { } mockRefreshOAuthToken.mockResolvedValueOnce({ + ok: true, accessToken: 'new-token', expiresIn: 3600, refreshToken: 'new-refresh-token', @@ -130,7 +139,11 @@ describe('OAuth Utils', () => { providerId: 'google', } - mockRefreshOAuthToken.mockResolvedValueOnce(null) + mockRefreshOAuthToken.mockResolvedValueOnce({ + ok: false, + errorCode: 'invalid_grant', + message: 'Failed', + }) await expect( refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') @@ -198,6 +211,7 @@ describe('OAuth Utils', () => { mockUpdateChain() mockRefreshOAuthToken.mockResolvedValueOnce({ + ok: true, accessToken: 'new-token', expiresIn: 3600, refreshToken: 'new-refresh-token', @@ -237,7 +251,11 @@ describe('OAuth Utils', () => { mockSelectChain([mockResolvedCredential]) mockSelectChain([mockAccountRow]) - mockRefreshOAuthToken.mockResolvedValueOnce(null) + mockRefreshOAuthToken.mockResolvedValueOnce({ + ok: false, + errorCode: 'invalid_grant', + message: 'Failed', + }) const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 4109441528d..bbfdb0135be 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -4,6 +4,8 @@ import { account, credential, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, inArray } from 'drizzle-orm' +import { withLeaderLock } from '@/lib/concurrency/leader-lock' +import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' import { refreshOAuthToken } from '@/lib/oauth' import { @@ -11,6 +13,11 @@ import { isMicrosoftProvider, PROACTIVE_REFRESH_THRESHOLD_DAYS, } from '@/lib/oauth/microsoft' +import { + getRecentTerminalError, + isTerminalRefreshError, + markCredentialDead, +} from '@/lib/oauth/terminal-errors' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, @@ -318,6 +325,112 @@ export async function getCredential(requestId: string, credentialId: string, use return getCredentialByAccountId(requestId, resolved.accountId, userId) } +interface CoalescedRefreshOptions { + accountId: string + providerId: string + refreshToken: string + requestId?: string + userId?: string +} + +async function performCoalescedRefresh({ + accountId, + providerId, + refreshToken, + requestId, + userId, +}: CoalescedRefreshOptions): Promise { + const logContext = { + ...(requestId ? { requestId } : {}), + ...(userId ? { userId } : {}), + providerId, + accountId, + } + + const deadCode = await getRecentTerminalError(accountId) + if (deadCode) { + logger.warn('Skipping refresh: credential recently failed', { + ...logContext, + errorCode: deadCode, + }) + return null + } + + const lockKey = `oauth:refresh:${accountId}` + + return coalesceLocally(lockKey, () => + withLeaderLock({ + key: lockKey, + onLeader: async () => { + try { + const result = await refreshOAuthToken(providerId, refreshToken) + + if (!result.ok) { + logger.error('Failed to refresh token', { + ...logContext, + errorCode: result.errorCode, + }) + if (result.errorCode && isTerminalRefreshError(result.errorCode)) { + await markCredentialDead(accountId, result.errorCode) + } + return null + } + + const updateData: Record = { + accessToken: result.accessToken, + accessTokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000), + updatedAt: new Date(), + } + if (result.refreshToken && result.refreshToken !== refreshToken) { + updateData.refreshToken = result.refreshToken + } + if (isMicrosoftProvider(providerId)) { + updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } + + await db.update(account).set(updateData).where(eq(account.id, accountId)) + + logger.info('Successfully refreshed access token', logContext) + return result.accessToken + } catch (error) { + logger.error('Refresh failed inside leader path', { + ...logContext, + error: toError(error).message, + }) + return null + } + }, + onFollower: async () => { + try { + const [row] = await db + .select({ + accessToken: account.accessToken, + accessTokenExpiresAt: account.accessTokenExpiresAt, + }) + .from(account) + .where(eq(account.id, accountId)) + .limit(1) + if ( + row?.accessToken && + row.accessTokenExpiresAt && + row.accessTokenExpiresAt > new Date() + ) { + logger.info('Got fresh access token from coalesced refresh', logContext) + return row.accessToken + } + return null + } catch (error) { + logger.warn('Follower DB read failed during refresh poll', { + ...logContext, + error: toError(error).message, + }) + return null + } + }, + }) + ) +} + export async function getOAuthToken(userId: string, providerId: string): Promise { const connections = await db .select({ @@ -347,52 +460,12 @@ export async function getOAuthToken(userId: string, providerId: string): Promise !!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now)) if (shouldAttemptRefresh) { - logger.info( - `Access token expired for user ${userId}, provider ${providerId}. Attempting to refresh.` - ) - - try { - // Use the existing refreshOAuthToken function - const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!) - - if (!refreshResult) { - logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, { - providerId, - userId, - hasRefreshToken: !!credential.refreshToken, - }) - return null - } - - const { accessToken, expiresIn, refreshToken: newRefreshToken } = refreshResult - - // Update the database with new tokens - const updateData: any = { - accessToken, - accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Convert seconds to milliseconds - updatedAt: new Date(), - } - - // If we received a new refresh token (some providers like Airtable rotate them), save it - if (newRefreshToken && newRefreshToken !== credential.refreshToken) { - logger.info(`Updating refresh token for user ${userId}, provider ${providerId}`) - updateData.refreshToken = newRefreshToken - } - - // Update the token in the database with the actual expiration time from the provider - await db.update(account).set(updateData).where(eq(account.id, credential.id)) - - logger.info(`Successfully refreshed token for user ${userId}, provider ${providerId}`) - return accessToken - } catch (error) { - logger.error(`Error refreshing token for user ${userId}, provider ${providerId}`, { - error: toError(error).message, - stack: error instanceof Error ? error.stack : undefined, - providerId, - userId, - }) - return null - } + return performCoalescedRefresh({ + accountId: credential.id, + providerId, + refreshToken: credential.refreshToken!, + userId, + }) } if (!credential.accessToken) { @@ -472,66 +545,27 @@ export async function refreshAccessTokenIfNeeded( const accessToken = credential.accessToken if (shouldRefresh) { - logger.info(`[${requestId}] Refreshing token for credential`) - try { - const refreshedToken = await refreshOAuthToken( - credential.providerId, - credential.refreshToken! - ) - - if (!refreshedToken) { - logger.error(`[${requestId}] Failed to refresh token for credential: ${credentialId}`, { - credentialId, - providerId: credential.providerId, - userId: credential.userId, - hasRefreshToken: !!credential.refreshToken, - }) - if (!accessTokenNeedsRefresh && accessToken) { - logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) - return accessToken - } - return null - } + const resolvedCredentialId = + (credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId - // Prepare update data - const updateData: Record = { - accessToken: refreshedToken.accessToken, - accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), - updatedAt: new Date(), - } - - // If we received a new refresh token, update it - if (refreshedToken.refreshToken && refreshedToken.refreshToken !== credential.refreshToken) { - logger.info(`[${requestId}] Updating refresh token for credential`) - updateData.refreshToken = refreshedToken.refreshToken - } - - if (isMicrosoftProvider(credential.providerId)) { - updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() - } + const fresh = await performCoalescedRefresh({ + accountId: resolvedCredentialId, + providerId: credential.providerId, + refreshToken: credential.refreshToken!, + requestId, + userId: credential.userId, + }) + if (fresh) return fresh - // Update the token in the database - const resolvedCredentialId = - (credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId - await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId)) - - logger.info(`[${requestId}] Successfully refreshed access token for credential`) - return refreshedToken.accessToken - } catch (error) { - logger.error(`[${requestId}] Error refreshing token for credential`, { - error: toError(error).message, - stack: error instanceof Error ? error.stack : undefined, - providerId: credential.providerId, - credentialId, - userId: credential.userId, - }) - if (!accessTokenNeedsRefresh && accessToken) { - logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) - return accessToken - } - return null + // If refresh was only triggered proactively (Microsoft refresh-token aging), + // the still-valid access token is a fine fallback. + if (!accessTokenNeedsRefresh && accessToken) { + logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`) + return accessToken } - } else if (!accessToken) { + return null + } + if (!accessToken) { // We have no access token and either no refresh token or not eligible to refresh logger.error(`[${requestId}] Missing access token for credential`) return null @@ -580,65 +614,20 @@ export async function refreshTokenIfNeeded( return { accessToken: credential.accessToken, refreshed: false } } - try { - const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!) - - if (!refreshResult) { - logger.error(`[${requestId}] Failed to refresh token for credential`) - if (!accessTokenNeedsRefresh && credential.accessToken) { - logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) - return { accessToken: credential.accessToken, refreshed: false } - } - throw new Error('Failed to refresh token') - } - - const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult - - // Prepare update data - const updateData: Record = { - accessToken: refreshedToken, - accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry - updatedAt: new Date(), - } - - // If we received a new refresh token, update it - if (newRefreshToken && newRefreshToken !== credential.refreshToken) { - logger.info(`[${requestId}] Updating refresh token`) - updateData.refreshToken = newRefreshToken - } - - if (isMicrosoftProvider(credential.providerId)) { - updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() - } - - await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId)) - - logger.info(`[${requestId}] Successfully refreshed access token`) - return { accessToken: refreshedToken, refreshed: true } - } catch (error) { - logger.warn( - `[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded` - ) - - const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId) - if (freshCredential?.accessToken) { - const freshExpiresAt = freshCredential.accessTokenExpiresAt - const stillValid = !freshExpiresAt || freshExpiresAt > new Date() - - if (stillValid) { - logger.info(`[${requestId}] Found valid token from concurrent refresh, using it`) - return { accessToken: freshCredential.accessToken, refreshed: true } - } - } - - if (!accessTokenNeedsRefresh && credential.accessToken) { - logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) - return { accessToken: credential.accessToken, refreshed: false } - } + const fresh = await performCoalescedRefresh({ + accountId: resolvedCredentialId, + providerId: credential.providerId, + refreshToken: credential.refreshToken!, + requestId, + userId: credential.userId, + }) + if (fresh) return { accessToken: fresh, refreshed: true } - logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error) - throw error + if (!accessTokenNeedsRefresh && credential.accessToken) { + logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`) + return { accessToken: credential.accessToken, refreshed: false } } + throw new Error('Failed to refresh token') } export interface CredentialSetCredential { @@ -701,32 +690,13 @@ export async function getCredentialsForCredentialSet( let accessToken = cred.accessToken if (shouldRefresh && cred.refreshToken) { - try { - const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken) - - if (refreshResult) { - accessToken = refreshResult.accessToken - - const updateData: Record = { - accessToken: refreshResult.accessToken, - accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000), - updatedAt: new Date(), - } - - if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) { - updateData.refreshToken = refreshResult.refreshToken - } - - await db.update(account).set(updateData).where(eq(account.id, cred.id)) - - logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`) - } - } catch (error) { - logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, { - error: toError(error).message, - }) - continue - } + const fresh = await performCoalescedRefresh({ + accountId: cred.id, + providerId, + refreshToken: cred.refreshToken, + userId: cred.userId, + }) + if (fresh) accessToken = fresh } if (accessToken) { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index cccbe6af1a1..18c8aafb563 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -13,6 +13,7 @@ import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { verifyFileAccess } from '@/app/api/files/authorization' +import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') @@ -95,7 +96,7 @@ export const GET = withRouteHandler( status: 200, headers: { 'Content-Type': 'text/markdown; charset=utf-8', - 'Content-Disposition': `attachment; filename="${mdName}"`, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(mdName)}`, 'Content-Length': String(mdBytes.length), }, }) @@ -158,7 +159,7 @@ export const GET = withRouteHandler( status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${zipName}"`, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(zipName)}`, 'Content-Length': String(zipBuffer.length), }, }) diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index b6b05f4cbb6..2bdf7663825 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -191,7 +191,7 @@ function getSecureFileHeaders(filename: string, originalContentType: string) { } } -function encodeFilenameForHeader(storageKey: string): string { +export function encodeFilenameForHeader(storageKey: string): string { const filename = storageKey.split('/').pop() || storageKey const hasNonAscii = /[^\x00-\x7F]/.test(filename) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 15b115c0b7c..08171fbc97c 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -167,9 +167,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { - // discoverServerTools writes the result to this server's cache so the UI's - // immediate refetch hits it instead of re-fetching live. - await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId) + // forceRefresh: skip any stale cache from before re-auth. + await mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, true) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) } diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index 7bab3fade1f..9f216ebf959 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -197,7 +197,12 @@ export const POST = withRouteHandler( } try { - discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + discoveredTools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + true + ) connectionStatus = 'connected' toolCount = discoveredTools.length logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`) diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index b125fa7ff2b..612788b4875 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -28,7 +28,7 @@ export const GET = withRouteHandler( logger.info(`[${requestId}] Discovering MCP tools`, { serverId, workspaceId, forceRefresh }) const tools = serverId - ? await mcpService.discoverServerTools(userId, serverId, workspaceId) + ? await mcpService.discoverServerTools(userId, serverId, workspaceId, forceRefresh) : await mcpService.discoverTools(userId, workspaceId, forceRefresh) const byServer: Record = {} @@ -76,7 +76,7 @@ export const POST = withRouteHandler( const results = await Promise.allSettled( serverIds.map(async (serverId: string) => { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true) return { serverId, toolCount: tools.length } }) ) diff --git a/apps/sim/app/api/tools/hubspot/lists/route.ts b/apps/sim/app/api/tools/hubspot/lists/route.ts new file mode 100644 index 00000000000..0ee11b7c043 --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/lists/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotListsSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotListsAPI') + +interface HubSpotList { + listId: string + name: string + objectTypeId?: string + processingType?: string + deletedAt?: string | null +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotListsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, objectTypeId, query } = parsed.data.query + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const params = new URLSearchParams() + if (objectTypeId) params.set('objectTypeId', objectTypeId as string) + params.set('count', '500') + + const response = await fetch( + `https://api.hubapi.com/crm/v3/lists/search?${params.toString()}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: '', + processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'], + ...(objectTypeId ? { additionalProperties: ['hs_object_id'] } : {}), + }), + } + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot lists API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot lists' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { lists?: HubSpotList[] } + const filterTerm = (query as string | undefined)?.toLowerCase() + const lists = (data.lists ?? []) + .filter((l) => !l.deletedAt) + .map((l) => ({ + id: l.listId, + name: l.name, + objectType: l.objectTypeId, + processingType: l.processingType, + })) + .filter( + (l) => + !filterTerm || + l.id.toLowerCase().includes(filterTerm) || + l.name.toLowerCase().includes(filterTerm) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ lists }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot lists:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot lists' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/hubspot/owners/route.ts b/apps/sim/app/api/tools/hubspot/owners/route.ts new file mode 100644 index 00000000000..da58d59b2bf --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/owners/route.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotOwnersSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotOwnersAPI') + +interface HubSpotOwner { + id: string + email?: string + firstName?: string + lastName?: string + archived?: boolean +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotOwnersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, query } = parsed.data.query + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const collected: HubSpotOwner[] = [] + let after: string | undefined + let pages = 0 + do { + const params = new URLSearchParams({ limit: '100' }) + if (after) params.set('after', after) + const response = await fetch(`https://api.hubapi.com/crm/v3/owners?${params.toString()}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot owners API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot owners' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { + results?: HubSpotOwner[] + paging?: { next?: { after?: string } } + } + if (data.results?.length) collected.push(...data.results) + after = data.paging?.next?.after + pages++ + } while (after && pages < 10) + + const filterTerm = (query as string | undefined)?.toLowerCase() + const owners = collected + .filter((o) => !o.archived) + .map((o) => ({ + id: o.id, + name: [o.firstName, o.lastName].filter(Boolean).join(' ') || o.email || o.id, + email: o.email, + })) + .filter( + (o) => + !filterTerm || + o.name.toLowerCase().includes(filterTerm) || + (o.email?.toLowerCase().includes(filterTerm) ?? false) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ owners }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot owners:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot owners' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/hubspot/pipelines/route.ts b/apps/sim/app/api/tools/hubspot/pipelines/route.ts new file mode 100644 index 00000000000..7543120e571 --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/pipelines/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotPipelinesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotPipelinesAPI') + +const BUILT_IN_PATH: Record = { + contact: 'contacts', + company: 'companies', + deal: 'deals', + ticket: 'tickets', +} + +interface HubSpotPipeline { + id: string + label: string + stages?: Array<{ id: string; label: string }> + archived?: boolean +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotPipelinesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, objectType } = parsed.data.query + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const pathSegment = BUILT_IN_PATH[objectType] ?? objectType + const response = await fetch( + `https://api.hubapi.com/crm/v3/pipelines/${encodeURIComponent(pathSegment)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot pipelines API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot pipelines' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { results?: HubSpotPipeline[] } + const pipelines = (data.results ?? []) + .filter((p) => !p.archived) + .map((p) => ({ + id: p.id, + name: p.label, + stages: p.stages?.map((s) => ({ id: s.id, label: s.label })), + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ pipelines }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot pipelines:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot pipelines' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/hubspot/properties/route.ts b/apps/sim/app/api/tools/hubspot/properties/route.ts new file mode 100644 index 00000000000..1fafcaab6f0 --- /dev/null +++ b/apps/sim/app/api/tools/hubspot/properties/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('HubSpotPropertiesAPI') + +const BUILT_IN_PATH: Record = { + contact: 'contacts', + company: 'companies', + deal: 'deals', + ticket: 'tickets', +} + +interface HubSpotProperty { + name: string + label: string + type?: string + fieldType?: string + groupName?: string + hidden?: boolean + archived?: boolean +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const parsed = await parseRequest(hubspotPropertiesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credentialId, objectType, query } = parsed.data.query + + const authz = await authorizeCredentialUse(request, { + credentialId, + requireWorkflowIdForInternal: false, + }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const pathSegment = BUILT_IN_PATH[objectType] ?? objectType + const response = await fetch( + `https://api.hubapi.com/crm/v3/properties/${encodeURIComponent(pathSegment)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + logger.error(`[${requestId}] HubSpot properties API error ${response.status}: ${errorText}`) + return NextResponse.json( + { error: errorText || 'Failed to fetch HubSpot properties' }, + { status: response.status } + ) + } + + const data = (await response.json()) as { results?: HubSpotProperty[] } + if (!Array.isArray(data.results)) { + return NextResponse.json({ error: 'Invalid HubSpot properties response' }, { status: 500 }) + } + + const filterTerm = (query as string | undefined)?.toLowerCase() + const properties = data.results + .filter((p) => !p.hidden && !p.archived) + .map((p) => ({ + id: p.name, + name: p.label || p.name, + type: p.type, + fieldType: p.fieldType, + groupName: p.groupName, + })) + .filter( + (p) => + !filterTerm || + p.id.toLowerCase().includes(filterTerm) || + p.name.toLowerCase().includes(filterTerm) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + + return NextResponse.json({ properties }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching HubSpot properties:`, error) + return NextResponse.json({ error: 'Failed to fetch HubSpot properties' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index abf3ad14135..e7ca9bab1a7 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -45,17 +45,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const linearClient = new LinearClient({ accessToken }) - let projects: Array<{ id: string; name: string }> = [] - const team = await linearClient.team(teamId) - const projectsResult = await team.projects() - projects = projectsResult.nodes.map((project: Project) => ({ - id: project.id, - name: project.name, - })) + /** + * teamId may be a single ID or a comma-separated list when the basic-mode + * team selector is in multi-select. Fetch projects from each team in + * parallel and dedupe by project ID (Linear projects can be cross-team). + */ + const teamIds = teamId + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const perTeam = await Promise.all( + teamIds.map(async (id) => { + const team = await linearClient.team(id) + const result = await team.projects() + return result.nodes.map((project: Project) => ({ + id: project.id, + name: project.name, + })) + }) + ) + + const seen = new Set() + const projects: Array<{ id: string; name: string }> = [] + for (const teamProjects of perTeam) { + for (const project of teamProjects) { + if (seen.has(project.id)) continue + seen.add(project.id) + projects.push(project) + } + } if (projects.length === 0) { - logger.info('No projects found for team', { teamId }) + logger.info('No projects found for team(s)', { teamIds }) } return NextResponse.json({ projects }) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index f8e3d5dde69..fb597ca7c16 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -21,6 +21,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -242,7 +243,7 @@ export const GET = withRouteHandler( status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`, 'Content-Length': arrayBuffer.byteLength.toString(), }, }) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index ebcdc0f2616..41b00f8377a 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -21,6 +21,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -162,7 +163,7 @@ export const GET = withRouteHandler( status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`, 'Content-Length': arrayBuffer.byteLength.toString(), }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index ad215ab4a19..55760bb818d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -29,6 +29,7 @@ import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import type { ConfigFieldValue } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -108,6 +109,7 @@ export function AddConnectorModal({ setCanonicalModes, canonicalGroups, isFieldVisible, + isFieldPopulated, handleFieldChange, toggleCanonicalMode, resolveSourceConfig, @@ -150,7 +152,7 @@ export function AddConnectorModal({ for (const field of connectorConfig.configFields) { if (!field.required) continue if (!isFieldVisible(field)) continue - if (!sourceConfig[field.id]?.trim()) return false + if (!isFieldPopulated(field)) return false } return true }, [ @@ -158,8 +160,8 @@ export function AddConnectorModal({ isApiKeyMode, apiKeyValue, effectiveCredentialId, - sourceConfig, isFieldVisible, + isFieldPopulated, ]) const handleSubmit = () => { @@ -169,7 +171,13 @@ export function AddConnectorModal({ const resolvedConfig: Record = {} for (const [key, value] of Object.entries(resolveSourceConfig())) { - if (value) resolvedConfig[key] = value + if (Array.isArray(value)) { + if (value.length > 0) resolvedConfig[key] = value + } else if (typeof value === 'string') { + if (value) resolvedConfig[key] = value + } else if (value !== undefined && value !== null) { + resolvedConfig[key] = value + } } if (disabledTagIds.size > 0) { resolvedConfig.disabledTagIds = Array.from(disabledTagIds) @@ -370,8 +378,8 @@ export function AddConnectorModal({ {field.type === 'selector' && field.selectorKey ? ( handleFieldChange(field.id, value)} + value={sourceConfig[field.id] ?? (field.multi ? [] : '')} + onChange={(value: ConfigFieldValue) => handleFieldChange(field.id, value)} credentialId={effectiveCredentialId} sourceConfig={sourceConfig} configFields={connectorConfig.configFields} @@ -385,13 +393,21 @@ export function AddConnectorModal({ label: opt.label, value: opt.id, }))} - value={sourceConfig[field.id] || undefined} + value={ + typeof sourceConfig[field.id] === 'string' + ? (sourceConfig[field.id] as string) || undefined + : undefined + } onChange={(value) => handleFieldChange(field.id, value)} placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} /> ) : ( handleFieldChange(field.id, e.target.value)} placeholder={field.placeholder} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 527971dfc28..aab56688cad 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -3,6 +3,10 @@ import { useMemo } from 'react' import { Combobox, type ComboboxOption, Loader } from '@/components/emcn' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' +import type { + ConfigFieldMap, + ConfigFieldValue, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfigField } from '@/connectors/types' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' @@ -10,10 +14,10 @@ import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' interface ConnectorSelectorFieldProps { field: ConnectorConfigField & { selectorKey: SelectorKey } - value: string - onChange: (value: string) => void + value: ConfigFieldValue + onChange: (value: ConfigFieldValue) => void credentialId: string | null - sourceConfig: Record + sourceConfig: ConfigFieldMap configFields: ConnectorConfigField[] canonicalModes: Record disabled?: boolean @@ -29,6 +33,8 @@ export function ConnectorSelectorField({ canonicalModes, disabled, }: ConnectorSelectorFieldProps) { + const isMulti = Boolean(field.multi) + const context = useMemo(() => { const ctx: SelectorContext = {} if (credentialId) ctx.oauthCredential = credentialId @@ -73,11 +79,34 @@ export function ConnectorSelectorField({ ) } + if (isMulti) { + const multiValues = Array.isArray(value) ? value : value ? [value] : [] + return ( + onChange(values)} + searchable + searchPlaceholder={`Search ${field.title.toLowerCase()}...`} + placeholder={ + !credentialId + ? 'Connect an account first' + : !depsResolved + ? `Select ${getDependencyLabel(field, configFields)} first` + : field.placeholder || `Select ${field.title.toLowerCase()}` + } + disabled={disabled || !credentialId || !depsResolved} + /> + ) + } + + const singleValue = Array.isArray(value) ? value[0] : value return ( onChange(next)} searchable searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ @@ -96,18 +125,28 @@ function resolveDepValue( depFieldId: string, configFields: ConnectorConfigField[], canonicalModes: Record, - sourceConfig: Record + sourceConfig: ConfigFieldMap ): string { const depField = configFields.find((f) => f.id === depFieldId) - if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? '' + /** + * For multi-value parent fields, pass all selected values to dependent + * selectors as a comma-joined string so the downstream selector can load + * options across every selected parent (e.g. Linear projects across multiple + * selected teams). Single-value parents pass through unchanged. + */ + const readDep = (raw: ConfigFieldValue | undefined): string => { + if (Array.isArray(raw)) return raw.join(',') + return raw ?? '' + } + if (!depField?.canonicalParamId) return readDep(sourceConfig[depFieldId]) const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic' - if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? '' + if (depField.mode === activeMode) return readDep(sourceConfig[depFieldId]) const activeField = configFields.find( (f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode ) - return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '') + return activeField ? readDep(sourceConfig[activeField.id]) : readDep(sourceConfig[depFieldId]) } function getDependencyLabel( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 01fdbf39fc0..a15578145f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -28,6 +28,10 @@ import { getSubscriptionAccessState } from '@/lib/billing/client' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import type { + ConfigFieldMap, + ConfigFieldValue, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -61,6 +65,51 @@ function readPersistedCanonicalModes( return result } +/** + * Deep equality for sourceConfig values (string, string[], or undefined/null). + * + * Empty string, empty array, and nullish are treated as equivalent to absence. + * When either side is an array (multi-value field), both sides are normalized + * to string[] via CSV-split-and-trim so a persisted legacy scalar `"ENG"` + * compares equal to an in-memory `["ENG"]` and a persisted CSV `"ENG,PROJ"` + * compares equal to `["ENG","PROJ"]`. Without this, opening edit on a + * pre-multi-select connector would falsely show unsaved changes. + */ +function valuesEqual(a: unknown, b: unknown): boolean { + const isEmpty = (v: unknown): boolean => { + if (v == null) return true + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'string') return v.trim() === '' + return false + } + if (isEmpty(a) && isEmpty(b)) return true + + const toArray = (v: unknown): string[] | null => { + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string') + if (typeof v === 'string') { + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + return null + } + + if (Array.isArray(a) || Array.isArray(b)) { + const arrA = toArray(a) ?? [] + const arrB = toArray(b) ?? [] + if (arrA.length !== arrB.length) return false + /** + * Order-insensitive: the multi-select UI does not guarantee insertion order + * matches the server-returned order, so `["PROD","ENG"]` and `["ENG","PROD"]` + * should be treated as equal to avoid a false unsaved-changes state. + */ + const setA = new Set(arrA) + return arrB.every((v) => setA.has(v)) + } + return a === b +} + function didCanonicalModesChange( current: Record, persisted: Record @@ -96,11 +145,16 @@ export function EditConnectorModal({ * manual input), both field IDs get the same value so toggling preserves it. * Captured once on mount; editing state is owned by the hook afterward. */ - const [initialSourceConfig] = useState>(() => { - const config: Record = {} + const [initialSourceConfig] = useState(() => { + const config: ConfigFieldMap = {} if (!connectorConfig) { for (const [key, value] of Object.entries(connector.sourceConfig)) { - if (!INTERNAL_CONFIG_KEYS.has(key)) config[key] = String(value ?? '') + if (INTERNAL_CONFIG_KEYS.has(key)) continue + if (Array.isArray(value)) { + config[key] = value.filter((v): v is string => typeof v === 'string') + } else { + config[key] = String(value ?? '') + } } return config } @@ -108,7 +162,21 @@ export function EditConnectorModal({ const canonicalId = field.canonicalParamId ?? field.id if (INTERNAL_CONFIG_KEYS.has(canonicalId)) continue const rawValue = connector.sourceConfig[canonicalId] - if (rawValue !== undefined) config[field.id] = String(rawValue ?? '') + if (rawValue === undefined) continue + if (field.multi) { + if (Array.isArray(rawValue)) { + config[field.id] = rawValue.filter((v): v is string => typeof v === 'string') + } else if (typeof rawValue === 'string') { + config[field.id] = rawValue + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } else { + config[field.id] = [] + } + } else { + config[field.id] = String(rawValue ?? '') + } } return config }) @@ -147,7 +215,7 @@ export function EditConnectorModal({ if (didCanonicalModesChange(canonicalModes, persistedCanonicalModes)) return true const resolved = resolveSourceConfig() for (const [key, value] of Object.entries(resolved)) { - if (String(connector.sourceConfig[key] ?? '') !== value) return true + if (!valuesEqual(connector.sourceConfig[key], value)) return true } return false }, [ @@ -169,9 +237,9 @@ export function EditConnectorModal({ } const resolved = resolveSourceConfig() - const changedEntries: Record = {} + const changedEntries: Record = {} for (const [key, value] of Object.entries(resolved)) { - if (String(connector.sourceConfig[key] ?? '') !== value) changedEntries[key] = value + if (!valuesEqual(connector.sourceConfig[key], value)) changedEntries[key] = value } const modesChanged = didCanonicalModesChange(canonicalModes, persistedCanonicalModes) @@ -276,12 +344,12 @@ export function EditConnectorModal({ interface SettingsTabProps { connectorConfig: ConnectorConfig | null - sourceConfig: Record + sourceConfig: ConfigFieldMap credentialId: string | null canonicalGroups: Map canonicalModes: Record onToggleCanonicalMode: (canonicalId: string) => void - onFieldChange: (fieldId: string, value: string) => void + onFieldChange: (fieldId: string, value: ConfigFieldValue) => void isFieldVisible: (field: ConnectorConfigField) => boolean syncInterval: number setSyncInterval: (v: number) => void @@ -344,8 +412,8 @@ function SettingsTab({ {field.type === 'selector' && field.selectorKey ? ( onFieldChange(field.id, value)} + value={sourceConfig[field.id] ?? (field.multi ? [] : '')} + onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)} credentialId={credentialId} sourceConfig={sourceConfig} configFields={connectorConfig.configFields} @@ -359,13 +427,21 @@ function SettingsTab({ label: opt.label, value: opt.id, }))} - value={sourceConfig[field.id] || undefined} + value={ + typeof sourceConfig[field.id] === 'string' + ? (sourceConfig[field.id] as string) || undefined + : undefined + } onChange={(value) => onFieldChange(field.id, value)} placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} /> ) : ( onFieldChange(field.id, e.target.value)} placeholder={field.placeholder} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts index 8419b749602..737c96caced 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts @@ -4,42 +4,75 @@ import { useCallback, useMemo, useState } from 'react' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' +export type ConfigFieldValue = string | string[] +export type ConfigFieldMap = Record + export interface UseConnectorConfigFieldsOptions { connectorConfig: ConnectorConfig | null - initialSourceConfig?: Record + initialSourceConfig?: ConfigFieldMap initialCanonicalModes?: Record } export interface UseConnectorConfigFieldsResult { - sourceConfig: Record - setSourceConfig: React.Dispatch>> + sourceConfig: ConfigFieldMap + setSourceConfig: React.Dispatch> canonicalModes: Record setCanonicalModes: React.Dispatch>> canonicalGroups: Map isFieldVisible: (field: ConnectorConfigField) => boolean - handleFieldChange: (fieldId: string, value: string) => void + isFieldPopulated: (field: ConnectorConfigField) => boolean + handleFieldChange: (fieldId: string, value: ConfigFieldValue) => void toggleCanonicalMode: (canonicalId: string) => void - resolveSourceConfig: () => Record + resolveSourceConfig: () => Record +} + +function isMultiField(field: ConnectorConfigField | undefined): boolean { + return Boolean(field?.multi) +} + +function emptyValue(field: ConnectorConfigField | undefined): ConfigFieldValue { + return isMultiField(field) ? [] : '' +} + +/** + * Coerces a stored value to the shape expected by the field (string vs string[]). + * Multi fields accept either a string[] or a CSV string from advanced mode. + */ +function coerceForField(field: ConnectorConfigField, raw: unknown): ConfigFieldValue { + if (isMultiField(field)) { + if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string') + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return [] + return trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + return [] + } + if (Array.isArray(raw)) { + return raw.filter((v): v is string => typeof v === 'string').join(',') + } + return raw == null ? '' : String(raw) +} + +function isValuePopulated(value: ConfigFieldValue): boolean { + if (Array.isArray(value)) return value.length > 0 + return value.trim().length > 0 } /** * Shared state and helpers for connector configuration fields that support - * canonical pairs (selector + manual input sharing a `canonicalParamId`). - * - * - Tracks current field values and active mode (basic/advanced) per canonical group. - * - Computes the dependency graph including canonical-sibling expansion so that - * changing a dependency clears both siblings of any dependent canonical pair. - * - Returns `resolveSourceConfig` which collapses the per-field map back to a - * canonical-keyed object ready to submit. + * canonical pairs (selector + manual input sharing a `canonicalParamId`) and + * multi-value fields (selector or short-input with `multi: true`). */ export function useConnectorConfigFields({ connectorConfig, initialSourceConfig, initialCanonicalModes, }: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult { - const [sourceConfig, setSourceConfig] = useState>( - () => initialSourceConfig ?? {} - ) + const [sourceConfig, setSourceConfig] = useState(() => initialSourceConfig ?? {}) const [canonicalModes, setCanonicalModes] = useState>( () => initialCanonicalModes ?? {} ) @@ -56,6 +89,13 @@ export function useConnectorConfigFields({ return groups }, [connectorConfig]) + const fieldsById = useMemo(() => { + const map = new Map() + if (!connectorConfig) return map + for (const field of connectorConfig.configFields) map.set(field.id, field) + return map + }, [connectorConfig]) + const dependentFieldIds = useMemo(() => { const result = new Map() if (!connectorConfig) return result @@ -104,12 +144,18 @@ export function useConnectorConfigFields({ [canonicalModes] ) - const handleFieldChange = (fieldId: string, value: string) => { + const isFieldPopulated = useCallback( + (field: ConnectorConfigField): boolean => + isValuePopulated(sourceConfig[field.id] ?? emptyValue(field)), + [sourceConfig] + ) + + const handleFieldChange = (fieldId: string, value: ConfigFieldValue) => { setSourceConfig((prev) => { - const next = { ...prev, [fieldId]: value } + const next: ConfigFieldMap = { ...prev, [fieldId]: value } const toClear = dependentFieldIds.get(fieldId) if (toClear) { - for (const depId of toClear) next[depId] = '' + for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId)) } return next }) @@ -122,8 +168,8 @@ export function useConnectorConfigFields({ })) } - const resolveSourceConfig = useCallback((): Record => { - const resolved: Record = {} + const resolveSourceConfig = useCallback((): Record => { + const resolved: Record = {} const processed = new Set() if (!connectorConfig) return resolved @@ -135,9 +181,11 @@ export function useConnectorConfigFields({ if (!group) continue const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' const activeField = group.find((f) => f.mode === activeMode) ?? group[0] - resolved[field.canonicalParamId] = sourceConfig[activeField.id] ?? '' + const raw = sourceConfig[activeField.id] ?? emptyValue(activeField) + resolved[field.canonicalParamId] = coerceForField(activeField, raw) } else { - resolved[field.id] = sourceConfig[field.id] ?? '' + const raw = sourceConfig[field.id] ?? emptyValue(field) + resolved[field.id] = coerceForField(field, raw) } } return resolved @@ -150,6 +198,7 @@ export function useConnectorConfigFields({ setCanonicalModes, canonicalGroups, isFieldVisible, + isFieldPopulated, handleFieldChange, toggleCanonicalMode, resolveSourceConfig, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts index 74f504d75a1..66470aec8d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants.ts @@ -1,2 +1 @@ -export const WORKFLOW_SEARCH_HIGHLIGHT_CLASS = - 'rounded-sm bg-orange-400 shadow-[3px_0_0_#fb923c,-3px_0_0_#fb923c]' +export const WORKFLOW_SEARCH_HIGHLIGHT_CLASS = 'rounded-sm bg-orange-400 font-normal text-inherit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx index 3b524cf90a5..401ee1f57a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx @@ -1,6 +1,9 @@ import { Info } from 'lucide-react' import { Checkbox, Label, Tooltip } from '@/components/emcn' +import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' +import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import type { ActiveSearchTarget } from '@/stores/panel/editor/store' interface CheckboxListOption { label: string @@ -11,18 +14,23 @@ interface CheckboxListOption { interface CheckboxListProps { blockId: string + subBlockId: string options: CheckboxListOption[] isPreview?: boolean subBlockValues?: Record disabled?: boolean + activeSearchTarget?: ActiveSearchTarget | null } interface CheckboxItemProps { blockId: string + subBlockId: string option: CheckboxListOption + index: number isPreview: boolean subBlockValues?: Record disabled: boolean + activeSearchTarget?: ActiveSearchTarget | null } /** @@ -33,8 +41,24 @@ interface CheckboxItemProps { * case we fall back to `option.defaultChecked` for the displayed state. Any * explicit boolean (including `false`) takes precedence over the default. */ -function CheckboxItem({ blockId, option, isPreview, subBlockValues, disabled }: CheckboxItemProps) { +function CheckboxItem({ + blockId, + subBlockId, + option, + index, + isPreview, + subBlockValues, + disabled, + activeSearchTarget, +}: CheckboxItemProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, option.id) + const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ + activeSearchTarget, + blockId, + subBlockId, + valuePath: ['options', index], + label: option.label, + }) const previewValue = isPreview && subBlockValues ? subBlockValues[option.id]?.value : undefined const rawValue = isPreview ? previewValue : storeValue @@ -58,7 +82,7 @@ function CheckboxItem({ blockId, option, isPreview, subBlockValues, disabled }: htmlFor={`${blockId}-${option.id}`} className='cursor-pointer font-medium font-sans text-[var(--text-primary)] text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50' > - {option.label} + {formatDisplayText(option.label, { workflowSearchHighlight })} {option.description && ( @@ -76,21 +100,26 @@ function CheckboxItem({ blockId, option, isPreview, subBlockValues, disabled }: export function CheckboxList({ blockId, + subBlockId, options, isPreview = false, subBlockValues, disabled = false, + activeSearchTarget, }: CheckboxListProps) { return (
- {options.map((option) => ( + {options.map((option, index) => ( ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index ca4ec57afb1..526c0bc147d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -21,14 +21,20 @@ import { SYSTEM_REFERENCE_PREFIXES, splitReferenceSegment, } from '@/lib/workflows/sanitization/references' +import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants' import { checkEnvVarTrigger, EnvVarDropdown, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + getValidWorkflowSearchRange, + type WorkflowSearchTextHighlight, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { checkTagTrigger, TagDropdown, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils' @@ -41,6 +47,7 @@ import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/re import { useTagSelection } from '@/hooks/kb/use-tag-selection' import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' import { useCodeUndoRedo } from '@/hooks/use-code-undo-redo' +import type { ActiveSearchTarget } from '@/stores/panel/editor/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('Code') @@ -80,6 +87,16 @@ const applyDarkModeTokenStyling = (highlightedCode: string): string => { return highlightedCode } +const WORKFLOW_SEARCH_MATCH_PLACEHOLDER = '__WORKFLOW_SEARCH_MATCH__' + +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + /** * Type definition for code placeholders during syntax highlighting. */ @@ -99,11 +116,20 @@ interface CodePlaceholder { const createHighlightFunction = ( effectiveLanguage: 'javascript' | 'python' | 'json', shouldHighlightReference: (part: string) => boolean, - shouldHighlightEnvVar: (varName: string) => boolean + shouldHighlightEnvVar: (varName: string) => boolean, + workflowSearchHighlight?: WorkflowSearchTextHighlight | null ) => { return (codeToHighlight: string): string => { const placeholders: CodePlaceholder[] = [] let processedCode = codeToHighlight + const workflowSearchRange = getValidWorkflowSearchRange( + codeToHighlight, + workflowSearchHighlight + ) + + if (workflowSearchRange) { + processedCode = `${codeToHighlight.slice(0, workflowSearchRange.start)}${WORKFLOW_SEARCH_MATCH_PLACEHOLDER}${codeToHighlight.slice(workflowSearchRange.end)}` + } processedCode = processedCode.replace(createEnvVarPattern(), (match) => { const varName = match.slice(2, -2).trim() @@ -144,6 +170,14 @@ const createHighlightFunction = ( } }) + if (workflowSearchRange) { + const matchText = codeToHighlight.slice(workflowSearchRange.start, workflowSearchRange.end) + highlightedCode = highlightedCode.replace( + WORKFLOW_SEARCH_MATCH_PLACEHOLDER, + `${escapeHtml(matchText)}` + ) + } + return highlightedCode } } @@ -178,6 +212,7 @@ interface CodeProps { wandControlRef?: React.MutableRefObject /** Whether to hide the internal wand button (controlled by parent) */ hideInternalWand?: boolean + activeSearchTarget?: ActiveSearchTarget | null } export const Code = memo(function Code({ @@ -197,6 +232,7 @@ export const Code = memo(function Code({ wandConfig, wandControlRef, hideInternalWand = false, + activeSearchTarget, }: CodeProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -641,11 +677,21 @@ export const Code = memo(function Code({ () => createShouldHighlightEnvVar(availableEnvVars), [availableEnvVars] ) + const workflowSearchHighlight = getActiveWorkflowSearchHighlight({ + activeSearchTarget, + subBlockId, + valuePath: [], + }) const highlightCode = useMemo( () => - createHighlightFunction(effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar), - [effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar] + createHighlightFunction( + effectiveLanguage, + shouldHighlightReference, + shouldHighlightEnvVar, + workflowSearchHighlight + ), + [effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar, workflowSearchHighlight] ) const handleValueChange = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 51a1b1b40dc..e802667aa4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller' +import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { getBlock } from '@/blocks/registry' @@ -15,6 +16,7 @@ import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { usePermissionConfig } from '@/hooks/use-permission-config' import { getProviderFromModel } from '@/providers/utils' +import type { ActiveSearchTarget } from '@/stores/panel/editor/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -68,6 +70,7 @@ interface ComboBoxProps { ) => Promise<{ label: string; id: string } | null> /** Field dependencies that trigger option refetch when changed */ dependsOn?: SubBlockConfig['dependsOn'] + activeSearchTarget?: ActiveSearchTarget | null } export const ComboBox = memo(function ComboBox({ @@ -84,6 +87,7 @@ export const ComboBox = memo(function ComboBox({ fetchOptions, fetchOptionById, dependsOn, + activeSearchTarget, }: ComboBoxProps) { // Hooks and context const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) @@ -449,6 +453,13 @@ export const ComboBox = memo(function ComboBox({ const overlayContent = useMemo(() => { const SelectedIcon = selectedOptionIcon const displayLabel = inputValue + const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ + activeSearchTarget, + blockId, + subBlockId, + valuePath: [], + label: displayLabel, + }) return (
{SelectedIcon && } @@ -456,11 +467,12 @@ export const ComboBox = memo(function ComboBox({ {formatDisplayText(displayLabel, { accessiblePrefixes, highlightAll: !accessiblePrefixes, + workflowSearchHighlight, })}
) - }, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon]) + }, [activeSearchTarget, blockId, inputValue, accessiblePrefixes, selectedOptionIcon, subBlockId]) const ctrlOnChangeRef = useRef< ((e: React.ChangeEvent) => void) | null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index b6e173949bb..edcebec5324 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -21,14 +21,21 @@ import { SYSTEM_REFERENCE_PREFIXES, splitReferenceSegment, } from '@/lib/workflows/sanitization/references' +import { WORKFLOW_SEARCH_HIGHLIGHT_CLASS } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/constants' import { checkEnvVarTrigger, EnvVarDropdown, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + formatDisplayText, + getValidWorkflowSearchRange, + type WorkflowSearchTextHighlight, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { checkTagTrigger, TagDropdown, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' @@ -36,6 +43,7 @@ import { normalizeName } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import { useTagSelection } from '@/hooks/kb/use-tag-selection' import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' +import type { ActiveSearchTarget } from '@/stores/panel/editor/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('ConditionInput') @@ -49,6 +57,15 @@ const ROUTER_DEFAULT_HEIGHT_PX = 100 * Minimum height for router textareas in pixels */ const ROUTER_MIN_HEIGHT_PX = 80 +const WORKFLOW_SEARCH_MATCH_PLACEHOLDER = '__WORKFLOW_SEARCH_MATCH__' + +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') /** * Represents a single conditional block (if/else if/else). @@ -88,6 +105,7 @@ interface ConditionInputProps { disabled?: boolean /** Mode: 'condition' for code editor, 'router' for text input */ mode?: 'condition' | 'router' + activeSearchTarget?: ActiveSearchTarget | null } /** @@ -116,6 +134,7 @@ export function ConditionInput({ previewValue, disabled = false, mode = 'condition', + activeSearchTarget, }: ConditionInputProps) { const isRouterMode = mode === 'router' const params = useParams() @@ -132,6 +151,7 @@ export function ConditionInput({ const containerRef = useRef(null) const inputRefs = useRef>(new Map()) + const overlayRefs = useRef>(new Map()) /** * Determines if a reference string should be highlighted in the editor. @@ -169,6 +189,68 @@ export function ConditionInput({ return accessiblePrefixes.has(normalizedPrefix) } + + const createHighlightFunction = ( + workflowSearchHighlight?: WorkflowSearchTextHighlight | null + ) => { + return (codeToHighlight: string) => { + const placeholders: { + placeholder: string + original: string + type: 'var' | 'env' + shouldHighlight: boolean + }[] = [] + let processedCode = codeToHighlight + const workflowSearchRange = getValidWorkflowSearchRange( + codeToHighlight, + workflowSearchHighlight + ) + + if (workflowSearchRange) { + processedCode = `${codeToHighlight.slice(0, workflowSearchRange.start)}${WORKFLOW_SEARCH_MATCH_PLACEHOLDER}${codeToHighlight.slice(workflowSearchRange.end)}` + } + + processedCode = processedCode.replace(createEnvVarPattern(), (match) => { + const varName = match.slice(2, -2).trim() + if (shouldHighlightEnvVar(varName)) { + const placeholder = `__ENV_VAR_${placeholders.length}__` + placeholders.push({ placeholder, original: match, type: 'env', shouldHighlight: true }) + return placeholder + } + return match + }) + + processedCode = processedCode.replace(createReferencePattern(), (match) => { + const shouldHighlight = shouldHighlightReference(match) + if (shouldHighlight) { + const placeholder = `__VAR_REF_${placeholders.length}__` + placeholders.push({ placeholder, original: match, type: 'var', shouldHighlight: true }) + return placeholder + } + return match + }) + + let highlightedCode = highlight(processedCode, languages.javascript, 'javascript') + + placeholders.forEach(({ placeholder, original, type, shouldHighlight }) => { + if (!shouldHighlight) return + + highlightedCode = highlightedCode.replace( + placeholder, + `${type === 'env' ? original : escapeHtml(original)}` + ) + }) + + if (workflowSearchRange) { + highlightedCode = highlightedCode.replace( + WORKFLOW_SEARCH_MATCH_PLACEHOLDER, + `${escapeHtml(codeToHighlight.slice(workflowSearchRange.start, workflowSearchRange.end))}` + ) + } + + return highlightedCode + } + } const [visualLineHeights, setVisualLineHeights] = useState<{ [key: string]: number[] }>({}) @@ -819,9 +901,13 @@ export function ConditionInput({ // Update the textarea height directly for smooth resizing const textarea = inputRefs.current.get(blockId) + const overlay = overlayRefs.current.get(blockId) if (textarea) { textarea.style.height = `${newHeight}px` } + if (overlay) { + overlay.style.height = `${newHeight}px` + } // Update state to keep track setRouterHeights((prev) => ({ ...prev, [blockId]: newHeight })) @@ -848,448 +934,425 @@ export function ConditionInput({ return (
- {conditionalBlocks.map((block, index) => ( -
+ {conditionalBlocks.map((block, index) => { + const workflowSearchHighlight = getActiveWorkflowSearchHighlight({ + activeSearchTarget, + blockId, + subBlockId, + valuePath: [index, 'value'], + }) + return (
- - {isRouterMode ? `Route ${index + 1}` : block.title} - -
- - - - - Add Block - - - - - - - Move Up - - - - - - - Move Down - - - - - - - - {isRouterMode ? 'Delete Route' : 'Delete Condition'} - - -
-
- {/* Router mode: show description textarea with tag/env var support */} - {isRouterMode && (
e.preventDefault()} - onDrop={(e) => handleDrop(block.id, e)} + className={cn( + 'flex items-center justify-between overflow-hidden bg-transparent px-2.5 py-[5px]', + isRouterMode + ? 'rounded-t-[4px] border-[var(--border-1)] border-b' + : block.title === 'else' + ? 'rounded-sm border-0' + : 'rounded-t-[4px] border-[var(--border-1)] border-b' + )} > -