cal.diy 中的 Salesforce 集成指南:把预订参与者自动写入 Sales Cloud 联系人
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
本篇技术指南以本仓库(cal.diy,Cal.com 调度基础设施项目)中 Salesforce 集成应用的官方描述文档 DESCRIPTION.md 为核心,结合其 OAuth 授权、CRM 服务、事件类型配置与 Salesforce Apex 扩展包的完整源码实现,系统讲解该集成"把活动参与者(attendees)自动创建为 Salesforce 联系人"的核心能力、可配置项与开发发布流程。读完本文,你将掌握该集成的安装授权链路、事件类型级配置项语义、底层 CRM 服务实现原理,以及基于 scratch org 的本地开发与 SFDC Unlocked Package 发布方法。
集成总览:Salesforce (Sales Cloud) 是什么、集成做什么
按照官方描述文档 DESCRIPTION.md 的定义,Salesforce (Sales Cloud) 是一款云端应用,它通过集中管理客户信息、记录客户与公司的交互、并自动化销售人员每天要完成的许多任务,来帮助销售团队更聪明、更快速地销售。
在该集成中,Cal.com 承担的角色是"调度基础设施":当客户通过你的 Cal.com 链接完成预订后,集成会把本次预订的参与者自动同步到 Salesforce,作为 Sales Cloud 中的联系人(Contact)记录,从而让销售数据与日程数据打通。其特性在文档中被明确概括为一条:
- 将预订事件参与者(event attendees)创建为 Salesforce (Sales Cloud) 中的联系人。
从 config.json 可以看到该应用在 app-store 体系中的完整元信息:
{ "name": "Salesforce", "slug": "salesforce", "type": "salesforce_crm", "logo": "icon.png", "variant": "crm", "categories": ["crm"], "extendsFeature": "EventType", "publisher": "Cal.com, Inc.", "isOAuth": true }关键点解读:
type: "salesforce_crm"表明它实现了 Cal.com 的 CRM 集成契约,注册在 CRM 类型下;variant: "crm"与categories: ["crm"]决定它在应用市场中归属于 CRM 分类;extendsFeature: "EventType"说明该应用会为每个事件类型(EventType)提供可独立配置的能力卡片(AppCard);isOAuth: true说明其安装走 OAuth 授权流程。
从项目整体结构看,这个集成由两部分组成:一部分是 Cal.com 侧基于 Node/TypeScript 的集成服务(位于 packages/app-store/salesforce),另一部分是部署在 Salesforce 组织内的 Apex 扩展包(sfdc-package),两者通过 OAuth 凭据与 Named Credential 协同工作。
OAuth 授权链路:从安装到令牌落地
Salesforce 集成的安装是一个标准 OAuth 2.0 授权码流程,由两个 Next.js API 端点完成:发起授权(api/add.ts)与回调换令牌(api/callback.ts)。
发起授权
api/add.ts 中,服务端先从应用密钥中读取consumer_key,缺失时直接返回400 Salesforce client id missing:
const appKeys = await getAppKeysFromSlug("salesforce"); if (typeof appKeys.consumer_key === "string") consumerKey = appKeys.consumer_key; if (!consumerKey) return res.status(400).json({ message: "Salesforce client id missing." });随后基于 jsforce 构造 OAuth2 客户端,并以refresh_token full作为授权 scope(前者换取长期可用令牌,后者申请完整 API 权限):
const salesforceClient = new jsforce.Connection({ oauth2: { clientId: consumerKey, redirectUri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/salesforce/callback`, }, }); const url = salesforceClient.oauth2.getAuthorizationUrl({ scope: "refresh_token full", ...(state && { state }), }); res.status(200).json({ url });注意redirectUri指向WEBAPP_URL_FOR_OAUTH下的 callback 端点,同时会把请求的 OAuth state 编码进授权 URL,用于回调时校验会话归属。
回调换令牌与令牌生命周期探测
api/callback.ts 在拿到code后,使用consumer_key与consumer_secret调用 Salesforce 令牌端点换取访问令牌:
const conn = new jsforce.Connection({ oauth2: { clientId: consumerKey, clientSecret: consumerSecret, redirectUri: ... }, }); const salesforceTokenInfo = await conn.oauth2.requestToken(code as string);值得注意的实现细节是:集成并不会直接保存令牌,而是先调用 Salesforce 的 OIDC Token Introspection 端点(lib/getSalesforceTokenLifetime.ts)计算令牌有效时长,再把token_lifetime一并存入凭据:
const response = await fetch(`${instanceUrl}/services/oauth2/introspect`, { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${consumer_key}:${consumer_secret}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ token: accessToken, token_type_hint: "access_token" }), }); const tokenLifetime = data.exp - data.iat; // exp 与 iat 均以秒为单位最终通过createOAuthAppCredential将{ ...salesforceTokenInfo, token_lifetime }落库,然后重定向回应用安装页(getInstalledAppPath)。凭据对象的结构在 lib/CrmService.ts 中有对应的 zod schema 约束,包含instance_url、access_token、scope、token_lifetime等字段。
事件类型级配置项:schema 定义与界面开关
得益于extendsFeature: "EventType",每个事件类型都可以独立开关并配置 Salesforce 同步行为。配置结构定义在 zod.ts 的appDataSchema中(基于eventTypeAppCardZod扩展),其配置 UI 实现在 components/EventTypeAppCardInterface.tsx。
核心记录类型与写回枚举
lib/enums.ts 定义了所有配置项可用的枚举值:
- Salesforce 记录类型
SalesforceRecordEnum:Contact、Lead、Account、Event; - 写回时机
WhenToWriteToRecord:every_booking(每次预订都写)、field_empty(仅当字段为空时写); - 字段类型
SalesforceFieldType:date、string、phone、custom、picklist、boolean、datetime、textarea; - 日期数据来源
DateFieldTypeData:booking_start_date、booking_created_date、booking_cancel_date; - 轮询路由判定来源
RoutingReasons:account_lookup_field、lead_owner、contact_owner、account_owner。
完整配置项清单
appDataSchema中可配置项及语义如下(布尔开关均有对应的 UI 控件,字符串/记录型字段在配置卡片中编辑):
| 配置键 | 类型 | 默认值 | 语义 |
|---|---|---|---|
roundRobinLeadSkip | boolean | 无 | 轮询路由(Round Robin)分配时跳过已有匹配记录的参与者 |
roundRobinSkipCheckRecordOn | Contact/Lead/Account | Contact | 轮询跳过时在哪种记录上检查归属 |
rrSkipFieldRules | {field, value, action}[] | 无 | 字段级跳过规则,action为ignore(命中则跳过)或must_include(必须命中才跳过) |
ifFreeEmailDomainSkipOwnerCheck | boolean | false | 免费邮箱域名(如 gmail.com)跳过 owner 检查 |
roundRobinSkipFallbackToLeadOwner | boolean | false | 找不到归属时回退到 Lead Owner |
skipContactCreation | boolean | 无 | 不自动创建联系人 |
createEventOn | Contact/Lead/Account | Contact | 在哪种记录类型下创建 Salesforce Event |
createNewContactUnderAccount | boolean | 无 | 在 Account 下新建 Contact |
createLeadIfAccountNull | boolean | 无 | Account 为空时创建 Lead |
onBookingWriteToEventObject | boolean | false | 预订成功后写回 Event 对象 |
onBookingWriteToEventObjectMap | record | {} | Event 对象字段映射 |
createEventOnLeadCheckForContact | boolean | 无 | 以 Lead 创建 Event 前先检查 Contact |
onBookingChangeRecordOwner | boolean | false | 预订后修改记录 Owner |
onBookingChangeRecordOwnerName | string | [] | 新 Owner 名称 |
sendNoShowAttendeeData | boolean | false | 发送 No-Show 参与者数据 |
sendNoShowAttendeeDataField | string | "" | No-Show 数据写入的字段 |
onBookingWriteToRecord | boolean | false | 预订成功后写回记录 |
onBookingWriteToRecordFields | record | {} | 记录字段映射,值为writeToBookingEntry |
ignoreGuests | boolean | false | 忽略参与者(Guests),只处理主预订人 |
onCancelWriteToEventRecord | boolean | false | 取消预订时写回 |
onCancelWriteToEventRecordFields | record | {} | 取消时字段映射 |
其中写回条目的结构(writeToBookingEntry)为:
{ value: string | boolean, // 要写入的值 fieldType: SalesforceFieldType, // 对应 SF 字段类型 whenToWrite: WhenToWriteToRecord // every_booking | field_empty }UI 侧(components/EventTypeAppCardInterface.tsx)通过useAppContextWithSchema读写这些配置,并渲染三个记录类型下拉框:recordOptions(Contact/Lead/Account,决定createEventOn)与checkOwnerOptions(Contact/Lead/Account,决定roundRobinSkipCheckRecordOn),以及WriteToObjectSettings、FieldRulesSettings两个子设置组件(见 components/components)。
核心实现:CRM 服务如何把参与者写进 Salesforce
整个集成的主逻辑集中在 lib/CrmService.ts(约 1956 行),它实现了 Cal.com 通用的 CRM 接口,并通过 lib/index.ts 以BuildCrmService的名义导出,供 crmManager 统一调度。
SalesforceCRMService的关键设计(lib/CrmService.ts):
- 持有
appOptions(即上一节的事件类型配置),预订流程按配置逐项执行; - 基于
jsforce维护惰性Connection,用存储的令牌访问 Salesforce REST API; - 通过 graphql/SalesforceGraphQLClient.ts 使用 Salesforce GraphQL 端点(schema 定义见 src/gql)查询记录归属;
- 额外扩展了
SalesforceCRM接口,增加 Salesforce 特有的三个方法:findUserEmailFromLookupField(按查找字段反查用户邮箱与路由来源)、incompleteBookingWriteToRecord(预订信息不完整时的写回兜底)、getAllPossibleAccountWebsiteFromEmailDomain(从邮箱域名推断 Account 官网)。
联系人查找与去重是核心流程之一:代码中定义了SalesforceDuplicateError类型(lib/CrmService.ts),用于解析 Salesforce 重复规则(Duplicate Rule)返回的matchResults,从而在写入前判断目标联系人是否已存在。工具函数 lib/utils/getDominantAccountId.ts 与 lib/utils/getAllPossibleWebsiteValuesFromEmailDomain.ts 负责在多候选 Account 之间决策归属、从免费邮箱域名推导可能官网,这些均有配套单元测试(lib/utils/tests)。
对轮询路由(Round Robin)场景,集成还引入了"Assignment Reason"记录:配置打开roundRobinLeadSkip后,服务会基于rrSkipFieldRules与RoutingReasons判定跳过逻辑,并通过 lib/repositories/PrismaAssignmentReasonRepository.ts 把路由原因持久化,便于审计。
集成测试(lib/tests/CrmService.integration.test.ts)配合 salesforceMock.ts 与 graphql/tests中的 urqlMock 覆盖了创建联系人、写入 Event、GraphQL 查询等关键链路,是理解预期行为的最佳参考。
Salesforce 侧 Apex 扩展包与用户同步
集成并不止于 Cal.com 单向写数据,还包括部署在 Salesforce 组织内的 Unlocked Package(sfdc-package),实现"Salesforce 变化反向通知 Cal.com"的双向能力。Apex 源码位于 sfdc-package/force-app/main/default:
- CalComCalloutQueueable.cls:以 Queueable 方式异步向 Cal.com 发起 Callout,避免在触发器同步链路中阻塞事务;
- UserUpdateHandler.cls 与 UserUpdateTrigger.trigger:监听 Salesforce 侧 User 记录变更,触发同步请求;
- CalComHttpMock.cls:测试用的 HTTP Mock,配合 UserUpdateHandlerTest.cls 与 CalComCalloutQueueableTest.cls 验证行为;
- Named Credential(CalCom_Development.namedCredential-meta.xml 与
CalCom_Production)用于把目标 Cal.com 实例地址配置化,开发环境指向本地实例,生产环境指向线上。
Cal.com 侧接收这些同步请求的端点是 api/user-sync.ts。该 POST 端点会校验请求体中的instanceUrl是否与已存储凭据匹配、orgId是否与凭据 URL 中的组织 ID 一致、以及邮箱与团队用户是否对应,三重校验全部通过才返回{ success: true },防止跨组织伪造同步请求。
本地开发、测试与包发布流程
官方开发文档 README.md 给出了完整的工程化流程。
创建 Salesforce 测试组织(Scratch Org)
需先安装 Salesforce CLI,然后:
yarn scratch-org:create # 按 project-scratch-def.json 配置创建 scratch org yarn scratch-org:start # 在浏览器中打开该 org若要在本地联调,需要把 scratch org 中的 Named Credential 指向本地实例(将CalCom_Development指向 localhost)。
GraphQL 类型生成
该集成通过 GraphQL Codegen 从 Salesforce schema 生成查询与类型(文档见 README.md):
- 由于 Salesforce GraphQL 端点 v63 在生成
Setup__JoinInput类型时存在已知报错,schema 需从 Salesforce GraphQL introspection 结果转换而来(使用graphql-introspection-json-to-sdl工具); - 运行
yarn generate:schema生成 SDL 文件; - 开发期间保持
yarn codegen:watch后台运行,自动从 SDL 生成查询与类型; - 相关配置见 codegen.ts、graphql.config.ts 与 graphqlrc.config.ts。
Apex 包部署与发布
Apex 侧开发(需 Salesforce CLI):
yarn sfdc:deploy:preview # 预览将部署到 scratch org 的变更 yarn sfdc:deploy # 实际部署到 scratch org发布 Unlocked Package 时所有命令需在 sfdc-package 目录下执行。首次创建包(一次性):
sf package create \ --name "calcom-sfdc-package" \ --package-type Unlocked \ --path force-app \ --target-dev-hub team@cal.com每次发布新版本:
sf package version create \ --package "calcom-sfdc-package" \ --installation-key-bypass \ --wait 20 \ --target-dev-hub team@cal.com其中--installation-key-bypass允许免密码安装,--wait 20表示最多等待 20 分钟构建完成;准备 promote 时需追加--code-coverage(要求 Apex 测试覆盖率不低于 75%)。查看已发布版本:
sf package version list --target-dev-hub team@cal.com安装 URL 格式为https://login.salesforce.com/packaging/installPackage.apexp?p0=<04t_SUBSCRIBER_PACKAGE_VERSION_ID>。Beta 版只能安装到沙盒/scratch org,允许安装到生产组织的版本需先 promote:
sf package version promote \ --package "calcom-sfdc-package@X.X.X-X" \ --target-dev-hub team@cal.com运行 Apex 测试与覆盖率统计:
sf project deploy start --target-org <org-alias> sf apex run test --test-level RunLocalTests --wait 10 --target-org <org-alias>小结
本文基于官方描述文档 DESCRIPTION.md 展开,完整梳理了 cal.diy 中 Salesforce 集成"将预订参与者创建为 Sales Cloud 联系人"这一核心能力的技术全貌:OAuth 授权与令牌生命周期管理(api/add.ts、api/callback.ts)、二十余项事件类型级配置(zod.ts)、以 lib/CrmService.ts 为核心的写入与去重逻辑、Salesforce 侧 Apex 包的双向同步,以及从 scratch org 到 Unlocked Package 发布的一整套工程实践。如需深入源码,建议从 lib/tests/CrmService.integration.test.ts 的集成测试与 sfdc-package 的 Apex 触发器入手,逐条对照配置项与断言,即可快速掌握端到端的数据流。
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考