feat: Hubspot write to meeting object (#26039)

* feat: Hubspot write to meeting object

* fix: translate hardcoded strings and remove debug console.log

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* refactor

* fix: improve static value detection for placeholder replacement

- Changed condition from startsWith/endsWith to includes('{') to properly detect embedded placeholders
- Strings like 'Hello {name}!' are now correctly processed for placeholder replacement
- Pure placeholder tokens that can't be resolved return null
- Partially resolved values are returned as-is

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* refactor: split WriteToObjectSettings into types and utils files

- Extract interfaces, enums, and type definitions to WriteToObjectSettings.types.ts
- Extract constants and utility functions to WriteToObjectSettings.utils.ts
- Update main component to use the new utility functions
- Improves code organization and maintainability

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* revert: restore original static value detection logic

Per user request - the startsWith/endsWith check was intentional

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* test: add unit tests for HubSpot CRM service

Tests cover:
- ensureFieldsExistOnMeeting: field validation against HubSpot properties
- getTextValueFromBookingTracking: UTM parameter extraction
- getTextValueFromBookingResponse: placeholder replacement
- getDateFieldValue: date field resolution for different date types
- getFieldValue: main field value resolution for all field types
- generateWriteToMeetingBody: write-to-meeting body generation
- getContacts: contact search functionality

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: correct type errors in HubSpot CRM service tests

- Import WhenToWrite enum from crm-enums
- Replace string literals with WhenToWrite.EVERY_BOOKING enum values
- Add missing whenToWrite property to field config objects

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: use type casting for intentional type errors in tests

- Cast numeric fieldValue to string for testing non-string handling
- Cast invalid field type string to CrmFieldType for testing unsupported types

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: prevent unhandled promise rejections in HubSpot CRM tests

- Re-apply mockGetAppKeysFromSlug implementation in beforeEach to ensure
  mock is always set correctly after clearAllMocks
- Change vi.resetAllMocks() to vi.clearAllMocks() to preserve mock
  implementations between tests
- Add explicit types to satisfy biome lint rules
- This fixes the 'Cannot read properties of undefined (reading client_id)'
  errors that were causing CI to fail with exit code 1

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* refactor: convert HubSpot tests to black-box testing through public methods

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: properly type mock CalendarEvent in HubSpot tests

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: properly type TFunction in mock CalendarEvent

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* chore: graceful owner fail test

* refactor

* fix: type check

* fix: remove null fields

* Update packages/app-store/hubspot/lib/CrmService.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Amit Sharma
2026-01-06 14:58:46 -03:00
committed by GitHub
co-authored by amit@cal.com <samit91848@gmail.com> amit@cal.com <samit91848@gmail.com> amit@cal.com <samit91848@gmail.com> amit@cal.com <samit91848@gmail.com> cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 8ad5f7bf55
commit eacfcd15a4
13 changed files with 1732 additions and 1 deletions
@@ -3189,6 +3189,8 @@
"rerouted_booking_successfully_redirecting_to_booking_page": "Rerouted booking successfully. Redirecting to booking page",
"on_booking_write_to_event_object": "On booking, write to event object",
"field_name": "Field name",
"field_name_cannot_be_empty": "Field name cannot be empty",
"field_already_exists": "Field already exists",
"add_new_field": "Add new field",
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
"form_response_not_found": "Form response not found",
@@ -0,0 +1,506 @@
import { useState, useMemo } from "react";
import { WhenToWrite } from "@calcom/app-store/_lib/crm-enums";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { InputField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
import { showToast } from "@calcom/ui/components/toast";
import type { WriteToRecordEntrySchema, WriteToObjectSettingsProps } from "./WriteToObjectSettings.types";
import {
DATE_FIELD_TYPE,
CHECKBOX_FIELD_TYPE,
buildFieldTypeOptions,
buildDateFieldValueOptions,
buildWhenToWriteOptions,
buildCheckboxFieldValueOptions,
} from "./WriteToObjectSettings.utils";
const WriteToObjectSettings = ({
bookingAction,
optionLabel,
optionEnabled,
optionSwitchOnChange,
writeToObjectData,
updateWriteToObjectData,
supportedFieldTypes,
supportedDateFields,
supportedWriteTriggers = [WhenToWrite.EVERY_BOOKING, WhenToWrite.FIELD_EMPTY],
}: WriteToObjectSettingsProps) => {
const { t } = useLocale();
const fieldTypeOptions = useMemo(
() => buildFieldTypeOptions(supportedFieldTypes, t),
[supportedFieldTypes, t]
);
const dateFieldValueOptions = useMemo(
() => buildDateFieldValueOptions(bookingAction, supportedDateFields, t),
[supportedDateFields, bookingAction, t]
);
const whenToWriteOptions = useMemo(
() => buildWhenToWriteOptions(supportedWriteTriggers, bookingAction, t),
[supportedWriteTriggers, bookingAction, t]
);
const showWhenToWriteColumn = whenToWriteOptions.length > 1;
const checkboxFieldValueOptions = useMemo(() => buildCheckboxFieldValueOptions(t), [t]);
const [fieldTypeSelectedOption, setFieldTypeSelectedOption] = useState(fieldTypeOptions[0]);
const [dateFieldSelectedOption, setDateFieldSelectedOption] = useState(dateFieldValueOptions[0]);
const [checkboxFieldSelectedOption, setCheckboxFieldSelectedOption] = useState(
checkboxFieldValueOptions[0]
);
const [whenToWriteSelectedOption, setWhenToWriteSelectedOption] = useState(whenToWriteOptions[0]);
const [editingRows, setEditingRows] = useState<Record<string, boolean>>({});
const [editingData, setEditingData] = useState<Record<string, WriteToRecordEntrySchema>>({});
const [newOnWriteToRecordEntry, setNewOnWriteToRecordEntry] = useState<WriteToRecordEntrySchema>({
field: "",
fieldType: fieldTypeSelectedOption.value,
value: "",
whenToWrite: whenToWriteSelectedOption.value,
});
const startEditing = (key: string) => {
Object.keys(editingRows).forEach((rowKey) => {
if (editingRows[rowKey] && rowKey !== key) {
cancelEditing(rowKey);
}
});
setEditingRows((prev) => ({ ...prev, [key]: true }));
setEditingData((prev) => ({
...prev,
[key]: {
field: key,
fieldType: writeToObjectData[key].fieldType,
value: writeToObjectData[key].value,
whenToWrite: writeToObjectData[key].whenToWrite,
},
}));
};
const cancelEditing = (key: string) => {
setEditingRows((prev) => ({ ...prev, [key]: false }));
setEditingData((prev) => {
const newData = { ...prev };
delete newData[key];
return newData;
});
};
const saveEditing = (key: string) => {
const editData = editingData[key];
if (!editData) return;
if (!editData.field.trim()) {
showToast(t("field_name_cannot_be_empty"), "error");
return;
}
if (editData.field !== key && Object.keys(writeToObjectData).includes(editData.field.trim())) {
showToast(t("field_already_exists"), "error");
return;
}
const newWriteToObjectData = { ...writeToObjectData };
if (editData.field !== key) {
delete newWriteToObjectData[key];
}
newWriteToObjectData[editData.field.trim()] = {
fieldType: editData.fieldType,
value: editData.value,
whenToWrite: editData.whenToWrite,
};
updateWriteToObjectData(newWriteToObjectData);
cancelEditing(key);
};
return (
<>
<Section.SubSectionHeader icon="star" labelFor="write-to-object-settings" title={optionLabel}>
<Switch
checked={optionEnabled}
onCheckedChange={optionSwitchOnChange}
id="write-to-object-settings"
size="sm"
/>
</Section.SubSectionHeader>
{optionEnabled ? (
<Section.SubSectionContent>
<div className="text-subtle flex gap-3 px-3 py-[6px] text-sm font-medium">
<div className="flex-1">{t("field_name")}</div>
<div className="flex-1">{t("field_type")}</div>
<div className="flex-1">{t("value")}</div>
{showWhenToWriteColumn && <div className="flex-1">{t("when_to_write")}</div>}
<div className="w-20" />
</div>
<Section.SubSectionNested>
{Object.keys(writeToObjectData).map((key) => {
const isEditing = editingRows[key];
const editData = editingData[key];
return (
<div className="flex items-center gap-2" key={key}>
<div className="flex-1">
{isEditing ? (
<InputField
value={editData?.field || key}
onChange={(e) =>
setEditingData((prev) => ({
...prev,
[key]: { ...editData, field: e.target.value },
}))
}
size="sm"
className="w-full"
/>
) : (
<InputField value={key} readOnly size="sm" className="w-full" />
)}
</div>
<div className="flex-1">
{isEditing ? (
<Select
size="sm"
className="w-full"
options={fieldTypeOptions}
value={fieldTypeOptions.find((option) => option.value === editData?.fieldType)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: {
...editData,
fieldType: e.value,
...(e.value === DATE_FIELD_TYPE && {
value: dateFieldValueOptions[0].value,
}),
...(e.value === CHECKBOX_FIELD_TYPE && {
value: checkboxFieldValueOptions[0].value,
}),
},
}));
}
}}
/>
) : (
<Select
size="sm"
className="w-full"
value={fieldTypeOptions.find(
(option) => option.value === writeToObjectData[key].fieldType
)}
isDisabled={true}
/>
)}
</div>
<div className="flex-1">
{isEditing ? (
editData?.fieldType === DATE_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={dateFieldValueOptions}
value={dateFieldValueOptions.find((option) => option.value === editData.value)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: { ...editData, value: e.value },
}));
}
}}
/>
) : editData?.fieldType === CHECKBOX_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={checkboxFieldValueOptions}
value={checkboxFieldValueOptions.find((option) => option.value === editData.value)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: { ...editData, value: e.value },
}));
}
}}
/>
) : (
<InputField
value={(editData?.value as string) || ""}
onChange={(e) =>
setEditingData((prev) => ({
...prev,
[key]: { ...editData, value: e.target.value },
}))
}
size="sm"
className="w-full"
/>
)
) : writeToObjectData[key].fieldType === DATE_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
value={dateFieldValueOptions.find(
(option) => option.value === writeToObjectData[key].value
)}
isDisabled={true}
/>
) : writeToObjectData[key].fieldType === CHECKBOX_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
value={checkboxFieldValueOptions.find(
(option) => option.value === writeToObjectData[key].value
)}
isDisabled={true}
/>
) : (
<InputField
value={writeToObjectData[key].value as string}
readOnly
size="sm"
className="w-full"
/>
)}
</div>
{showWhenToWriteColumn && (
<div className="flex-1">
{isEditing ? (
<Select
size="sm"
className="w-full"
options={whenToWriteOptions}
value={whenToWriteOptions.find((option) => option.value === editData?.whenToWrite)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: { ...editData, whenToWrite: e.value },
}));
}
}}
/>
) : (
<Select
size="sm"
className="w-full"
value={whenToWriteOptions.find(
(option) => option.value === writeToObjectData[key].whenToWrite
)}
isDisabled={true}
/>
)}
</div>
)}
<div className="flex w-20 justify-center gap-1">
{isEditing ? (
<>
<Button
size="sm"
StartIcon="check"
variant="icon"
color="primary"
onClick={() => saveEditing(key)}
/>
<Button
size="sm"
StartIcon="x"
variant="icon"
color="secondary"
onClick={() => cancelEditing(key)}
/>
</>
) : (
<>
<Button
size="sm"
StartIcon="pencil"
variant="icon"
color="minimal"
onClick={() => startEditing(key)}
/>
<Button
size="sm"
StartIcon="x"
variant="icon"
color="minimal"
onClick={() => {
const newObject = { ...writeToObjectData };
delete newObject[key];
updateWriteToObjectData(newObject);
}}
/>
</>
)}
</div>
</div>
);
})}
<div className="flex gap-2">
<div className="flex-1">
<InputField
size="sm"
className="w-full"
value={newOnWriteToRecordEntry.field}
onChange={(e) =>
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
field: e.target.value,
})
}
/>
</div>
<div className="flex-1">
<Select
size="sm"
className="w-full"
options={fieldTypeOptions}
value={fieldTypeSelectedOption}
onChange={(e) => {
if (e) {
setFieldTypeSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
fieldType: e.value,
...(e.value === DATE_FIELD_TYPE && { value: dateFieldSelectedOption.value }),
...(e.value === CHECKBOX_FIELD_TYPE && {
value: checkboxFieldSelectedOption.value,
}),
});
}
}}
/>
</div>
<div className="flex-1">
{newOnWriteToRecordEntry.fieldType === DATE_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={dateFieldValueOptions}
value={dateFieldSelectedOption}
onChange={(e) => {
if (e) {
setDateFieldSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
value: e.value,
});
}
}}
/>
) : newOnWriteToRecordEntry.fieldType === CHECKBOX_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={checkboxFieldValueOptions}
value={checkboxFieldSelectedOption}
onChange={(e) => {
if (e) {
setCheckboxFieldSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
value: e.value,
});
}
}}
/>
) : (
<InputField
size="sm"
className="w-full"
value={newOnWriteToRecordEntry.value as string}
onChange={(e) =>
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
value: e.target.value,
})
}
/>
)}
</div>
{showWhenToWriteColumn && (
<div className="flex-1">
<Select
size="sm"
className="w-full"
options={whenToWriteOptions}
value={whenToWriteSelectedOption}
onChange={(e) => {
if (e) {
setWhenToWriteSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
whenToWrite: e.value,
});
}
}}
/>
</div>
)}
<div className="w-20" />
</div>
</Section.SubSectionNested>
<Button
className="text-subtle mt-2 w-fit"
StartIcon="plus"
color="minimal"
size="sm"
disabled={
!(
newOnWriteToRecordEntry.field &&
newOnWriteToRecordEntry.fieldType &&
newOnWriteToRecordEntry.value !== "" &&
newOnWriteToRecordEntry.whenToWrite
)
}
onClick={() => {
if (Object.keys(writeToObjectData).includes(newOnWriteToRecordEntry.field.trim())) {
showToast(t("field_already_exists"), "error");
return;
}
updateWriteToObjectData({
...writeToObjectData,
[newOnWriteToRecordEntry.field.trim()]: {
fieldType: newOnWriteToRecordEntry.fieldType,
value: newOnWriteToRecordEntry.value,
whenToWrite: newOnWriteToRecordEntry.whenToWrite,
},
});
setNewOnWriteToRecordEntry({
field: "",
fieldType: fieldTypeOptions[0].value,
value: "",
whenToWrite: whenToWriteOptions[0].value,
});
}}>
{t("add_new_field")}
</Button>
</Section.SubSectionContent>
) : null}
</>
);
};
export {
BookingActionEnum,
type SelectOption,
type WriteToRecordEntry,
type WriteToRecordEntrySchema,
type WriteToObjectSettingsProps,
} from "./WriteToObjectSettings.types";
export default WriteToObjectSettings;
@@ -0,0 +1,38 @@
import { CrmFieldType, WhenToWrite } from "@calcom/app-store/_lib/crm-enums";
export { CrmFieldType, DateFieldType, WhenToWrite } from "@calcom/app-store/_lib/crm-enums";
export enum BookingActionEnum {
ON_BOOKING = "on_booking",
ON_CANCEL = "on_cancel",
}
export interface SelectOption<T = string> {
label: string;
value: T;
}
export interface WriteToRecordEntry {
fieldType: CrmFieldType;
value: string | boolean;
whenToWrite: WhenToWrite;
}
export interface WriteToRecordEntrySchema {
field: string;
fieldType: CrmFieldType;
value: string | boolean;
whenToWrite: WhenToWrite;
}
export interface WriteToObjectSettingsProps {
bookingAction: BookingActionEnum;
optionLabel: string;
optionEnabled: boolean;
optionSwitchOnChange: (checked: boolean) => void;
writeToObjectData: Record<string, WriteToRecordEntry>;
updateWriteToObjectData: (data: Record<string, WriteToRecordEntry>) => void;
supportedFieldTypes: readonly CrmFieldType[];
supportedDateFields?: readonly import("@calcom/app-store/_lib/crm-enums").DateFieldType[];
supportedWriteTriggers?: readonly WhenToWrite[];
}
@@ -0,0 +1,70 @@
import { CrmFieldType, DateFieldType, WhenToWrite } from "@calcom/app-store/_lib/crm-enums";
import type { SelectOption } from "./WriteToObjectSettings.types";
import { BookingActionEnum } from "./WriteToObjectSettings.types";
export const DATE_FIELD_TYPE = CrmFieldType.DATE;
export const CHECKBOX_FIELD_TYPE = CrmFieldType.CHECKBOX;
export const FIELD_TYPE_LABELS: Record<CrmFieldType, string> = {
[CrmFieldType.TEXT]: "text",
[CrmFieldType.STRING]: "text",
[CrmFieldType.DATE]: "date",
[CrmFieldType.DATETIME]: "datetime",
[CrmFieldType.PHONE]: "phone",
[CrmFieldType.CHECKBOX]: "checkbox",
[CrmFieldType.PICKLIST]: "picklist",
[CrmFieldType.CUSTOM]: "custom",
[CrmFieldType.TEXTAREA]: "textarea",
};
export const DATE_FIELD_LABEL_MAP: Record<DateFieldType, string> = {
[DateFieldType.BOOKING_CANCEL_DATE]: "booking_cancel_date",
[DateFieldType.BOOKING_START_DATE]: "booking_start_date",
[DateFieldType.BOOKING_CREATED_DATE]: "booking_created_date",
};
export const getWhenToWriteLabelMap = (
bookingAction: BookingActionEnum
): Record<WhenToWrite, string> => ({
[WhenToWrite.EVERY_BOOKING]:
bookingAction === BookingActionEnum.ON_CANCEL ? "salesforce_on_every_cancellation" : "on_every_booking",
[WhenToWrite.FIELD_EMPTY]: "only_if_field_is_empty",
});
export const buildFieldTypeOptions = (
supportedFieldTypes: readonly CrmFieldType[],
t: (key: string) => string
): SelectOption<CrmFieldType>[] =>
supportedFieldTypes.map((type) => ({
label: t(FIELD_TYPE_LABELS[type]),
value: type,
}));
export const buildDateFieldValueOptions = (
bookingAction: BookingActionEnum,
supportedDateFields: readonly DateFieldType[] | undefined,
t: (key: string) => string
): SelectOption<DateFieldType>[] => {
const defaultDateFields =
bookingAction === BookingActionEnum.ON_CANCEL
? [DateFieldType.BOOKING_CANCEL_DATE, DateFieldType.BOOKING_START_DATE, DateFieldType.BOOKING_CREATED_DATE]
: [DateFieldType.BOOKING_START_DATE, DateFieldType.BOOKING_CREATED_DATE];
const fields = supportedDateFields ?? defaultDateFields;
return fields.map((type) => ({ label: t(DATE_FIELD_LABEL_MAP[type]), value: type }));
};
export const buildWhenToWriteOptions = (
supportedWriteTriggers: readonly WhenToWrite[],
bookingAction: BookingActionEnum,
t: (key: string) => string
): SelectOption<WhenToWrite>[] => {
const labelMap = getWhenToWriteLabelMap(bookingAction);
return supportedWriteTriggers.map((trigger) => ({ label: t(labelMap[trigger]), value: trigger }));
};
export const buildCheckboxFieldValueOptions = (t: (key: string) => string): SelectOption<boolean>[] => [
{ label: t("true"), value: true },
{ label: t("false"), value: false },
];
+37
View File
@@ -0,0 +1,37 @@
/**
* CRM field types supported across different CRM integrations.
*
* Implementation notes:
* - TEXT, STRING, PHONE, TEXTAREA, CUSTOM: All handled identically as text fields
* - DATE, DATETIME: Both handled identically, return ISO 8601 datetime strings
* - CHECKBOX: Returns boolean values (true/false)
* - PICKLIST: Dropdown/select fields
*/
export enum CrmFieldType {
TEXT = "text",
STRING = "string",
DATE = "date",
DATETIME = "datetime",
PHONE = "phone",
CHECKBOX = "checkbox",
PICKLIST = "picklist",
CUSTOM = "custom",
TEXTAREA = "textarea",
}
export enum DateFieldType {
BOOKING_START_DATE = "booking_start_date",
BOOKING_CREATED_DATE = "booking_created_date",
BOOKING_CANCEL_DATE = "booking_cancel_date",
}
/**
* Controls when CRM field values should be written.
*
* - EVERY_BOOKING: Always write the value on every booking
* - FIELD_EMPTY: Only write if the field is currently empty (prevents overwrites)
*/
export enum WhenToWrite {
EVERY_BOOKING = "every_booking",
FIELD_EMPTY = "field_empty",
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
import { CrmFieldType, WhenToWrite } from "./crm-enums";
export const writeToBookingEntry = z.object({
value: z.union([z.string(), z.boolean()]),
fieldType: z.nativeEnum(CrmFieldType),
whenToWrite: z.nativeEnum(WhenToWrite),
});
export const writeToRecordEntrySchema = z.object({
field: z.string(),
fieldType: z.nativeEnum(CrmFieldType),
value: z.union([z.string(), z.boolean()]),
whenToWrite: z.nativeEnum(WhenToWrite),
});
@@ -2,6 +2,10 @@ import { usePathname } from "next/navigation";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import { CrmFieldType } from "@calcom/app-store/_lib/crm-enums";
import WriteToObjectSettings, {
BookingActionEnum,
} from "@calcom/app-store/_components/crm/WriteToObjectSettings";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -10,6 +14,7 @@ import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
import type { appDataSchema } from "../zod";
import { WhenToWrite } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
app,
@@ -23,6 +28,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const ignoreGuests = getAppData("ignoreGuests") ?? false;
const skipContactCreation = getAppData("skipContactCreation") ?? false;
const onBookingWriteToEventObject = getAppData("onBookingWriteToEventObject") ?? false;
const onBookingWriteToEventObjectFields = getAppData("onBookingWriteToEventObjectFields") ?? {};
return (
<AppCard
@@ -66,6 +73,27 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
/>
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<WriteToObjectSettings
bookingAction={BookingActionEnum.ON_BOOKING}
optionLabel={t("on_booking_write_to_event_object")}
optionEnabled={onBookingWriteToEventObject}
writeToObjectData={onBookingWriteToEventObjectFields}
optionSwitchOnChange={(checked) => {
setAppData("onBookingWriteToEventObject", checked);
}}
updateWriteToObjectData={(data) => setAppData("onBookingWriteToEventObjectFields", data)}
supportedFieldTypes={[
CrmFieldType.TEXT,
CrmFieldType.DATE,
CrmFieldType.PHONE,
CrmFieldType.CHECKBOX,
CrmFieldType.CUSTOM,
]}
supportedWriteTriggers={[WhenToWrite.EVERY_BOOKING]}
/>
</Section.SubSection>
</Section.Content>
</AppCard>
);
@@ -0,0 +1,797 @@
import type { TFunction } from "i18next";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import { CrmFieldType, DateFieldType, WhenToWrite } from "../../_lib/crm-enums";
import type { appDataSchema } from "../zod";
type AppOptions = z.infer<typeof appDataSchema>;
// Create hoisted mocks that will be available before module imports
const {
mockHubspotClient,
mockGetAppKeysFromSlug,
}: {
mockHubspotClient: {
crm: {
properties: { coreApi: { getAll: ReturnType<typeof vi.fn> } };
contacts: {
searchApi: { doSearch: ReturnType<typeof vi.fn> };
basicApi: { create: ReturnType<typeof vi.fn> };
};
objects: {
meetings: {
basicApi: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
archive: ReturnType<typeof vi.fn>;
};
};
};
associations: { batchApi: { create: ReturnType<typeof vi.fn> } };
owners: { ownersApi: { getPage: ReturnType<typeof vi.fn> } };
};
oauth: { tokensApi: { createToken: ReturnType<typeof vi.fn> } };
setAccessToken: ReturnType<typeof vi.fn>;
};
mockGetAppKeysFromSlug: ReturnType<typeof vi.fn>;
} = vi.hoisted(() => {
const mockHubspotClient = {
crm: {
properties: {
coreApi: {
getAll: vi.fn(),
},
},
contacts: {
searchApi: {
doSearch: vi.fn(),
},
basicApi: {
create: vi.fn(),
},
},
objects: {
meetings: {
basicApi: {
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
},
},
},
associations: {
batchApi: {
create: vi.fn(),
},
},
owners: {
ownersApi: {
getPage: vi.fn(),
},
},
},
oauth: {
tokensApi: {
createToken: vi.fn(),
},
},
setAccessToken: vi.fn(),
};
const mockGetAppKeysFromSlug = vi.fn().mockResolvedValue({
client_id: "mock_client_id",
client_secret: "mock_client_secret",
});
return { mockHubspotClient, mockGetAppKeysFromSlug };
});
vi.mock("@hubspot/api-client", () => {
return {
Client: class {
crm = mockHubspotClient.crm;
oauth = mockHubspotClient.oauth;
setAccessToken = mockHubspotClient.setAccessToken;
},
};
});
vi.mock("../../_utils/getAppKeysFromSlug", () => ({
default: mockGetAppKeysFromSlug,
}));
vi.mock("@calcom/prisma", () => ({
default: {
credential: {
update: vi.fn(),
},
},
}));
import HubspotCalendarService from "./CrmService";
describe("HubspotCalendarService", () => {
let service: HubspotCalendarService;
let mockTrackingRepository: {
findByBookingUid: ReturnType<typeof vi.fn>;
};
let mockBookingRepository: {
findBookingByUid: ReturnType<typeof vi.fn>;
};
setupAndTeardown();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T10:00:00.000Z"));
// Re-apply mock implementation for getAppKeysFromSlug (needed after clearAllMocks)
mockGetAppKeysFromSlug.mockResolvedValue({
client_id: "mock_client_id",
client_secret: "mock_client_secret",
});
mockTrackingRepository = {
findByBookingUid: vi.fn(),
};
mockBookingRepository = {
findBookingByUid: vi.fn(),
};
const mockCredential: CredentialPayload = {
id: 1,
type: "hubspot_other_calendar",
key: {
accessToken: "mock_token",
refreshToken: "mock_refresh",
expiryDate: Date.now() + 3600000,
tokenType: "Bearer",
},
userId: 1,
appId: "hubspot",
teamId: null,
invalid: false,
user: {
email: "test-user@example.com",
},
delegationCredentialId: null,
};
service = new HubspotCalendarService(mockCredential, {});
// @ts-expect-error - Injecting mock repositories for testing
service.trackingRepository = mockTrackingRepository;
// @ts-expect-error - Injecting mock repositories for testing
service.bookingRepository = mockBookingRepository;
// @ts-expect-error - Injecting mock hubspot client for testing
service.hubspotClient = mockHubspotClient;
// @ts-expect-error - Mocking auth promise to prevent unhandled rejections
service.auth = Promise.resolve({ getToken: vi.fn() });
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function mockAppOptions(appOptions: AppOptions): void {
const appOptionsSpy = vi.spyOn(service, "getAppOptions");
appOptionsSpy.mockReturnValue(appOptions);
}
// Helper to create a mock CalendarEvent
function createMockEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
type: "test-event",
title: "Test Meeting",
startTime: "2024-01-20T14:00:00.000Z",
endTime: "2024-01-20T15:00:00.000Z",
uid: "booking-123",
responses: {
name: { label: "Name", value: "John Doe" },
email: { label: "Email", value: "john@example.com" },
},
// customInputs is required by getLabelValueMapFromResponses when userFieldsResponses is not present
customInputs: {},
organizer: {
email: "organizer@example.com",
name: "Organizer",
timeZone: "America/New_York",
language: {
translate: ((key: string) => key) as TFunction,
locale: "en",
},
},
attendees: [
{
email: "attendee@example.com",
name: "Attendee",
timeZone: "America/New_York",
language: {
translate: ((key: string) => key) as TFunction,
locale: "en",
},
},
],
...overrides,
};
}
// Helper to setup common mocks for createEvent tests
function setupCreateEventMocks(): void {
// Mock owner lookup - return empty results (no owner found)
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [] });
// Mock meeting creation
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
// Mock association creation
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
}
describe("getContacts", () => {
it("should return contacts when found", async () => {
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{ id: "contact-1", properties: { email: "test@example.com" } },
{ id: "contact-2", properties: { email: "test2@example.com" } },
],
});
const result = await service.getContacts({ emails: ["test@example.com", "test2@example.com"] });
expect(result).toEqual([
{ id: "contact-1", email: "test@example.com" },
{ id: "contact-2", email: "test2@example.com" },
]);
});
it("should handle single email string input", async () => {
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [{ id: "contact-1", properties: { email: "test@example.com" } }],
});
const result = await service.getContacts({ emails: "test@example.com" });
expect(result).toEqual([{ id: "contact-1", email: "test@example.com" }]);
});
it("should return empty array when no contacts found", async () => {
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [],
});
const result = await service.getContacts({ emails: "nonexistent@example.com" });
expect(result).toEqual([]);
});
});
describe("createContacts", () => {
it("should create contacts successfully", async () => {
mockHubspotClient.crm.contacts.basicApi.create.mockResolvedValueOnce({
id: "new-contact-1",
properties: { email: "new@example.com", firstname: "New", lastname: "Contact" },
});
const result = await service.createContacts([{ name: "New Contact", email: "new@example.com" }]);
expect(result).toEqual([{ id: "new-contact-1", email: "new@example.com" }]);
expect(mockHubspotClient.crm.contacts.basicApi.create).toHaveBeenCalledWith({
properties: {
firstname: "New",
lastname: "Contact",
email: "new@example.com",
},
});
});
it("should handle existing contact error gracefully", async () => {
mockHubspotClient.crm.contacts.basicApi.create.mockRejectedValueOnce({
body: { message: "Contact already exists. Existing ID: existing-contact-123" },
});
const result = await service.createContacts([
{ name: "Existing Contact", email: "existing@example.com" },
]);
expect(result).toEqual([{ id: "existing-contact-123", email: "existing@example.com" }]);
});
});
describe("createEvent", () => {
it("should create a meeting without custom fields when feature is disabled", async () => {
mockAppOptions({});
setupCreateEventMocks();
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
// Verify meeting was created with standard properties only
expect(mockHubspotClient.crm.objects.meetings.basicApi.create).toHaveBeenCalledWith({
properties: expect.objectContaining({
hs_meeting_title: "Test Meeting",
hs_meeting_outcome: "SCHEDULED",
}),
});
// Verify no custom fields were included
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties).not.toHaveProperty("custom_name");
});
it("should include custom text field with static value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
custom_source: {
fieldType: CrmFieldType.TEXT,
value: "Cal.com Booking",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
// Mock field validation - field exists on HubSpot
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "custom_source", type: "string" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.custom_source).toBe("Cal.com Booking");
});
it("should include custom text field with booking response placeholder", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
attendee_name: {
fieldType: CrmFieldType.TEXT,
value: "{name}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "attendee_name", type: "string" }],
});
const event = createMockEvent({
responses: {
name: { label: "Name", value: "John Doe" },
},
});
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.attendee_name).toBe("John Doe");
});
it("should include custom text field with UTM tracking value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
utm_source_field: {
fieldType: CrmFieldType.TEXT,
value: "{utm:source}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "utm_source_field", type: "string" }],
});
mockTrackingRepository.findByBookingUid.mockResolvedValueOnce({
utm_source: "google_ads",
utm_medium: "cpc",
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.utm_source_field).toBe("google_ads");
});
it("should include checkbox field with boolean value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
is_confirmed: {
fieldType: CrmFieldType.CHECKBOX,
value: true,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "is_confirmed", type: "bool" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.is_confirmed).toBe(true);
});
it("should include date field with booking start date", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
meeting_date: {
fieldType: CrmFieldType.DATE,
value: DateFieldType.BOOKING_START_DATE,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "meeting_date", type: "date" }],
});
const event = createMockEvent({ startTime: "2024-01-20T14:00:00.000Z" });
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.meeting_date).toBe("2024-01-20T14:00:00.000Z");
});
it("should include date field with booking created date", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
created_date: {
fieldType: CrmFieldType.DATE,
value: DateFieldType.BOOKING_CREATED_DATE,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "created_date", type: "date" }],
});
mockBookingRepository.findBookingByUid.mockResolvedValueOnce({
id: 1,
uid: "booking-123",
createdAt: new Date("2024-01-10T09:00:00.000Z"),
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.created_date).toBe("2024-01-10T09:00:00.000Z");
});
it("should include phone field with static value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
contact_phone: {
fieldType: CrmFieldType.PHONE,
value: "+1234567890",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "contact_phone", type: "phone" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.contact_phone).toBe("+1234567890");
});
it("should skip fields that do not exist on HubSpot meeting object", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
existing_field: {
fieldType: CrmFieldType.TEXT,
value: "exists",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
nonexistent_field: {
fieldType: CrmFieldType.TEXT,
value: "does not exist",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
// Only return existing_field from HubSpot properties
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "existing_field", type: "string" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.existing_field).toBe("exists");
expect(createCall.properties).not.toHaveProperty("nonexistent_field");
});
it("should handle multiple custom fields of different types", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
text_field: {
fieldType: CrmFieldType.TEXT,
value: "Static Text",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
name_field: {
fieldType: CrmFieldType.TEXT,
value: "{name}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
checkbox_field: {
fieldType: CrmFieldType.CHECKBOX,
value: true,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
date_field: {
fieldType: CrmFieldType.DATE,
value: DateFieldType.BOOKING_START_DATE,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [
{ name: "text_field", type: "string" },
{ name: "name_field", type: "string" },
{ name: "checkbox_field", type: "bool" },
{ name: "date_field", type: "date" },
],
});
const event = createMockEvent({
responses: { name: { label: "Name", value: "Jane Smith" } },
startTime: "2024-02-15T10:00:00.000Z",
});
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.text_field).toBe("Static Text");
expect(createCall.properties.name_field).toBe("Jane Smith");
expect(createCall.properties.checkbox_field).toBe(true);
expect(createCall.properties.date_field).toBe("2024-02-15T10:00:00.000Z");
});
it("should skip fields with unknown placeholders that cannot be resolved", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
unknown_field: {
fieldType: CrmFieldType.TEXT,
value: "{unknown_placeholder}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "unknown_field", type: "string" }],
});
const event = createMockEvent({
responses: { name: { label: "Name", value: "John" } },
});
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
// Field with null value should be filtered out and not sent to HubSpot
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties).not.toHaveProperty("unknown_field");
});
it("should associate meeting with contacts", async () => {
mockAppOptions({});
setupCreateEventMocks();
const event = createMockEvent();
const contacts = [
{ id: "contact-1", email: "attendee1@example.com" },
{ id: "contact-2", email: "attendee2@example.com" },
];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.associations.batchApi.create).toHaveBeenCalledWith(
"meetings",
"contacts",
{
inputs: [
{ _from: { id: "meeting-123" }, to: { id: "contact-1" }, type: "meeting_event_to_contact" },
{ _from: { id: "meeting-123" }, to: { id: "contact-2" }, type: "meeting_event_to_contact" },
],
}
);
});
it("should set hubspot owner when organizer email matches", async () => {
mockAppOptions({});
// Mock owner lookup - return matching owner
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "organizer@example.com" }],
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBe("owner-123");
});
it("should create meeting successfully when owner lookup fails due to missing scope", async () => {
mockAppOptions({});
// Mock owner lookup to fail with 403 missing scope error (simulating owners.read scope not granted)
mockHubspotClient.crm.owners.ownersApi.getPage.mockRejectedValue({
code: 403,
body: {
status: "error",
message: "This app hasn't been granted all required scopes to make this call.",
category: "MISSING_SCOPES",
},
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
// Should not throw and meeting should be created successfully
const result = await service.createEvent(event, contacts);
expect(result).toEqual({
uid: "meeting-123",
id: "meeting-123",
type: "hubspot_other_calendar",
password: "",
url: "",
additionalInfo: { contacts, associatedMeeting: { results: [] } },
});
// Verify meeting was created without owner
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBeUndefined();
// Verify meeting creation and association still happened
expect(mockHubspotClient.crm.objects.meetings.basicApi.create).toHaveBeenCalled();
expect(mockHubspotClient.crm.associations.batchApi.create).toHaveBeenCalled();
});
});
describe("updateEvent", () => {
it("should update meeting with standard properties", async () => {
mockHubspotClient.crm.objects.meetings.basicApi.update.mockResolvedValue({
id: "meeting-123",
properties: {},
});
const event = createMockEvent({
title: "Updated Meeting",
startTime: "2024-01-25T14:00:00.000Z",
endTime: "2024-01-25T15:00:00.000Z",
});
await service.updateEvent("meeting-123", event);
expect(mockHubspotClient.crm.objects.meetings.basicApi.update).toHaveBeenCalledWith("meeting-123", {
properties: expect.objectContaining({
hs_meeting_title: "Updated Meeting",
hs_meeting_outcome: "RESCHEDULED",
}),
});
});
});
describe("deleteEvent", () => {
it("should cancel meeting when organizer has not changed", async () => {
mockHubspotClient.crm.objects.meetings.basicApi.update.mockResolvedValue({
id: "meeting-123",
properties: {},
});
const event = createMockEvent({ hasOrganizerChanged: false });
await service.deleteEvent("meeting-123", event);
expect(mockHubspotClient.crm.objects.meetings.basicApi.update).toHaveBeenCalledWith("meeting-123", {
properties: {
hs_meeting_outcome: "CANCELED",
},
});
});
it("should archive meeting when organizer has changed", async () => {
mockHubspotClient.crm.objects.meetings.basicApi.archive.mockResolvedValue(undefined);
const event = createMockEvent({ hasOrganizerChanged: true });
await service.deleteEvent("meeting-123", event);
expect(mockHubspotClient.crm.objects.meetings.basicApi.archive).toHaveBeenCalledWith("meeting-123");
});
});
});
+208 -1
View File
@@ -14,12 +14,15 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM, ContactCreateInput, Contact, CrmEvent } from "@calcom/types/CrmService";
import { CrmFieldType, DateFieldType } from "../../_lib/crm-enums";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { PrismaTrackingRepository } from "@calcom/lib/server/repository/PrismaTrackingRepository";
import type { HubspotToken } from "../api/callback";
import type { appDataSchema } from "../zod";
@@ -32,6 +35,8 @@ export default class HubspotCalendarService implements CRM {
private client_secret = "";
private hubspotClient: hubspot.Client;
private appOptions: z.infer<typeof appDataSchema>;
private bookingRepository: BookingRepository;
private trackingRepository: PrismaTrackingRepository;
constructor(credential: CredentialPayload, appOptions?: z.infer<typeof appDataSchema>) {
this.hubspotClient = new hubspot.Client();
@@ -43,6 +48,8 @@ export default class HubspotCalendarService implements CRM {
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.appOptions = appOptions || {};
this.bookingRepository = new BookingRepository(prisma);
this.trackingRepository = new PrismaTrackingRepository(prisma);
}
private getHubspotMeetingBody = (event: CalendarEvent): string => {
@@ -72,7 +79,206 @@ export default class HubspotCalendarService implements CRM {
`;
};
private async ensureFieldsExistOnMeeting(fieldsToTest: string[]) {
const log = logger.getSubLogger({ prefix: [`[ensureFieldsExistOnMeeting]`] });
const fieldSet = new Set(fieldsToTest);
const foundFields: Array<{ name: string; type: string;[key: string]: any }> = [];
try {
const properties = await this.hubspotClient.crm.properties.coreApi.getAll("meetings");
for (const property of properties.results) {
if (foundFields.length === fieldSet.size) break;
if (fieldSet.has(property.name)) {
foundFields.push(property);
}
}
const foundFieldNames = new Set(foundFields.map((f) => f.name));
const missingFields = fieldsToTest.filter((field) => !foundFieldNames.has(field));
if (missingFields.length > 0) {
log.warn(
`The following fields do not exist in HubSpot and will be skipped: ${missingFields.join(", ")}. Meeting creation will continue without these fields.`
);
}
return foundFields;
} catch (e: any) {
log.error(`Error ensuring fields ${fieldsToTest} exist on Meeting object with error ${e}`);
// Return empty array to gracefully degrade - meeting creation will proceed without custom field validation
return [];
}
}
private async getTextValueFromBookingTracking(fieldValue: string, bookingUid: string, fieldName: string) {
const log = logger.getSubLogger({
prefix: [`[getTextValueFromBookingTracking]: ${fieldName} - ${bookingUid}`],
});
const tracking = await this.trackingRepository.findByBookingUid(bookingUid);
if (!tracking) {
log.warn(`No tracking found for bookingUid ${bookingUid}`);
return "";
}
const utmParam = fieldValue.split(":")[1].slice(0, -1);
return tracking[`utm_${utmParam}` as keyof typeof tracking]?.toString() ?? "";
}
private getTextValueFromBookingResponse(fieldValue: string, calEventResponses: CalEventResponses) {
const regexValueToReplace = /\{(.*?)\}/g;
return fieldValue.replace(regexValueToReplace, (match, captured) => {
return calEventResponses[captured]?.value ? calEventResponses[captured].value.toString() : match;
});
}
private async getDateFieldValue(
fieldValue: string,
startTime?: string,
bookingUid?: string | null
): Promise<string | null> {
if (fieldValue === DateFieldType.BOOKING_START_DATE) {
if (!startTime) {
this.log.error("StartTime is required for BOOKING_START_DATE but was not provided");
return null;
}
return new Date(startTime).toISOString();
}
if (fieldValue === DateFieldType.BOOKING_CREATED_DATE && bookingUid) {
const booking = await this.bookingRepository.findBookingByUid({ bookingUid });
if (!booking) {
this.log.warn(`No booking found for ${bookingUid}`);
return null;
}
return new Date(booking.createdAt).toISOString();
}
if (fieldValue === DateFieldType.BOOKING_CANCEL_DATE) {
return new Date().toISOString();
}
return null;
}
private async getFieldValue({
fieldValue,
fieldType,
calEventResponses,
bookingUid,
startTime,
fieldName,
}: {
fieldValue: string | boolean;
fieldType: CrmFieldType;
calEventResponses?: CalEventResponses | null;
bookingUid?: string | null;
startTime?: string;
fieldName: string;
}): Promise<string | boolean | null> {
const log = logger.getSubLogger({ prefix: [`[getFieldValue]: ${fieldName}`] });
if (fieldType === CrmFieldType.CHECKBOX) {
return !!fieldValue;
}
if (fieldType === CrmFieldType.DATE || fieldType === CrmFieldType.DATETIME) {
return await this.getDateFieldValue(fieldValue as string, startTime, bookingUid);
}
if (
fieldType === CrmFieldType.TEXT ||
fieldType === CrmFieldType.STRING ||
fieldType === CrmFieldType.PHONE ||
fieldType === CrmFieldType.TEXTAREA ||
fieldType === CrmFieldType.CUSTOM
) {
if (typeof fieldValue !== "string") {
log.error(`Expected string value for field ${fieldName}, got ${typeof fieldValue}`);
return null;
}
if (!fieldValue.startsWith("{") && !fieldValue.endsWith("}")) {
log.info("Returning static value");
return fieldValue;
}
let valueToWrite = fieldValue;
// Extract from UTM tracking
if (fieldValue.startsWith("{utm:")) {
if (!bookingUid) {
log.error(`BookingUid not passed. Cannot get tracking values without it`);
return null;
}
valueToWrite = await this.getTextValueFromBookingTracking(fieldValue, bookingUid, fieldName);
} else {
// Extract from booking form responses
if (!calEventResponses) {
log.error(`CalEventResponses not passed. Cannot get booking form responses`);
return null;
}
valueToWrite = this.getTextValueFromBookingResponse(fieldValue, calEventResponses);
}
if (valueToWrite === fieldValue) {
log.error("No responses found returning nothing");
return null;
}
return valueToWrite;
}
log.error(`Unsupported field type ${fieldType} for field ${fieldName}`);
return null;
}
private async generateWriteToMeetingBody(event: CalendarEvent) {
const appOptions = this.getAppOptions();
const customFieldInputsEnabled =
appOptions?.onBookingWriteToEventObject && appOptions?.onBookingWriteToEventObjectFields;
if (!customFieldInputsEnabled) return {};
if (!appOptions?.onBookingWriteToEventObjectFields) return {};
const customFieldInputs = customFieldInputsEnabled
? await this.ensureFieldsExistOnMeeting(Object.keys(appOptions.onBookingWriteToEventObjectFields))
: [];
const confirmedCustomFieldInputs: Record<string, any> = {};
for (const field of customFieldInputs) {
const fieldConfig = appOptions.onBookingWriteToEventObjectFields[field.name];
const fieldValue = await this.getFieldValue({
fieldValue: fieldConfig.value,
fieldType: fieldConfig.fieldType,
calEventResponses: event.responses,
bookingUid: event?.uid,
startTime: event.startTime,
fieldName: field.name,
});
if (fieldValue !== null) {
confirmedCustomFieldInputs[field.name] = fieldValue;
}
}
this.log.info(`Writing to meeting fields: ${Object.keys(confirmedCustomFieldInputs)}`);
return confirmedCustomFieldInputs;
}
private hubspotCreateMeeting = async (event: CalendarEvent, hubspotOwnerId?: string) => {
const writeToMeetingRecord = await this.generateWriteToMeetingBody(event);
const properties: Record<string, string> = {
hs_timestamp: Date.now().toString(),
hs_meeting_title: event.title,
@@ -81,6 +287,7 @@ export default class HubspotCalendarService implements CRM {
hs_meeting_start_time: new Date(event.startTime).toISOString(),
hs_meeting_end_time: new Date(event.endTime).toISOString(),
hs_meeting_outcome: "SCHEDULED",
...writeToMeetingRecord,
};
if (hubspotOwnerId) {
+6
View File
@@ -1,10 +1,16 @@
import { z } from "zod";
import { writeToBookingEntry } from "../_lib/crm-schemas";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export { writeToBookingEntry, writeToRecordEntrySchema } from "../_lib/crm-schemas";
export { CrmFieldType, WhenToWrite, DateFieldType } from "../_lib/crm-enums";
export const appDataSchema = eventTypeAppCardZod.extend({
ignoreGuests: z.boolean().optional(),
skipContactCreation: z.boolean().optional(),
onBookingWriteToEventObject: z.boolean().optional(),
onBookingWriteToEventObjectFields: z.record(z.string(), writeToBookingEntry).optional(),
});
export const appKeysSchema = z.object({
@@ -0,0 +1,18 @@
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { TrackingRepositoryInterface } from "./TrackingRepository.interface";
export class PrismaTrackingRepository implements TrackingRepositoryInterface {
constructor(private readonly prismaClient: PrismaClient = prisma) {}
async findByBookingUid(bookingUid: string) {
return await this.prismaClient.tracking.findFirst({
where: {
booking: {
uid: bookingUid,
},
},
});
}
}
@@ -0,0 +1,5 @@
import type { Tracking } from "@calcom/prisma/client";
export interface TrackingRepositoryInterface {
findByBookingUid(bookingUid: string): Promise<Tracking | null>;
}
+1
View File
@@ -10,6 +10,7 @@ export const bookingMinimalSelect = {
endTime: true,
attendees: true,
metadata: true,
createdAt: true,
} satisfies Prisma.BookingSelect;
export const bookingAuthorizationCheckSelect = {