Skip to content

Commit 2664eb4

Browse files
joeauyeungdevin-ai-integration[bot]eunjae-leehariombalhara
authored
feat: Init Cal.com Salesforce package (calcom#26330)
* Add db schema * Add `CredentialRepository.findByTeamIdAndSlugs` * Add enabled app slugs for attribute syncing * Create repository for `IntegrationAttributeSync` * Create zod schemas * Create `AttributeSyncUserRuleOutputMapper` * Create `IntegrationAttributeSyncService` * Create DI contianer * Create trpc endpoints * Create page * Include team name in `CredentialRepository.findByTeamIdAndSlugs` * Update schema and relations * Update types and schemas * Add more methods to IntegrationAttributeSyncRepository * Add more services to `IntegrationAttributeSyncService` - getById - Init updateIncludeRulesAndMappings * Refactor `getTeams.handler` to use repository * Create `createAttributeSync` trpc endpoint * Create `updateAttributeSync` trpc endpoint * Add router to trpc * Create attribute sync child components * Pass custom actions to `FormCard` * Create `IntegrationAttributeSyncCard` * Pass inital props via server side * Fix prop * Only refetch on mutation * Fixes * Add form error when duplicate field and attribute combo * Add `updateTransactionWithRUleAndMappings` logic * Adjust zod schemas * Service add `updateIncludeRulesAndMappings` * Pass orgId from server to component * Rename types * Add deleteById method to repository * Add name to integrationAttributeSync * Add deleteById method to service * Rename method * Add deleteAttributeSync trpc endpoint * Make the IntegrationAttributeSyncCard a dummy component * test: add tests for IntegrationAttributeSync feature Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Move creating a attribute sync record to the service * Add i18n strings * Safe select credential in find by id and team * Fix default credentialId value in form * Update repository return types * Add i18n string * Make credentialId optional for form schema * Fix label * Add cascade deletes * Add verification that syncs belong to org * Create mapper for repository output * Type fixes * Remove old test file * Pass `attributeOptions` from parent to children * Infer types from zod schema * Type fixes * Type fix * Clean up * Add i18n strings * Remove unused file * Address feedback * Add migration file * Address feedback * Add validation for new integration values * Remove unused router * Move away from z.infer to z.ZodType * Clean up comments * Type fix * Type fixes * Type fix * fix: add passthrough to syncFormDataSchema to preserve extra fields Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: remove incorrect test that expected extra fields to pass through syncFormDataSchema Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Add endpoint for SF to call * Create scratch org config * Create sf cli scripts * Create package logic * Update README * Remove unused file * Add indexes * Add aria label * Address feedback - consistent validation * Fix import paths for attribute types * refactor: change attributeSyncRules array to singular attributeSyncRule The database schema enforces a one-to-one relationship between IntegrationAttributeSync and AttributeSyncRule (via @unique constraint), and the UI only supports a single rule. This change makes the TypeScript type match the database schema and UI behavior. Changes: - Update IntegrationAttributeSync interface to use attributeSyncRule: AttributeSyncRule | null - Update mapper to return singular rule instead of wrapping in array - Update UI component to access sync.attributeSyncRule directly - Update IIntegrationAttributeSyncUpdateParams Omit type - Update tests to use attributeSyncRule: null instead of attributeSyncRules: [] Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat: implement FeatureOptInService (calcom#25805) * feat: implement FeatureOptInService WIP * clean up * feat: consolidate feature repositories and add updateFeatureForUser - Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam) - Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository - Update FeatureOptInService to use only FeaturesRepository - Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService - Update _router.ts to remove PrismaFeatureOptInRepository usage - Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts - Update features.repository.interface.ts and features.repository.mock.ts - Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState - Update service.integration-test.ts to use FeaturesRepository Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: rename updateFeatureForUser to setUserFeatureState Rename to match the convention used for setTeamFeatureState Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState * fix integration tests * clean up logics * update services and router * refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array - Renamed getUserFeatureState to getUserFeatureStates - Renamed getTeamFeatureState to getTeamFeatureStates - Changed parameter from featureId: string to featureIds: string[] - Changed return type from FeatureState to Record<string, FeatureState> - Updated FeatureOptInService to use the new batch methods - Added tests for querying multiple features in a single call - Optimized listFeaturesForTeam to fetch all feature states in one query Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add getFeatureStateForTeams for batch querying multiple teams - Added getFeatureStateForTeams method to query a single feature across multiple teams in one call - Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method - Replaces N+1 queries with a single database query for team states - Added comprehensive integration tests for the new method Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: combine org and team state queries into single call - Include orgId in the teamIds array passed to getFeatureStateForTeams - Extract org state and team states from the combined result - Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use team.isOrganization and clarify computeEffectiveState comment Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use MembershipRepository.findAllByUserId with isOrganization Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add featureId validation using isOptInFeature type guard Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * less queries * add fallback value * fix type error * move files * add autoOptInFeatures column * use autoOptInFeatures flag within FeatureOptInService * add setUserAutoOptIn and setTeamAutoOptIn * fix computeEffectiveState logic * rewrite computeEffectiveState * clean up integration tests * clean up in afterEach * fix type error * refactor: use FeaturesRepository methods instead of direct Prisma calls Replace all manual userFeatures and teamFeatures Prisma operations with the new setUserFeatureState and setTeamFeatureState repository methods. Changes include: - Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam) - Test fixtures and integration tests - Playwright fixtures - Development scripts This ensures consistent feature flag management through the repository pattern and supports the new tri-state semantics (enabled/disabled/inherit). Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up * fix the logic * extract some logic into applyAutoOptIn() * remove wrong code * refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union - Convert multiple positional parameters to single object parameter - Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit - Update all callers across repository, service, handlers, fixtures, and tests * fix type error * use Promise.all * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Prevent duplicate field and attribute mappings * Add validation that attribute belongs to the org * fix: address Cubic AI review feedback - Add @@index([credentialId]) to IntegrationAttributeSync model for efficient cascade deletes and credential-based queries (confidence: 9/10) - Fix translation key from 'credential_required' to 'attribute_sync_credential_required' to match existing locale definition (confidence: 10/10) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: address remaining cubic review feedback - RuleBuilder.tsx: Use unique IDs for React keys instead of array index to prevent reconciliation bugs when removing conditions - updateAttributeSync.handler.ts: Add early organization check before service calls for consistency - createAttributeSync.handler.ts: Add CredentialNotFoundError class for type-safe error handling instead of string matching - IntegrationAttributeSyncService.test.ts: Replace expect.fail() with proper Vitest rejects.toSatisfy() pattern Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Pull test file from main * fix: address Cubic AI review feedback (confidence >= 9/10) - Remove sensitive fields (email, changedFields) from logging in user-sync.ts - Rename scratch-org:open to scratch-org:start in package.json to match README Co-Authored-By: unknown <> * Update README --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Eunjae Lee <hey@eunjae.dev> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
1 parent ce99744 commit 2664eb4

15 files changed

Lines changed: 217 additions & 3 deletions
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
## Working With GraphQL
1+
# Creating a Salesforce test org
2+
You must have the [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) installed to create a test org. Once installed, you can create a test org using the following command `yarn scratch-org:create`
3+
4+
This will create a scratch org with the configuration specified in the `project-scratch-def.json` file.
5+
6+
To open a browser tab to the org, run `yarn scratch-org:start`
7+
8+
# Working With GraphQL
29
This package utilizes [`GraphQL Codegen`](https://the-guild.dev/graphql/codegen#graphql-codegen) to generate types and queries from the Salesforce schema.
310

411
### Generating GraphQL Schema
512
Currently v63 of the Salesforce graphql endpoint throws an error when trying to generate files. This is due to the `Setup__JoinInput` type not generating any fields. To work around this, the `schema.json` file comes from the [Salesforce graphql introspection query](https://www.postman.com/salesforce-developers/salesforce-developers/request/sy8qaf9/introspection-query). This file is then converted to a SDL file using the [graphql-introspection-json-to-sdl](https://github.com/Calcom/graphql-introspection-json-to-sdl) package. You can generate the SDL file by running `yarn generate:schema`.
613

714
### Generating Queries
8-
When working with graphql files ensure that `yarn codegen:watch` is running in the background. This will generate the types and queries from the SDL file.
15+
When working with graphql files ensure that `yarn codegen:watch` is running in the background. This will generate the types and queries from the SDL file.
16+
17+
# Developing the SFDC package
18+
The SFDC package is written using Apex. To develop this package, you need to have the Salesforce CLI installed. Then you can run `yarn sfdc:deploy:preview` to see what changes will be deployed to the scratch org. Running `yarn sfdc:deploy` will deploy the changes to the scratch org.
19+
20+
Note that if you want to call your local development instances you need to change the "Named Credential" on the scratch org settings to point the `CalCom_Development` credential to the local instance.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as add } from "./add";
22
export { default as callback } from "./callback";
3+
export { default as "user-sync" } from "./user-sync";
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
3+
import logger from "@calcom/lib/logger";
4+
5+
const log = logger.getSubLogger({ prefix: ["[salesforce/user-sync]"] });
6+
7+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
8+
if (req.method !== "POST") {
9+
return res.status(405).json({ error: "Method not allowed" });
10+
}
11+
12+
const { instanceUrl, orgId, salesforceUserId, email, changedFields, timestamp } = req.body;
13+
14+
log.info("Received user sync request", {
15+
instanceUrl,
16+
orgId,
17+
salesforceUserId,
18+
timestamp,
19+
});
20+
21+
// TODO: Validate instanceUrl + orgId against stored credentials
22+
// TODO: Sync changedFields to Cal.com user
23+
24+
return res.status(200).json({ success: true });
25+
}

packages/app-store/salesforce/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
"scripts": {
88
"generate:schema": "graphql-introspection-json-to-sdl ./schema.json > ./schema.graphql",
99
"codegen": "graphql-codegen --config codegen.ts",
10-
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
10+
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
11+
"scratch-org:create": "sf org create scratch --definition-file scratch-org-def.json --alias calcom-test --duration-days 7 --set-default",
12+
"scratch-org:start": "sf org open --target-org calcom-test",
13+
"sfdc:deploy": "cd sfdc-package && sf project deploy start",
14+
"sfdc:deploy:preview": "cd sfdc-package && sf project deploy preview"
1115
},
1216
"dependencies": {
1317
"@calcom/lib": "workspace:*",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"orgName": "Cal.com Test Org",
3+
"edition": "Developer",
4+
"features": [],
5+
"settings": {}
6+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
2+
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
3+
#
4+
5+
package.xml
6+
7+
# LWC configuration files
8+
**/jsconfig.json
9+
**/.eslintrc.json
10+
11+
# LWC Jest
12+
**/__tests__/**
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
public with sharing class CalComCalloutQueueable implements Queueable, Database.AllowsCallouts {
2+
3+
private static final String ENDPOINT_PATH = '/api/integrations/salesforce/user-sync';
4+
5+
private List<UserChangePayload> payloads;
6+
7+
public CalComCalloutQueueable(List<UserChangePayload> payloads) {
8+
this.payloads = payloads;
9+
}
10+
11+
public void execute(QueueableContext context) {
12+
String namedCredential = getNamedCredential();
13+
14+
for (UserChangePayload payload : payloads) {
15+
sendPayload(namedCredential, payload);
16+
}
17+
}
18+
19+
private String getNamedCredential() {
20+
Boolean isSandbox = [SELECT IsSandbox FROM Organization LIMIT 1].IsSandbox;
21+
return isSandbox ? 'callout:CalCom_Development' : 'callout:CalCom_Production';
22+
}
23+
24+
private void sendPayload(String namedCredential, UserChangePayload payload) {
25+
try {
26+
HttpRequest req = new HttpRequest();
27+
req.setEndpoint(namedCredential + ENDPOINT_PATH);
28+
req.setMethod('POST');
29+
req.setHeader('Content-Type', 'application/json');
30+
req.setBody(JSON.serialize(payload));
31+
req.setTimeout(30000);
32+
33+
Http http = new Http();
34+
HttpResponse res = http.send(req);
35+
36+
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
37+
System.debug(LoggingLevel.INFO, 'Successfully sent user update to Cal.com for user: ' + payload.salesforceUserId);
38+
} else {
39+
System.debug(LoggingLevel.ERROR, 'Failed to send user update to Cal.com. Status: ' + res.getStatusCode() + ' Body: ' + res.getBody());
40+
}
41+
} catch (Exception e) {
42+
System.debug(LoggingLevel.ERROR, 'Exception sending user update to Cal.com: ' + e.getMessage());
43+
}
44+
}
45+
46+
public class UserChangePayload {
47+
public Id salesforceUserId;
48+
public String email;
49+
public String instanceUrl;
50+
public String orgId;
51+
public Map<String, Object> changedFields;
52+
public String timestamp;
53+
}
54+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>64.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
public with sharing class UserUpdateHandler {
2+
3+
public static void handleAfterUpdate(Map<Id, User> oldMap, Map<Id, User> newMap) {
4+
List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();
5+
6+
for (Id userId : newMap.keySet()) {
7+
User oldUser = oldMap.get(userId);
8+
User newUser = newMap.get(userId);
9+
10+
Map<String, Object> changedFields = getChangedFields(oldUser, newUser);
11+
12+
if (!changedFields.isEmpty()) {
13+
CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload();
14+
payload.salesforceUserId = userId;
15+
payload.email = newUser.Email;
16+
payload.instanceUrl = URL.getOrgDomainUrl().toExternalForm();
17+
payload.orgId = UserInfo.getOrganizationId();
18+
payload.changedFields = changedFields;
19+
payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'');
20+
payloads.add(payload);
21+
}
22+
}
23+
24+
if (!payloads.isEmpty()) {
25+
System.enqueueJob(new CalComCalloutQueueable(payloads));
26+
}
27+
}
28+
29+
private static Map<String, Object> getChangedFields(User oldUser, User newUser) {
30+
Map<String, Object> changedFields = new Map<String, Object>();
31+
32+
Map<String, Object> oldFields = oldUser.getPopulatedFieldsAsMap();
33+
Map<String, Object> newFields = newUser.getPopulatedFieldsAsMap();
34+
35+
for (String fieldName : newFields.keySet()) {
36+
Object oldValue = oldFields.get(fieldName);
37+
Object newValue = newFields.get(fieldName);
38+
39+
if (oldValue != newValue) {
40+
changedFields.put(fieldName, newValue);
41+
}
42+
}
43+
44+
return changedFields;
45+
}
46+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<apiVersion>64.0</apiVersion>
4+
<status>Active</status>
5+
</ApexClass>

0 commit comments

Comments
 (0)