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:
co-authored by
Udit Takkar
Udit Takkar
parent
9a1b5ad149
commit
e07a28a2d1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user