feat: Init Cal.com Salesforce package (#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 (#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>
This commit is contained in:
co-authored by
unknown
Devin AI
Eunjae Lee
Hariom Balhara
parent
ce997447a4
commit
2664eb459b
@@ -1,8 +1,20 @@
|
||||
## Working With GraphQL
|
||||
# Creating a Salesforce test org
|
||||
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`
|
||||
|
||||
This will create a scratch org with the configuration specified in the `project-scratch-def.json` file.
|
||||
|
||||
To open a browser tab to the org, run `yarn scratch-org:start`
|
||||
|
||||
# Working With GraphQL
|
||||
This package utilizes [`GraphQL Codegen`](https://the-guild.dev/graphql/codegen#graphql-codegen) to generate types and queries from the Salesforce schema.
|
||||
|
||||
### Generating GraphQL Schema
|
||||
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`.
|
||||
|
||||
### Generating Queries
|
||||
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.
|
||||
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.
|
||||
|
||||
# Developing the SFDC package
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
export { default as "user-sync" } from "./user-sync";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[salesforce/user-sync]"] });
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { instanceUrl, orgId, salesforceUserId, email, changedFields, timestamp } = req.body;
|
||||
|
||||
log.info("Received user sync request", {
|
||||
instanceUrl,
|
||||
orgId,
|
||||
salesforceUserId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// TODO: Validate instanceUrl + orgId against stored credentials
|
||||
// TODO: Sync changedFields to Cal.com user
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
@@ -7,7 +7,11 @@
|
||||
"scripts": {
|
||||
"generate:schema": "graphql-introspection-json-to-sdl ./schema.json > ./schema.graphql",
|
||||
"codegen": "graphql-codegen --config codegen.ts",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"scratch-org:create": "sf org create scratch --definition-file scratch-org-def.json --alias calcom-test --duration-days 7 --set-default",
|
||||
"scratch-org:start": "sf org open --target-org calcom-test",
|
||||
"sfdc:deploy": "cd sfdc-package && sf project deploy start",
|
||||
"sfdc:deploy:preview": "cd sfdc-package && sf project deploy preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/lib": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"orgName": "Cal.com Test Org",
|
||||
"edition": "Developer",
|
||||
"features": [],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
|
||||
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
|
||||
#
|
||||
|
||||
package.xml
|
||||
|
||||
# LWC configuration files
|
||||
**/jsconfig.json
|
||||
**/.eslintrc.json
|
||||
|
||||
# LWC Jest
|
||||
**/__tests__/**
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
public with sharing class CalComCalloutQueueable implements Queueable, Database.AllowsCallouts {
|
||||
|
||||
private static final String ENDPOINT_PATH = '/api/integrations/salesforce/user-sync';
|
||||
|
||||
private List<UserChangePayload> payloads;
|
||||
|
||||
public CalComCalloutQueueable(List<UserChangePayload> payloads) {
|
||||
this.payloads = payloads;
|
||||
}
|
||||
|
||||
public void execute(QueueableContext context) {
|
||||
String namedCredential = getNamedCredential();
|
||||
|
||||
for (UserChangePayload payload : payloads) {
|
||||
sendPayload(namedCredential, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private String getNamedCredential() {
|
||||
Boolean isSandbox = [SELECT IsSandbox FROM Organization LIMIT 1].IsSandbox;
|
||||
return isSandbox ? 'callout:CalCom_Development' : 'callout:CalCom_Production';
|
||||
}
|
||||
|
||||
private void sendPayload(String namedCredential, UserChangePayload payload) {
|
||||
try {
|
||||
HttpRequest req = new HttpRequest();
|
||||
req.setEndpoint(namedCredential + ENDPOINT_PATH);
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setBody(JSON.serialize(payload));
|
||||
req.setTimeout(30000);
|
||||
|
||||
Http http = new Http();
|
||||
HttpResponse res = http.send(req);
|
||||
|
||||
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
|
||||
System.debug(LoggingLevel.INFO, 'Successfully sent user update to Cal.com for user: ' + payload.salesforceUserId);
|
||||
} else {
|
||||
System.debug(LoggingLevel.ERROR, 'Failed to send user update to Cal.com. Status: ' + res.getStatusCode() + ' Body: ' + res.getBody());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.debug(LoggingLevel.ERROR, 'Exception sending user update to Cal.com: ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public class UserChangePayload {
|
||||
public Id salesforceUserId;
|
||||
public String email;
|
||||
public String instanceUrl;
|
||||
public String orgId;
|
||||
public Map<String, Object> changedFields;
|
||||
public String timestamp;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
public with sharing class UserUpdateHandler {
|
||||
|
||||
public static void handleAfterUpdate(Map<Id, User> oldMap, Map<Id, User> newMap) {
|
||||
List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();
|
||||
|
||||
for (Id userId : newMap.keySet()) {
|
||||
User oldUser = oldMap.get(userId);
|
||||
User newUser = newMap.get(userId);
|
||||
|
||||
Map<String, Object> changedFields = getChangedFields(oldUser, newUser);
|
||||
|
||||
if (!changedFields.isEmpty()) {
|
||||
CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload();
|
||||
payload.salesforceUserId = userId;
|
||||
payload.email = newUser.Email;
|
||||
payload.instanceUrl = URL.getOrgDomainUrl().toExternalForm();
|
||||
payload.orgId = UserInfo.getOrganizationId();
|
||||
payload.changedFields = changedFields;
|
||||
payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'');
|
||||
payloads.add(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (!payloads.isEmpty()) {
|
||||
System.enqueueJob(new CalComCalloutQueueable(payloads));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> getChangedFields(User oldUser, User newUser) {
|
||||
Map<String, Object> changedFields = new Map<String, Object>();
|
||||
|
||||
Map<String, Object> oldFields = oldUser.getPopulatedFieldsAsMap();
|
||||
Map<String, Object> newFields = newUser.getPopulatedFieldsAsMap();
|
||||
|
||||
for (String fieldName : newFields.keySet()) {
|
||||
Object oldValue = oldFields.get(fieldName);
|
||||
Object newValue = newFields.get(fieldName);
|
||||
|
||||
if (oldValue != newValue) {
|
||||
changedFields.put(fieldName, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFields;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<fullName>CalCom_Development</fullName>
|
||||
<label>Cal.com Development</label>
|
||||
<endpoint>https://app.cal.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>Custom</protocol>
|
||||
<allowMergeFieldsInBody>false</allowMergeFieldsInBody>
|
||||
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
<calloutStatus>Enabled</calloutStatus>
|
||||
</NamedCredential>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<fullName>CalCom_Production</fullName>
|
||||
<label>Cal.com Production</label>
|
||||
<endpoint>https://app.cal.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>Custom</protocol>
|
||||
<allowMergeFieldsInBody>false</allowMergeFieldsInBody>
|
||||
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
<calloutStatus>Enabled</calloutStatus>
|
||||
</NamedCredential>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
trigger UserUpdateTrigger on User (after update) {
|
||||
UserUpdateHandler.handleAfterUpdate(Trigger.oldMap, Trigger.newMap);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexTrigger>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"packageDirectories": [
|
||||
{
|
||||
"path": "force-app",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"name": "calcom-user-sync",
|
||||
"namespace": "",
|
||||
"sfdcLoginUrl": "https://login.salesforce.com",
|
||||
"sourceApiVersion": "64.0"
|
||||
}
|
||||
Reference in New Issue
Block a user