diff --git a/apps/sim/blocks/blocks/salesforce.ts b/apps/sim/blocks/blocks/salesforce.ts index 3843777ddf..b6c9a01826 100644 --- a/apps/sim/blocks/blocks/salesforce.ts +++ b/apps/sim/blocks/blocks/salesforce.ts @@ -44,6 +44,17 @@ export const SalesforceBlock: BlockConfig = { { label: 'Create Task', id: 'create_task' }, { label: 'Update Task', id: 'update_task' }, { label: 'Delete Task', id: 'delete_task' }, + { label: 'List Reports', id: 'list_reports' }, + { label: 'Get Report', id: 'get_report' }, + { label: 'Run Report', id: 'run_report' }, + { label: 'List Report Types', id: 'list_report_types' }, + { label: 'List Dashboards', id: 'list_dashboards' }, + { label: 'Get Dashboard', id: 'get_dashboard' }, + { label: 'Refresh Dashboard', id: 'refresh_dashboard' }, + { label: 'Run SOQL Query', id: 'query' }, + { label: 'Get More Query Results', id: 'query_more' }, + { label: 'Describe Object', id: 'describe_object' }, + { label: 'List Objects', id: 'list_objects' }, ], value: () => 'get_accounts', }, @@ -52,7 +63,7 @@ export const SalesforceBlock: BlockConfig = { title: 'Salesforce Account', type: 'oauth-input', serviceId: 'salesforce', - requiredScopes: ['api', 'refresh_token', 'openid'], + requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'], placeholder: 'Select Salesforce account', required: true, }, @@ -367,6 +378,77 @@ export const SalesforceBlock: BlockConfig = { placeholder: 'Account or Opportunity ID', condition: { field: 'operation', value: ['create_task'] }, }, + // Report fields + { + id: 'reportId', + title: 'Report ID', + type: 'short-input', + placeholder: 'Report ID', + condition: { field: 'operation', value: ['get_report', 'run_report'] }, + required: true, + }, + { + id: 'folderName', + title: 'Folder Name', + type: 'short-input', + placeholder: 'Filter by folder name', + condition: { field: 'operation', value: ['list_reports', 'list_dashboards'] }, + }, + { + id: 'searchTerm', + title: 'Search Term', + type: 'short-input', + placeholder: 'Search reports by name', + condition: { field: 'operation', value: ['list_reports'] }, + }, + { + id: 'includeDetails', + title: 'Include Details', + type: 'short-input', + placeholder: 'Include detail rows (true/false)', + condition: { field: 'operation', value: ['run_report'] }, + }, + { + id: 'filters', + title: 'Report Filters', + type: 'long-input', + placeholder: 'JSON array of report filters', + condition: { field: 'operation', value: ['run_report'] }, + }, + // Dashboard fields + { + id: 'dashboardId', + title: 'Dashboard ID', + type: 'short-input', + placeholder: 'Dashboard ID', + condition: { field: 'operation', value: ['get_dashboard', 'refresh_dashboard'] }, + required: true, + }, + // Query fields + { + id: 'query', + title: 'SOQL Query', + type: 'long-input', + placeholder: 'SELECT Id, Name FROM Account LIMIT 10', + condition: { field: 'operation', value: ['query'] }, + required: true, + }, + { + id: 'nextRecordsUrl', + title: 'Next Records URL', + type: 'short-input', + placeholder: 'URL from previous query response', + condition: { field: 'operation', value: ['query_more'] }, + required: true, + }, + { + id: 'objectName', + title: 'Object Name', + type: 'short-input', + placeholder: 'API name (e.g., Account, Lead, Custom_Object__c)', + condition: { field: 'operation', value: ['describe_object'] }, + required: true, + }, // Long-input fields at the bottom { id: 'description', @@ -418,6 +500,17 @@ export const SalesforceBlock: BlockConfig = { 'salesforce_create_task', 'salesforce_update_task', 'salesforce_delete_task', + 'salesforce_list_reports', + 'salesforce_get_report', + 'salesforce_run_report', + 'salesforce_list_report_types', + 'salesforce_list_dashboards', + 'salesforce_get_dashboard', + 'salesforce_refresh_dashboard', + 'salesforce_query', + 'salesforce_query_more', + 'salesforce_describe_object', + 'salesforce_list_objects', ], config: { tool: (params) => { @@ -470,6 +563,28 @@ export const SalesforceBlock: BlockConfig = { return 'salesforce_update_task' case 'delete_task': return 'salesforce_delete_task' + case 'list_reports': + return 'salesforce_list_reports' + case 'get_report': + return 'salesforce_get_report' + case 'run_report': + return 'salesforce_run_report' + case 'list_report_types': + return 'salesforce_list_report_types' + case 'list_dashboards': + return 'salesforce_list_dashboards' + case 'get_dashboard': + return 'salesforce_get_dashboard' + case 'refresh_dashboard': + return 'salesforce_refresh_dashboard' + case 'query': + return 'salesforce_query' + case 'query_more': + return 'salesforce_query_more' + case 'describe_object': + return 'salesforce_describe_object' + case 'list_objects': + return 'salesforce_list_objects' default: throw new Error(`Unknown operation: ${params.operation}`) } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 186912f467..a600f31313 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -98,6 +98,32 @@ export const auth = betterAuth({ }, }, }, + account: { + create: { + after: async (account) => { + // Salesforce doesn't return expires_in in its token response (unlike other OAuth providers). + // We set a default 2-hour expiration so token refresh logic works correctly. + if (account.providerId === 'salesforce' && !account.accessTokenExpiresAt) { + const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000) + try { + await db + .update(schema.account) + .set({ accessTokenExpiresAt: twoHoursFromNow }) + .where(eq(schema.account.id, account.id)) + logger.info( + '[databaseHooks.account.create.after] Set default expiration for Salesforce token', + { accountId: account.id, expiresAt: twoHoursFromNow } + ) + } catch (error) { + logger.error( + '[databaseHooks.account.create.after] Failed to set Salesforce token expiration', + { accountId: account.id, error } + ) + } + } + }, + }, + }, session: { create: { before: async (session) => { @@ -841,9 +867,10 @@ export const auth = betterAuth({ authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', - scopes: ['api', 'refresh_token', 'openid'], + scopes: ['api', 'refresh_token', 'openid', 'offline_access'], pkce: true, prompt: 'consent', + accessType: 'offline', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`, getUserInfo: async (tokens) => { try { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 72badddeaa..3e4181aadf 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -840,7 +840,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'salesforce', icon: (props) => SalesforceIcon(props), baseProviderIcon: (props) => SalesforceIcon(props), - scopes: ['api', 'refresh_token', 'openid'], + scopes: ['api', 'refresh_token', 'openid', 'offline_access'], }, }, defaultService: 'salesforce', @@ -1427,7 +1427,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { clientId, clientSecret, useBasicAuth: false, - supportsRefreshTokenRotation: false, + supportsRefreshTokenRotation: true, } } case 'shopify': { @@ -1546,9 +1546,15 @@ export async function refreshOAuthToken( logger.error('Token refresh failed:', { status: response.status, + statusText: response.statusText, error: errorText, parsedError: errorData, providerId, + tokenEndpoint: config.tokenEndpoint, + hasClientId: !!config.clientId, + hasClientSecret: !!config.clientSecret, + hasRefreshToken: !!refreshToken, + refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none', }) throw new Error(`Failed to refresh token: ${response.status} ${errorText}`) } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 7d2d73738b..2f3930b3bf 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -928,12 +928,23 @@ import { salesforceDeleteLeadTool, salesforceDeleteOpportunityTool, salesforceDeleteTaskTool, + salesforceDescribeObjectTool, salesforceGetAccountsTool, salesforceGetCasesTool, salesforceGetContactsTool, + salesforceGetDashboardTool, salesforceGetLeadsTool, salesforceGetOpportunitiesTool, + salesforceGetReportTool, salesforceGetTasksTool, + salesforceListDashboardsTool, + salesforceListObjectsTool, + salesforceListReportsTool, + salesforceListReportTypesTool, + salesforceQueryMoreTool, + salesforceQueryTool, + salesforceRefreshDashboardTool, + salesforceRunReportTool, salesforceUpdateAccountTool, salesforceUpdateCaseTool, salesforceUpdateContactTool, @@ -2275,6 +2286,17 @@ export const tools: Record = { salesforce_create_task: salesforceCreateTaskTool, salesforce_update_task: salesforceUpdateTaskTool, salesforce_delete_task: salesforceDeleteTaskTool, + salesforce_list_reports: salesforceListReportsTool, + salesforce_get_report: salesforceGetReportTool, + salesforce_run_report: salesforceRunReportTool, + salesforce_list_report_types: salesforceListReportTypesTool, + salesforce_list_dashboards: salesforceListDashboardsTool, + salesforce_get_dashboard: salesforceGetDashboardTool, + salesforce_refresh_dashboard: salesforceRefreshDashboardTool, + salesforce_query: salesforceQueryTool, + salesforce_query_more: salesforceQueryMoreTool, + salesforce_describe_object: salesforceDescribeObjectTool, + salesforce_list_objects: salesforceListObjectsTool, pylon_list_issues: pylonListIssuesTool, pylon_create_issue: pylonCreateIssueTool, pylon_get_issue: pylonGetIssueTool, diff --git a/apps/sim/tools/salesforce/dashboards.ts b/apps/sim/tools/salesforce/dashboards.ts new file mode 100644 index 0000000000..20d61fd6fa --- /dev/null +++ b/apps/sim/tools/salesforce/dashboards.ts @@ -0,0 +1,258 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ToolConfig } from '@/tools/types' +import { extractErrorMessage, getInstanceUrl } from './utils' + +const logger = createLogger('SalesforceDashboards') + +export interface SalesforceListDashboardsParams { + accessToken: string + idToken?: string + instanceUrl?: string + folderName?: string +} + +/** + * List all dashboards accessible by the current user + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_getbasic_dashboardlist.htm + */ +export const salesforceListDashboardsTool: ToolConfig = { + id: 'salesforce_list_dashboards', + name: 'List Dashboards from Salesforce', + description: 'Get a list of dashboards accessible by the current user', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + folderName: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by folder name', + }, + }, + request: { + url: (params) => { + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/analytics/dashboards` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + 'Failed to list dashboards from Salesforce' + ) + logger.error('Failed to list dashboards', { data, status: response.status }) + throw new Error(errorMessage) + } + + let dashboards = data.dashboards || data || [] + + // Filter by folder name if provided + if (params.folderName) { + dashboards = dashboards.filter((dashboard: any) => + dashboard.folderName?.toLowerCase().includes(params.folderName.toLowerCase()) + ) + } + + return { + success: true, + output: { + dashboards, + metadata: { + operation: 'list_dashboards', + totalReturned: dashboards.length, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Dashboards data', + properties: { + dashboards: { type: 'array', description: 'Array of dashboard objects' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +export interface SalesforceGetDashboardParams { + accessToken: string + idToken?: string + instanceUrl?: string + dashboardId: string +} + +/** + * Get details for a specific dashboard + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_dashboard_results.htm + */ +export const salesforceGetDashboardTool: ToolConfig = { + id: 'salesforce_get_dashboard', + name: 'Get Dashboard from Salesforce', + description: 'Get details and results for a specific dashboard', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + dashboardId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Dashboard ID (required)', + }, + }, + request: { + url: (params) => { + if (!params.dashboardId || params.dashboardId.trim() === '') { + throw new Error('Dashboard ID is required. Please provide a valid Salesforce Dashboard ID.') + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/analytics/dashboards/${params.dashboardId}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + `Failed to get dashboard ID: ${params.dashboardId}` + ) + logger.error('Failed to get dashboard', { data, status: response.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + dashboard: data, + dashboardId: params.dashboardId, + components: data.componentData || [], + metadata: { + operation: 'get_dashboard', + dashboardName: data.name, + folderId: data.folderId, + runningUser: data.runningUser, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Dashboard data', + properties: { + dashboard: { type: 'object', description: 'Dashboard details' }, + dashboardId: { type: 'string', description: 'Dashboard ID' }, + components: { type: 'array', description: 'Dashboard component data' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +/** + * Refresh a dashboard to get latest data + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_refresh_dashboard.htm + */ +export const salesforceRefreshDashboardTool: ToolConfig = { + id: 'salesforce_refresh_dashboard', + name: 'Refresh Dashboard in Salesforce', + description: 'Refresh a dashboard to get the latest data', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + dashboardId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Dashboard ID (required)', + }, + }, + request: { + url: (params) => { + if (!params.dashboardId || params.dashboardId.trim() === '') { + throw new Error('Dashboard ID is required. Please provide a valid Salesforce Dashboard ID.') + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/analytics/dashboards/${params.dashboardId}` + }, + method: 'PUT', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + `Failed to refresh dashboard ID: ${params.dashboardId}` + ) + logger.error('Failed to refresh dashboard', { data, status: response.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + dashboard: data, + dashboardId: params.dashboardId, + components: data.componentData || [], + status: data.status, + metadata: { + operation: 'refresh_dashboard', + dashboardName: data.name, + refreshDate: data.refreshDate, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Refreshed dashboard data', + properties: { + dashboard: { type: 'object', description: 'Dashboard details' }, + dashboardId: { type: 'string', description: 'Dashboard ID' }, + components: { type: 'array', description: 'Dashboard component data' }, + status: { type: 'object', description: 'Dashboard status' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} diff --git a/apps/sim/tools/salesforce/index.ts b/apps/sim/tools/salesforce/index.ts index cbe2958df8..58071d91af 100644 --- a/apps/sim/tools/salesforce/index.ts +++ b/apps/sim/tools/salesforce/index.ts @@ -11,6 +11,11 @@ export { salesforceUpdateContactTool, } from './contacts' export { salesforceCreateAccountTool } from './create_account' +export { + salesforceGetDashboardTool, + salesforceListDashboardsTool, + salesforceRefreshDashboardTool, +} from './dashboards' export { salesforceDeleteAccountTool } from './delete_account' export { salesforceGetAccountsTool } from './get_accounts' export { @@ -25,6 +30,18 @@ export { salesforceGetOpportunitiesTool, salesforceUpdateOpportunityTool, } from './opportunities' +export { + salesforceDescribeObjectTool, + salesforceListObjectsTool, + salesforceQueryMoreTool, + salesforceQueryTool, +} from './query' +export { + salesforceGetReportTool, + salesforceListReportsTool, + salesforceListReportTypesTool, + salesforceRunReportTool, +} from './reports' export { salesforceCreateTaskTool, salesforceDeleteTaskTool, diff --git a/apps/sim/tools/salesforce/query.ts b/apps/sim/tools/salesforce/query.ts new file mode 100644 index 0000000000..fbf2954236 --- /dev/null +++ b/apps/sim/tools/salesforce/query.ts @@ -0,0 +1,366 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ToolConfig } from '@/tools/types' +import { extractErrorMessage, getInstanceUrl } from './utils' + +const logger = createLogger('SalesforceQuery') + +export interface SalesforceQueryParams { + accessToken: string + idToken?: string + instanceUrl?: string + query: string +} + +/** + * Execute a custom SOQL query + * @see https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm + */ +export const salesforceQueryTool: ToolConfig = { + id: 'salesforce_query', + name: 'Run SOQL Query in Salesforce', + description: 'Execute a custom SOQL query to retrieve data from Salesforce', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + query: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SOQL query to execute (e.g., SELECT Id, Name FROM Account LIMIT 10)', + }, + }, + request: { + url: (params) => { + if (!params.query || params.query.trim() === '') { + throw new Error( + 'SOQL Query is required. Please provide a valid SOQL query (e.g., SELECT Id, Name FROM Account LIMIT 10).' + ) + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + const encodedQuery = encodeURIComponent(params.query) + return `${instanceUrl}/services/data/v59.0/query?q=${encodedQuery}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + 'Failed to execute SOQL query' + ) + logger.error('Failed to execute SOQL query', { data, status: response.status }) + throw new Error(errorMessage) + } + + const records = data.records || [] + + return { + success: true, + output: { + records, + totalSize: data.totalSize || records.length, + done: data.done !== false, + nextRecordsUrl: data.nextRecordsUrl, + query: params.query, + metadata: { + operation: 'query', + totalReturned: records.length, + hasMore: !data.done, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Query results', + properties: { + records: { type: 'array', description: 'Array of record objects' }, + totalSize: { type: 'number', description: 'Total number of records matching query' }, + done: { type: 'boolean', description: 'Whether all records have been returned' }, + nextRecordsUrl: { type: 'string', description: 'URL to fetch next batch of records' }, + query: { type: 'string', description: 'The executed SOQL query' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +export interface SalesforceQueryMoreParams { + accessToken: string + idToken?: string + instanceUrl?: string + nextRecordsUrl: string +} + +/** + * Retrieve additional query results using the nextRecordsUrl + * @see https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm + */ +export const salesforceQueryMoreTool: ToolConfig = { + id: 'salesforce_query_more', + name: 'Get More Query Results from Salesforce', + description: 'Retrieve additional query results using the nextRecordsUrl from a previous query', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + nextRecordsUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The nextRecordsUrl from a previous query response', + }, + }, + request: { + url: (params) => { + if (!params.nextRecordsUrl || params.nextRecordsUrl.trim() === '') { + throw new Error( + 'Next Records URL is required. This should be the nextRecordsUrl value from a previous query response.' + ) + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + // nextRecordsUrl is typically a relative path like /services/data/v59.0/query/01g... + const nextUrl = params.nextRecordsUrl.startsWith('/') + ? params.nextRecordsUrl + : `/${params.nextRecordsUrl}` + return `${instanceUrl}${nextUrl}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + 'Failed to get more query results' + ) + logger.error('Failed to get more query results', { data, status: response.status }) + throw new Error(errorMessage) + } + + const records = data.records || [] + + return { + success: true, + output: { + records, + totalSize: data.totalSize || records.length, + done: data.done !== false, + nextRecordsUrl: data.nextRecordsUrl, + metadata: { + operation: 'query_more', + totalReturned: records.length, + hasMore: !data.done, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Query results', + properties: { + records: { type: 'array', description: 'Array of record objects' }, + totalSize: { type: 'number', description: 'Total number of records matching query' }, + done: { type: 'boolean', description: 'Whether all records have been returned' }, + nextRecordsUrl: { type: 'string', description: 'URL to fetch next batch of records' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +export interface SalesforceDescribeObjectParams { + accessToken: string + idToken?: string + instanceUrl?: string + objectName: string +} + +/** + * Describe a Salesforce object to get its metadata/fields + * Useful for discovering available fields for queries + */ +export const salesforceDescribeObjectTool: ToolConfig = { + id: 'salesforce_describe_object', + name: 'Describe Salesforce Object', + description: 'Get metadata and field information for a Salesforce object', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + objectName: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'API name of the object (e.g., Account, Contact, Lead, Custom_Object__c)', + }, + }, + request: { + url: (params) => { + if (!params.objectName || params.objectName.trim() === '') { + throw new Error( + 'Object Name is required. Please provide a valid Salesforce object API name (e.g., Account, Contact, Lead).' + ) + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/sobjects/${params.objectName}/describe` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + `Failed to describe object: ${params.objectName}` + ) + logger.error('Failed to describe object', { data, status: response.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + objectName: params.objectName, + label: data.label, + labelPlural: data.labelPlural, + fields: data.fields, + keyPrefix: data.keyPrefix, + queryable: data.queryable, + createable: data.createable, + updateable: data.updateable, + deletable: data.deletable, + childRelationships: data.childRelationships, + recordTypeInfos: data.recordTypeInfos, + metadata: { + operation: 'describe_object', + fieldCount: data.fields?.length || 0, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Object metadata', + properties: { + objectName: { type: 'string', description: 'API name of the object' }, + label: { type: 'string', description: 'Display label' }, + labelPlural: { type: 'string', description: 'Plural display label' }, + fields: { type: 'array', description: 'Array of field definitions' }, + keyPrefix: { type: 'string', description: 'ID prefix for this object type' }, + queryable: { type: 'boolean', description: 'Whether object can be queried' }, + createable: { type: 'boolean', description: 'Whether records can be created' }, + updateable: { type: 'boolean', description: 'Whether records can be updated' }, + deletable: { type: 'boolean', description: 'Whether records can be deleted' }, + childRelationships: { type: 'array', description: 'Child relationship definitions' }, + recordTypeInfos: { type: 'array', description: 'Record type information' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +/** + * List all available Salesforce objects + * Useful for discovering what objects are available + */ +export const salesforceListObjectsTool: ToolConfig = { + id: 'salesforce_list_objects', + name: 'List Salesforce Objects', + description: 'Get a list of all available Salesforce objects', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + }, + request: { + url: (params) => { + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/sobjects` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + 'Failed to list Salesforce objects' + ) + logger.error('Failed to list objects', { data, status: response.status }) + throw new Error(errorMessage) + } + + const objects = data.sobjects || [] + + return { + success: true, + output: { + objects, + encoding: data.encoding, + maxBatchSize: data.maxBatchSize, + metadata: { + operation: 'list_objects', + totalReturned: objects.length, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Objects list', + properties: { + objects: { type: 'array', description: 'Array of available Salesforce objects' }, + encoding: { type: 'string', description: 'Encoding used' }, + maxBatchSize: { type: 'number', description: 'Maximum batch size' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} diff --git a/apps/sim/tools/salesforce/reports.ts b/apps/sim/tools/salesforce/reports.ts new file mode 100644 index 0000000000..300f43ff5c --- /dev/null +++ b/apps/sim/tools/salesforce/reports.ts @@ -0,0 +1,378 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ToolConfig } from '@/tools/types' +import { extractErrorMessage, getInstanceUrl } from './utils' + +const logger = createLogger('SalesforceReports') + +export interface SalesforceListReportsParams { + accessToken: string + idToken?: string + instanceUrl?: string + folderName?: string + searchTerm?: string +} + +/** + * List all reports accessible by the current user + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_get_reportlist.htm + */ +export const salesforceListReportsTool: ToolConfig = { + id: 'salesforce_list_reports', + name: 'List Reports from Salesforce', + description: 'Get a list of reports accessible by the current user', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + folderName: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by folder name', + }, + searchTerm: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Search term to filter reports by name', + }, + }, + request: { + url: (params) => { + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/analytics/reports` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + 'Failed to list reports from Salesforce' + ) + logger.error('Failed to list reports', { data, status: response.status }) + throw new Error(errorMessage) + } + + let reports = data || [] + + // Filter by folder name if provided + if (params.folderName) { + reports = reports.filter((report: any) => + report.folderName?.toLowerCase().includes(params.folderName.toLowerCase()) + ) + } + + // Filter by search term if provided + if (params.searchTerm) { + reports = reports.filter( + (report: any) => + report.name?.toLowerCase().includes(params.searchTerm.toLowerCase()) || + report.description?.toLowerCase().includes(params.searchTerm?.toLowerCase()) + ) + } + + return { + success: true, + output: { + reports, + metadata: { + operation: 'list_reports', + totalReturned: reports.length, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Reports data', + properties: { + reports: { type: 'array', description: 'Array of report objects' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +export interface SalesforceGetReportParams { + accessToken: string + idToken?: string + instanceUrl?: string + reportId: string +} + +/** + * Get metadata for a specific report + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_get_reportmetadata.htm + */ +export const salesforceGetReportTool: ToolConfig = { + id: 'salesforce_get_report', + name: 'Get Report Metadata from Salesforce', + description: 'Get metadata and describe information for a specific report', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + reportId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Report ID (required)', + }, + }, + request: { + url: (params) => { + if (!params.reportId || params.reportId.trim() === '') { + throw new Error('Report ID is required. Please provide a valid Salesforce Report ID.') + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/analytics/reports/${params.reportId}/describe` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + `Failed to get report metadata for report ID: ${params.reportId}` + ) + logger.error('Failed to get report metadata', { data, status: response.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + report: data, + reportId: params.reportId, + metadata: { + operation: 'get_report', + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Report metadata', + properties: { + report: { type: 'object', description: 'Report metadata object' }, + reportId: { type: 'string', description: 'Report ID' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +export interface SalesforceRunReportParams { + accessToken: string + idToken?: string + instanceUrl?: string + reportId: string + includeDetails?: string + filters?: string +} + +/** + * Run a report and return the results + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_get_reportdata.htm + */ +export const salesforceRunReportTool: ToolConfig = { + id: 'salesforce_run_report', + name: 'Run Report in Salesforce', + description: 'Execute a report and retrieve the results', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + reportId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Report ID (required)', + }, + includeDetails: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Include detail rows (true/false, default: true)', + }, + filters: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'JSON string of report filters to apply', + }, + }, + request: { + url: (params) => { + if (!params.reportId || params.reportId.trim() === '') { + throw new Error('Report ID is required. Please provide a valid Salesforce Report ID.') + } + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + const includeDetails = params.includeDetails !== 'false' + return `${instanceUrl}/services/data/v59.0/analytics/reports/${params.reportId}?includeDetails=${includeDetails}` + }, + // Use GET for simple report runs, POST only when filters are provided + method: (params) => (params.filters ? 'POST' : 'GET'), + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + // Only send a body when filters are provided (POST request) + if (params.filters) { + try { + const filters = JSON.parse(params.filters) + return { reportMetadata: { reportFilters: filters } } + } catch (e) { + throw new Error( + `Invalid report filters JSON: ${e instanceof Error ? e.message : 'Parse error'}. Please provide a valid JSON array of filter objects.` + ) + } + } + // Return undefined for GET requests (no body) + return undefined as any + }, + }, + transformResponse: async (response, params) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + `Failed to run report ID: ${params.reportId}` + ) + logger.error('Failed to run report', { data, status: response.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + reportId: params.reportId, + reportMetadata: data.reportMetadata, + reportExtendedMetadata: data.reportExtendedMetadata, + factMap: data.factMap, + groupingsDown: data.groupingsDown, + groupingsAcross: data.groupingsAcross, + hasDetailRows: data.hasDetailRows, + allData: data.allData, + metadata: { + operation: 'run_report', + reportName: data.reportMetadata?.name, + reportFormat: data.reportMetadata?.reportFormat, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Report results', + properties: { + reportId: { type: 'string', description: 'Report ID' }, + reportMetadata: { type: 'object', description: 'Report metadata' }, + reportExtendedMetadata: { type: 'object', description: 'Extended metadata' }, + factMap: { type: 'object', description: 'Report data organized by groupings' }, + groupingsDown: { type: 'object', description: 'Row groupings' }, + groupingsAcross: { type: 'object', description: 'Column groupings' }, + hasDetailRows: { type: 'boolean', description: 'Whether report has detail rows' }, + allData: { type: 'boolean', description: 'Whether all data is returned' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} + +/** + * Get list of available report types + * @see https://developer.salesforce.com/docs/atlas.en-us.api_analytics.meta/api_analytics/sforce_analytics_rest_api_list_reporttypes.htm + */ +export const salesforceListReportTypesTool: ToolConfig = { + id: 'salesforce_list_report_types', + name: 'List Report Types from Salesforce', + description: 'Get a list of available report types', + version: '1.0.0', + oauth: { required: true, provider: 'salesforce' }, + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden' }, + idToken: { type: 'string', required: false, visibility: 'hidden' }, + instanceUrl: { type: 'string', required: false, visibility: 'hidden' }, + }, + request: { + url: (params) => { + const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl) + return `${instanceUrl}/services/data/v59.0/analytics/reportTypes` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + const errorMessage = extractErrorMessage( + data, + response.status, + 'Failed to list report types from Salesforce' + ) + logger.error('Failed to list report types', { data, status: response.status }) + throw new Error(errorMessage) + } + + return { + success: true, + output: { + reportTypes: data, + metadata: { + operation: 'list_report_types', + totalReturned: Array.isArray(data) ? data.length : 0, + }, + success: true, + }, + } + }, + outputs: { + success: { type: 'boolean', description: 'Success status' }, + output: { + type: 'object', + description: 'Report types data', + properties: { + reportTypes: { type: 'array', description: 'Array of report type objects' }, + metadata: { type: 'object', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + }, + }, +} diff --git a/apps/sim/tools/salesforce/types.ts b/apps/sim/tools/salesforce/types.ts index 6f195e7040..17cb41c04c 100644 --- a/apps/sim/tools/salesforce/types.ts +++ b/apps/sim/tools/salesforce/types.ts @@ -137,6 +137,178 @@ export interface SalesforceDeleteContactResponse { } } +// Report types +export interface SalesforceListReportsResponse { + success: boolean + output: { + reports: any[] + metadata: { + operation: 'list_reports' + totalReturned: number + } + success: boolean + } +} + +export interface SalesforceGetReportResponse { + success: boolean + output: { + report: any + reportId: string + metadata: { + operation: 'get_report' + } + success: boolean + } +} + +export interface SalesforceRunReportResponse { + success: boolean + output: { + reportId: string + reportMetadata: any + reportExtendedMetadata: any + factMap: any + groupingsDown: any + groupingsAcross: any + hasDetailRows: boolean + allData: boolean + metadata: { + operation: 'run_report' + reportName: string + reportFormat: string + } + success: boolean + } +} + +export interface SalesforceListReportTypesResponse { + success: boolean + output: { + reportTypes: any[] + metadata: { + operation: 'list_report_types' + totalReturned: number + } + success: boolean + } +} + +// Dashboard types +export interface SalesforceListDashboardsResponse { + success: boolean + output: { + dashboards: any[] + metadata: { + operation: 'list_dashboards' + totalReturned: number + } + success: boolean + } +} + +export interface SalesforceGetDashboardResponse { + success: boolean + output: { + dashboard: any + dashboardId: string + components: any[] + metadata: { + operation: 'get_dashboard' + dashboardName: string + folderId: string + runningUser: any + } + success: boolean + } +} + +export interface SalesforceRefreshDashboardResponse { + success: boolean + output: { + dashboard: any + dashboardId: string + components: any[] + status: any + metadata: { + operation: 'refresh_dashboard' + dashboardName: string + refreshDate: string + } + success: boolean + } +} + +// Query types +export interface SalesforceQueryResponse { + success: boolean + output: { + records: any[] + totalSize: number + done: boolean + nextRecordsUrl?: string + query: string + metadata: { + operation: 'query' + totalReturned: number + hasMore: boolean + } + success: boolean + } +} + +export interface SalesforceQueryMoreResponse { + success: boolean + output: { + records: any[] + totalSize: number + done: boolean + nextRecordsUrl?: string + metadata: { + operation: 'query_more' + totalReturned: number + hasMore: boolean + } + success: boolean + } +} + +export interface SalesforceDescribeObjectResponse { + success: boolean + output: { + objectName: string + label: string + labelPlural: string + fields: any[] + keyPrefix: string + queryable: boolean + createable: boolean + updateable: boolean + deletable: boolean + childRelationships: any[] + recordTypeInfos: any[] + metadata: { + operation: 'describe_object' + fieldCount: number + } + success: boolean + } +} + +export interface SalesforceListObjectsResponse { + success: boolean + output: { + objects: any[] + encoding: string + maxBatchSize: number + metadata: { + operation: 'list_objects' + totalReturned: number + } + success: boolean + } +} + // Generic Salesforce response type for the block export type SalesforceResponse = | SalesforceGetAccountsResponse @@ -147,4 +319,15 @@ export type SalesforceResponse = | SalesforceCreateContactResponse | SalesforceUpdateContactResponse | SalesforceDeleteContactResponse + | SalesforceListReportsResponse + | SalesforceGetReportResponse + | SalesforceRunReportResponse + | SalesforceListReportTypesResponse + | SalesforceListDashboardsResponse + | SalesforceGetDashboardResponse + | SalesforceRefreshDashboardResponse + | SalesforceQueryResponse + | SalesforceQueryMoreResponse + | SalesforceDescribeObjectResponse + | SalesforceListObjectsResponse | { success: boolean; output: any } // Generic for leads, opportunities, cases, tasks diff --git a/apps/sim/tools/salesforce/utils.ts b/apps/sim/tools/salesforce/utils.ts new file mode 100644 index 0000000000..74dbaeba30 --- /dev/null +++ b/apps/sim/tools/salesforce/utils.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('SalesforceUtils') + +/** + * Extracts Salesforce instance URL from ID token or uses provided instance URL + * @param idToken - The Salesforce ID token containing instance URL + * @param instanceUrl - Direct instance URL if provided + * @returns The Salesforce instance URL + * @throws Error if instance URL cannot be determined + */ +export function getInstanceUrl(idToken?: string, instanceUrl?: string): string { + if (instanceUrl) return instanceUrl + if (idToken) { + try { + const base64Url = idToken.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`) + .join('') + ) + const decoded = JSON.parse(jsonPayload) + if (decoded.profile) { + const match = decoded.profile.match(/^(https:\/\/[^/]+)/) + if (match) return match[1] + } else if (decoded.sub) { + const match = decoded.sub.match(/^(https:\/\/[^/]+)/) + if (match && match[1] !== 'https://login.salesforce.com') return match[1] + } + } catch (error) { + logger.error('Failed to decode Salesforce idToken', { error }) + } + } + throw new Error('Salesforce instance URL is required but not provided') +} + +/** + * Extracts a descriptive error message from Salesforce API responses + * @param data - The response data from Salesforce API + * @param status - HTTP status code + * @param defaultMessage - Default message to use if no specific error found + * @returns Formatted error message + */ +export function extractErrorMessage(data: any, status: number, defaultMessage: string): string { + if (Array.isArray(data) && data[0]?.message) { + return `Salesforce API Error (${status}): ${data[0].message}${data[0].errorCode ? ` [${data[0].errorCode}]` : ''}` + } + if (data?.message) { + return `Salesforce API Error (${status}): ${data.message}` + } + if (data?.error) { + return `Salesforce API Error (${status}): ${data.error}${data.error_description ? ` - ${data.error_description}` : ''}` + } + switch (status) { + case 400: + return `Salesforce API Error (400): Bad Request - The request was malformed or missing required parameters` + case 401: + return `Salesforce API Error (401): Unauthorized - Invalid or expired access token. Please re-authenticate.` + case 403: + return `Salesforce API Error (403): Forbidden - You do not have permission to access this resource.` + case 404: + return `Salesforce API Error (404): Not Found - The requested resource does not exist or you do not have access to it.` + case 500: + return `Salesforce API Error (500): Internal Server Error - An error occurred on Salesforce's servers.` + default: + return `${defaultMessage} (HTTP ${status})` + } +}