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) => (
+
+
+
+
+
+
+
+ {onBookingWriteToRecordFields[key].fieldType === SalesforceFieldType.DATE ? (
+
+
+
+
+
+
+ ))}
+
+
+
+ setNewOnBookingWriteToPersonObjectField({
+ ...newOnBookingWriteToPersonObjectField,
+ field: e.target.value,
+ })
+ }
+ />
+
+
+
+
+ {writeToPersonObjectFieldType.value === SalesforceFieldType.DATE ? (
+
+
+
+
+
+
+
+ ) : 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({