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:
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
@@ -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 },
|
||||
];
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const bookingMinimalSelect = {
|
||||
endTime: true,
|
||||
attendees: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
} satisfies Prisma.BookingSelect;
|
||||
|
||||
export const bookingAuthorizationCheckSelect = {
|
||||
|
||||
Reference in New Issue
Block a user