feat: Salesforce - on booking write to fields on person record [CAL-4575] (#17477)

* Create data type

* Start FE component

* Rename and add new enums

* Add new schema for write on record options

* Add options on the frontend

* Backend write to custom fields

* refactor: Salesforce - on booking write to fields on person record (#17481)

* Add translations

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
Joe Au-Yeung
2024-11-06 08:20:52 +00:00
committed by GitHub
co-authored by Udit Takkar Udit Takkar
parent 9a1b5ad149
commit e07a28a2d1
6 changed files with 409 additions and 12 deletions
@@ -2746,6 +2746,13 @@
"event_end_time_in_attendee_timezone_info": "Event End Time In Attendee Timezone",
"salesforce_create_event_on_contact": "Create event on contact, if it exists. Else fallback to lead",
"salesforce_owner_name_to_change": "Owner name to change",
"field_type": "Field type",
"when_to_write": "When to write",
"salesforce_on_booking_write_to_record": "On booking, write to a custom field on the attendee {{record}} record",
"on_every_booking": "On every booking",
"only_if_field_is_empty": "Only if field is empty",
"booking_start_date": "Booking start date",
"booking_created_date": "Booking created date",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -10,7 +10,12 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Switch, Alert, Select, Button, InputField, showToast } from "@calcom/ui";
import { SalesforceRecordEnum } from "../lib/recordEnum";
import {
SalesforceRecordEnum,
WhenToWriteToRecord,
SalesforceFieldType,
DateFieldTypeData,
} from "../lib/enums";
import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
@@ -32,6 +37,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const onBookingChangeRecordOwnerName = getAppData("onBookingChangeRecordOwnerName") ?? [];
const sendNoShowAttendeeData = getAppData("sendNoShowAttendeeData") ?? false;
const sendNoShowAttendeeDataField = getAppData("sendNoShowAttendeeDataField") ?? "";
const onBookingWriteToRecord = getAppData("onBookingWriteToRecord") ?? false;
const onBookingWriteToRecordFields = getAppData("onBookingWriteToRecordFields") ?? {};
const { t } = useLocale();
@@ -44,6 +51,34 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
recordOptions.find((option) => option.value === createEventOn) ?? recordOptions[0]
);
const fieldTypeOptions = [
{ label: t("text"), value: SalesforceFieldType.TEXT },
{ label: t("date"), value: SalesforceFieldType.DATE },
];
const [writeToPersonObjectFieldType, setWriteToPersonObjectFieldType] = useState(fieldTypeOptions[0]);
const whenToWriteToRecordOptions = [
{ label: t("on_every_booking"), value: WhenToWriteToRecord.EVERY_BOOKING },
{ label: t("only_if_field_is_empty"), value: WhenToWriteToRecord.FIELD_EMPTY },
];
const [whenToWriteToPersonRecord, setWhenToWriteToPersonRecord] = useState(whenToWriteToRecordOptions[0]);
const dateFieldValueOptions = [
{ label: t("booking_start_date"), value: DateFieldTypeData.BOOKING_START_DATE },
{ label: t("booking_created_date"), value: DateFieldTypeData.BOOKING_CREATED_DATE },
];
const [dateFieldValue, setDateValue] = useState(dateFieldValueOptions[0]);
const [newOnBookingWriteToPersonObjectField, setNewOnBookingWriteToPersonObjectField] = useState({
field: "",
fieldType: writeToPersonObjectFieldType.value,
value: "",
whenToWrite: WhenToWriteToRecord.FIELD_EMPTY,
});
const checkOwnerOptions = [
{ label: t("contact"), value: SalesforceRecordEnum.CONTACT },
{ label: t("salesforce_lead"), value: SalesforceRecordEnum.LEAD },
@@ -226,6 +261,185 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
) : null}
</div>
<div className="mt-4">
<Switch
label={t("salesforce_on_booking_write_to_record", { record: createEventOn })}
labelOnLeading
checked={onBookingWriteToRecord}
onCheckedChange={(checked) => {
setAppData("onBookingWriteToRecord", checked);
}}
/>
{onBookingWriteToRecord ? (
<div className="ml-2 mt-2">
<div className="grid grid-cols-5 gap-4">
<div>{t("field_name")}</div>
<div>{t("field_type")}</div>
<div>{t("value")}</div>
<div>{t("when_to_write")}</div>
</div>
<div>
{...Object.keys(onBookingWriteToRecordFields).map((key) => (
<div className="mt-2 grid grid-cols-5 gap-4" key={key}>
<div>
<InputField value={key} readOnly />
</div>
<div>
<Select
value={fieldTypeOptions.find(
(option) => option.value === onBookingWriteToRecordFields[key].fieldType
)}
isDisabled={true}
/>
</div>
<div>
{onBookingWriteToRecordFields[key].fieldType === SalesforceFieldType.DATE ? (
<Select
value={dateFieldValueOptions.find(
(option) => option.value === onBookingWriteToRecordFields[key].value
)}
isDisabled={true}
/>
) : (
<InputField value={onBookingWriteToRecordFields[key].value} readOnly />
)}
</div>
<div>
<Select
value={whenToWriteToRecordOptions.find(
(option) => option.value === onBookingWriteToRecordFields[key].whenToWrite
)}
isDisabled={true}
/>
</div>
<div>
<Button
StartIcon="trash"
variant="icon"
color="destructive"
onClick={() => {
const newObject = onBookingWriteToRecordFields;
delete onBookingWriteToRecordFields[key];
setAppData("onBookingWriteToRecordFields", newObject);
}}
/>
</div>
</div>
))}
<div className="mt-2 grid grid-cols-5 gap-4">
<div>
<InputField
value={newOnBookingWriteToPersonObjectField.field}
onChange={(e) =>
setNewOnBookingWriteToPersonObjectField({
...newOnBookingWriteToPersonObjectField,
field: e.target.value,
})
}
/>
</div>
<div>
<Select
options={fieldTypeOptions}
value={writeToPersonObjectFieldType}
onChange={(e) => {
if (e) {
setWriteToPersonObjectFieldType(e);
setNewOnBookingWriteToPersonObjectField({
...newOnBookingWriteToPersonObjectField,
fieldType: e.value,
...(e.value === SalesforceFieldType.DATE && { value: dateFieldValue.value }),
});
}
}}
/>
</div>
<div>
{writeToPersonObjectFieldType.value === SalesforceFieldType.DATE ? (
<Select
options={dateFieldValueOptions}
value={dateFieldValue}
onChange={(e) => {
if (e) {
setDateValue(e);
setNewOnBookingWriteToPersonObjectField({
...newOnBookingWriteToPersonObjectField,
value: e.value,
});
}
}}
/>
) : (
<InputField
value={newOnBookingWriteToPersonObjectField.value}
onChange={(e) =>
setNewOnBookingWriteToPersonObjectField({
...newOnBookingWriteToPersonObjectField,
value: e.target.value,
})
}
/>
)}
</div>
<div>
<Select
options={whenToWriteToRecordOptions}
value={whenToWriteToPersonRecord}
onChange={(e) => {
if (e) {
setWhenToWriteToPersonRecord(e);
setNewOnBookingWriteToPersonObjectField({
...newOnBookingWriteToPersonObjectField,
whenToWrite: e.value,
});
}
}}
/>
</div>
</div>
</div>
<Button
className="mt-2"
size="sm"
disabled={
!(
newOnBookingWriteToPersonObjectField.field &&
newOnBookingWriteToPersonObjectField.fieldType &&
newOnBookingWriteToPersonObjectField.value &&
newOnBookingWriteToPersonObjectField.whenToWrite
)
}
onClick={() => {
if (
Object.keys(onBookingWriteToRecordFields).includes(
newOnBookingWriteToEventObjectField.field.trim()
)
) {
showToast("Field already exists", "error");
return;
}
setAppData("onBookingWriteToRecordFields", {
...onBookingWriteToRecordFields,
[newOnBookingWriteToPersonObjectField.field.trim()]: {
fieldType: newOnBookingWriteToPersonObjectField.fieldType,
value: newOnBookingWriteToPersonObjectField.value,
whenToWrite: newOnBookingWriteToPersonObjectField.whenToWrite,
},
});
setNewOnBookingWriteToPersonObjectField({
field: "",
fieldType: writeToPersonObjectFieldType.value,
value: "",
whenToWrite: WhenToWriteToRecord.FIELD_EMPTY,
});
}}>
{t("add_new_field")}
</Button>
</div>
) : null}
</div>
<div className="mt-4">
<Switch
label="Change record owner on booking"
+158 -5
View File
@@ -16,7 +16,7 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import { default as appMeta } from "../config.json";
import { SalesforceRecordEnum } from "./recordEnum";
import { SalesforceRecordEnum, SalesforceFieldType, WhenToWriteToRecord, DateFieldTypeData } from "./enums";
type ExtendedTokenResponse = TokenResponse & {
instance_url: string;
@@ -59,6 +59,7 @@ export default class SalesforceCRMService implements CRM {
private calWarnings: string[] = [];
private appOptions: any;
private doNotCreateEvent = false;
private fallbackToContact = false;
constructor(credential: CredentialPayload, appOptions: any) {
this.integrationName = "salesforce_other_calendar";
@@ -185,7 +186,7 @@ export default class SalesforceCRMService implements CRM {
} = {};
for (const field of customFieldInputs) {
confirmedCustomFieldInputs[field] = appOptions.onBookingWriteToEventObjectMap[field];
confirmedCustomFieldInputs[field.name] = appOptions.onBookingWriteToEventObjectMap[field.name];
}
const ownerId = await this.getSalesforceUserIdFromEmail(event.organizer.email);
@@ -214,6 +215,9 @@ export default class SalesforceCRMService implements CRM {
if (appOptions.onBookingChangeRecordOwner && appOptions.onBookingChangeRecordOwnerName && ownerId) {
await this.checkRecordOwnerNameFromRecordId(contacts[0].id, ownerId);
}
if (appOptions.onBookingWriteToRecord && appOptions.onBookingWriteToRecordFields) {
await this.writeToPersonRecord(contacts[0].id, event.startTime, event.organizer.email, event?.uid);
}
return createdEvent;
};
@@ -358,8 +362,10 @@ export default class SalesforceCRMService implements CRM {
)}')`
);
if (contactSearch && contactSearch.records.length > 0)
if (contactSearch && contactSearch.records.length > 0) {
records = contactSearch.records as ContactRecord[];
this.setFallbackToContact(true);
}
} else if (!results || !results.records.length) {
return [];
}
@@ -651,7 +657,7 @@ export default class SalesforceCRMService implements CRM {
const conn = await this.conn;
const fieldSet = new Set(fieldsToTest);
const foundFields: string[] = [];
const foundFields: jsforce.Field[] = [];
try {
const salesforceEntity = await conn.describe(sobject);
@@ -661,7 +667,7 @@ export default class SalesforceCRMService implements CRM {
if (foundFields.length === fieldSet.size) break;
if (fieldSet.has(field.name)) {
foundFields.push(field.name);
foundFields.push(field);
}
}
@@ -711,4 +717,151 @@ export default class SalesforceCRMService implements CRM {
return this.getDominantAccountId(response.records as { AccountId: string }[]);
}
private setFallbackToContact(boolean: boolean) {
this.fallbackToContact = boolean;
}
private getFallbackToContact() {
return this.fallbackToContact;
}
private async writeToPersonRecord(
contactId: string,
startTime: string,
organizerEmail: string,
bookingUid?: string | null
) {
const conn = await this.conn;
const { createEventOn, onBookingWriteToRecordFields } = this.getAppOptions();
// Determine record type (Contact or Lead)
const personRecordType = this.determinePersonRecordType(createEventOn);
// Search the fields and ensure 1. they exist 2. they're the right type
const fieldsToWriteOn = Object.keys(onBookingWriteToRecordFields);
const existingFields = await this.ensureFieldsExistOnObject(fieldsToWriteOn, personRecordType);
const personRecord = await this.fetchPersonRecord(contactId, existingFields, personRecordType);
if (!personRecord) return;
const writeOnRecordBody = await this.buildRecordUpdatePayload({
existingFields,
personRecord,
onBookingWriteToRecordFields,
startTime,
bookingUid,
organizerEmail,
});
// Update the person record
await conn.sobject(personRecordType).update({
Id: contactId,
...writeOnRecordBody,
});
}
private async buildRecordUpdatePayload({
existingFields,
personRecord,
onBookingWriteToRecordFields,
startTime,
bookingUid,
organizerEmail,
}: {
existingFields: jsforce.Field[];
personRecord: Record<string, any>;
onBookingWriteToRecordFields: Record<string, any>;
startTime: string;
bookingUid?: string | null;
organizerEmail: string;
}): Promise<Record<string, any>> {
const writeOnRecordBody: Record<string, any> = {};
for (const field of existingFields) {
const fieldConfig = onBookingWriteToRecordFields[field.name];
// Skip if field should only be written when empty and already has a value
if (fieldConfig.whenToWrite === WhenToWriteToRecord.FIELD_EMPTY && personRecord[field.name]) {
continue;
}
// Handle different field types
if (fieldConfig.fieldType === field.type) {
if (field.type === SalesforceFieldType.TEXT) {
writeOnRecordBody[field.name] = fieldConfig.value.substring(0, field.length);
} else if (field.type === SalesforceFieldType.DATE) {
const dateValue = await this.getDateFieldValue(
fieldConfig.value,
startTime,
bookingUid,
organizerEmail
);
if (dateValue) {
writeOnRecordBody[field.name] = dateValue;
}
}
}
}
return writeOnRecordBody;
}
private async getDateFieldValue(
fieldValue: string,
startTime: string,
bookingUid?: string | null,
organizerEmail?: string
): Promise<string | null> {
if (fieldValue === DateFieldTypeData.BOOKING_START_DATE) {
return new Date(startTime).toISOString();
}
if (fieldValue === DateFieldTypeData.BOOKING_CREATED_DATE && bookingUid) {
const booking = await prisma.booking.findFirst({
where: { uid: bookingUid },
select: { createdAt: true },
});
if (!booking) {
this.log.warn(`No booking found for ${bookingUid}`);
return null;
}
return new Date(booking.createdAt).toISOString();
}
if (!bookingUid) {
this.log.warn(`No uid for booking with organizer ${organizerEmail}`);
}
return null;
}
private determinePersonRecordType(createEventOn: string): SalesforceRecordEnum {
return createEventOn === SalesforceRecordEnum.LEAD &&
this.appOptions.createEventOnLeadCheckForContact &&
this.getFallbackToContact()
? SalesforceRecordEnum.CONTACT
: this.appOptions.createEventOn;
}
private async fetchPersonRecord(
contactId: string,
existingFields: jsforce.Field[],
personRecordType: SalesforceRecordEnum
): Promise<Record<string, any> | null> {
const conn = await this.conn;
const existingFieldNames = existingFields.map((field) => field.name);
const query = await conn.query(
`SELECT ${existingFieldNames.join(", ")} FROM ${personRecordType} WHERE Id = '${contactId}'`
);
if (!query.records.length) {
this.log.warn(`Could not find person record with id ${contactId}`);
return null;
}
return query.records[0] as Record<string, any>;
}
}
@@ -0,0 +1,20 @@
export enum SalesforceRecordEnum {
CONTACT = "Contact",
LEAD = "Lead",
ACCOUNT = "Account",
}
export enum WhenToWriteToRecord {
EVERY_BOOKING = "every_booking",
FIELD_EMPTY = "field_empty",
}
export enum SalesforceFieldType {
DATE = "date",
TEXT = "string",
}
export enum DateFieldTypeData {
BOOKING_START_DATE = "booking_start_date",
BOOKING_CREATED_DATE = "booking_created_date",
}
@@ -1,5 +0,0 @@
export enum SalesforceRecordEnum {
CONTACT = "Contact",
LEAD = "Lead",
ACCOUNT = "Account",
}
+9 -1
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
import { SalesforceRecordEnum } from "./lib/recordEnum";
import { SalesforceRecordEnum, WhenToWriteToRecord, SalesforceFieldType } from "./lib/enums";
const writeToRecordEntry = z.object({
value: z.string(),
fieldType: z.nativeEnum(SalesforceFieldType),
whenToWrite: z.nativeEnum(WhenToWriteToRecord),
});
export const appDataSchema = eventTypeAppCardZod.extend({
roundRobinLeadSkip: z.boolean().optional(),
@@ -20,6 +26,8 @@ export const appDataSchema = eventTypeAppCardZod.extend({
onBookingChangeRecordOwnerName: z.string().optional(),
sendNoShowAttendeeData: z.boolean().optional(),
sendNoShowAttendeeDataField: z.string().optional(),
onBookingWriteToRecord: z.boolean().optional(),
onBookingWriteToRecordFields: z.record(z.string(), writeToRecordEntry).optional(),
});
export const appKeysSchema = z.object({