From e07a28a2d1befa474ff73a6f53101b8620a533c8 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 6 Nov 2024 03:20:52 -0500 Subject: [PATCH] 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 --- apps/web/public/static/locales/en/common.json | 7 + .../components/EventTypeAppCardInterface.tsx | 216 +++++++++++++++++- .../app-store/salesforce/lib/CrmService.ts | 163 ++++++++++++- packages/app-store/salesforce/lib/enums.ts | 20 ++ .../app-store/salesforce/lib/recordEnum.ts | 5 - packages/app-store/salesforce/zod.ts | 10 +- 6 files changed, 409 insertions(+), 12 deletions(-) create mode 100644 packages/app-store/salesforce/lib/enums.ts delete mode 100644 packages/app-store/salesforce/lib/recordEnum.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 812ba760a5..a39f1b9421 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx index 1e844706b8..684fce1c5a 100644 --- a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx @@ -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} +
+ { + setAppData("onBookingWriteToRecord", checked); + }} + /> + {onBookingWriteToRecord ? ( +
+
+
{t("field_name")}
+
{t("field_type")}
+
{t("value")}
+
{t("when_to_write")}
+
+
+ {...Object.keys(onBookingWriteToRecordFields).map((key) => ( +
+
+ +
+
+ option.value === onBookingWriteToRecordFields[key].value + )} + isDisabled={true} + /> + ) : ( + + )} +
+
+ { + if (e) { + setWriteToPersonObjectFieldType(e); + setNewOnBookingWriteToPersonObjectField({ + ...newOnBookingWriteToPersonObjectField, + fieldType: e.value, + ...(e.value === SalesforceFieldType.DATE && { value: dateFieldValue.value }), + }); + } + }} + /> +
+
+ {writeToPersonObjectFieldType.value === SalesforceFieldType.DATE ? ( + { + if (e) { + setWhenToWriteToPersonRecord(e); + setNewOnBookingWriteToPersonObjectField({ + ...newOnBookingWriteToPersonObjectField, + whenToWrite: e.value, + }); + } + }} + /> +
+
+
+ +
+ ) : null} +
+
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; + onBookingWriteToRecordFields: Record; + startTime: string; + bookingUid?: string | null; + organizerEmail: string; + }): Promise> { + const writeOnRecordBody: Record = {}; + + 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 { + 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 | 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; + } } diff --git a/packages/app-store/salesforce/lib/enums.ts b/packages/app-store/salesforce/lib/enums.ts new file mode 100644 index 0000000000..a072449e0a --- /dev/null +++ b/packages/app-store/salesforce/lib/enums.ts @@ -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", +} diff --git a/packages/app-store/salesforce/lib/recordEnum.ts b/packages/app-store/salesforce/lib/recordEnum.ts deleted file mode 100644 index b2b77eddec..0000000000 --- a/packages/app-store/salesforce/lib/recordEnum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum SalesforceRecordEnum { - CONTACT = "Contact", - LEAD = "Lead", - ACCOUNT = "Account", -} diff --git a/packages/app-store/salesforce/zod.ts b/packages/app-store/salesforce/zod.ts index a2c7e016ef..bfbeb8368a 100644 --- a/packages/app-store/salesforce/zod.ts +++ b/packages/app-store/salesforce/zod.ts @@ -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({