diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 61a63f6938..ccc4558feb 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/_components/crm/WriteToObjectSettings.tsx b/packages/app-store/_components/crm/WriteToObjectSettings.tsx new file mode 100644 index 0000000000..0ff6f9c378 --- /dev/null +++ b/packages/app-store/_components/crm/WriteToObjectSettings.tsx @@ -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>({}); + const [editingData, setEditingData] = useState>({}); + const [newOnWriteToRecordEntry, setNewOnWriteToRecordEntry] = useState({ + 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 ( + <> + + + + + {optionEnabled ? ( + +
+
{t("field_name")}
+
{t("field_type")}
+
{t("value")}
+ {showWhenToWriteColumn &&
{t("when_to_write")}
} +
+
+ + {Object.keys(writeToObjectData).map((key) => { + const isEditing = editingRows[key]; + const editData = editingData[key]; + + return ( +
+
+ {isEditing ? ( + + setEditingData((prev) => ({ + ...prev, + [key]: { ...editData, field: e.target.value }, + })) + } + size="sm" + className="w-full" + /> + ) : ( + + )} +
+
+ {isEditing ? ( + option.value === writeToObjectData[key].fieldType + )} + isDisabled={true} + /> + )} +
+
+ {isEditing ? ( + editData?.fieldType === DATE_FIELD_TYPE ? ( + option.value === editData.value)} + onChange={(e) => { + if (e) { + setEditingData((prev) => ({ + ...prev, + [key]: { ...editData, value: e.value }, + })); + } + }} + /> + ) : ( + + setEditingData((prev) => ({ + ...prev, + [key]: { ...editData, value: e.target.value }, + })) + } + size="sm" + className="w-full" + /> + ) + ) : writeToObjectData[key].fieldType === DATE_FIELD_TYPE ? ( + option.value === writeToObjectData[key].value + )} + isDisabled={true} + /> + ) : ( + + )} +
+ {showWhenToWriteColumn && ( +
+ {isEditing ? ( + option.value === writeToObjectData[key].whenToWrite + )} + isDisabled={true} + /> + )} +
+ )} +
+ {isEditing ? ( + <> +
+
+ ); + })} +
+
+ + setNewOnWriteToRecordEntry({ + ...newOnWriteToRecordEntry, + field: e.target.value, + }) + } + /> +
+
+ { + if (e) { + setDateFieldSelectedOption(e); + setNewOnWriteToRecordEntry({ + ...newOnWriteToRecordEntry, + value: e.value, + }); + } + }} + /> + ) : newOnWriteToRecordEntry.fieldType === CHECKBOX_FIELD_TYPE ? ( + { + if (e) { + setWhenToWriteSelectedOption(e); + setNewOnWriteToRecordEntry({ + ...newOnWriteToRecordEntry, + whenToWrite: e.value, + }); + } + }} + /> +
+ )} +
+
+ + + + ) : null} + + ); +}; + +export { + BookingActionEnum, + type SelectOption, + type WriteToRecordEntry, + type WriteToRecordEntrySchema, + type WriteToObjectSettingsProps, +} from "./WriteToObjectSettings.types"; + +export default WriteToObjectSettings; diff --git a/packages/app-store/_components/crm/WriteToObjectSettings.types.ts b/packages/app-store/_components/crm/WriteToObjectSettings.types.ts new file mode 100644 index 0000000000..82d2416528 --- /dev/null +++ b/packages/app-store/_components/crm/WriteToObjectSettings.types.ts @@ -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 { + 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; + updateWriteToObjectData: (data: Record) => void; + supportedFieldTypes: readonly CrmFieldType[]; + supportedDateFields?: readonly import("@calcom/app-store/_lib/crm-enums").DateFieldType[]; + supportedWriteTriggers?: readonly WhenToWrite[]; +} diff --git a/packages/app-store/_components/crm/WriteToObjectSettings.utils.ts b/packages/app-store/_components/crm/WriteToObjectSettings.utils.ts new file mode 100644 index 0000000000..30a30fbcba --- /dev/null +++ b/packages/app-store/_components/crm/WriteToObjectSettings.utils.ts @@ -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.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.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.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[] => + 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[] => { + 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[] => { + const labelMap = getWhenToWriteLabelMap(bookingAction); + return supportedWriteTriggers.map((trigger) => ({ label: t(labelMap[trigger]), value: trigger })); +}; + +export const buildCheckboxFieldValueOptions = (t: (key: string) => string): SelectOption[] => [ + { label: t("true"), value: true }, + { label: t("false"), value: false }, +]; diff --git a/packages/app-store/_lib/crm-enums.ts b/packages/app-store/_lib/crm-enums.ts new file mode 100644 index 0000000000..c158815f37 --- /dev/null +++ b/packages/app-store/_lib/crm-enums.ts @@ -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", +} diff --git a/packages/app-store/_lib/crm-schemas.ts b/packages/app-store/_lib/crm-schemas.ts new file mode 100644 index 0000000000..0fd17950e5 --- /dev/null +++ b/packages/app-store/_lib/crm-schemas.ts @@ -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), +}); diff --git a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx index 4b529c6721..37c82ff6db 100644 --- a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx @@ -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 ( + + + { + setAppData("onBookingWriteToEventObject", checked); + }} + updateWriteToObjectData={(data) => setAppData("onBookingWriteToEventObjectFields", data)} + supportedFieldTypes={[ + CrmFieldType.TEXT, + CrmFieldType.DATE, + CrmFieldType.PHONE, + CrmFieldType.CHECKBOX, + CrmFieldType.CUSTOM, + ]} + supportedWriteTriggers={[WhenToWrite.EVERY_BOOKING]} + /> + ); diff --git a/packages/app-store/hubspot/lib/CrmService.test.ts b/packages/app-store/hubspot/lib/CrmService.test.ts new file mode 100644 index 0000000000..0f5c099e0f --- /dev/null +++ b/packages/app-store/hubspot/lib/CrmService.test.ts @@ -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; + +// Create hoisted mocks that will be available before module imports +const { + mockHubspotClient, + mockGetAppKeysFromSlug, +}: { + mockHubspotClient: { + crm: { + properties: { coreApi: { getAll: ReturnType } }; + contacts: { + searchApi: { doSearch: ReturnType }; + basicApi: { create: ReturnType }; + }; + objects: { + meetings: { + basicApi: { + create: ReturnType; + update: ReturnType; + archive: ReturnType; + }; + }; + }; + associations: { batchApi: { create: ReturnType } }; + owners: { ownersApi: { getPage: ReturnType } }; + }; + oauth: { tokensApi: { createToken: ReturnType } }; + setAccessToken: ReturnType; + }; + mockGetAppKeysFromSlug: ReturnType; +} = 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; + }; + let mockBookingRepository: { + findBookingByUid: ReturnType; + }; + + 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 { + 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"); + }); + }); +}); diff --git a/packages/app-store/hubspot/lib/CrmService.ts b/packages/app-store/hubspot/lib/CrmService.ts index a3172a4d95..bb19809b78 100644 --- a/packages/app-store/hubspot/lib/CrmService.ts +++ b/packages/app-store/hubspot/lib/CrmService.ts @@ -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; + private bookingRepository: BookingRepository; + private trackingRepository: PrismaTrackingRepository; constructor(credential: CredentialPayload, appOptions?: z.infer) { 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 { + 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 { + 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 = {}; + + 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 = { 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) { diff --git a/packages/app-store/hubspot/zod.ts b/packages/app-store/hubspot/zod.ts index 2e173c3143..712d1b1bea 100644 --- a/packages/app-store/hubspot/zod.ts +++ b/packages/app-store/hubspot/zod.ts @@ -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({ diff --git a/packages/lib/server/repository/PrismaTrackingRepository.ts b/packages/lib/server/repository/PrismaTrackingRepository.ts new file mode 100644 index 0000000000..24b5f537d3 --- /dev/null +++ b/packages/lib/server/repository/PrismaTrackingRepository.ts @@ -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, + }, + }, + }); + } +} diff --git a/packages/lib/server/repository/TrackingRepository.interface.ts b/packages/lib/server/repository/TrackingRepository.interface.ts new file mode 100644 index 0000000000..9ccebda635 --- /dev/null +++ b/packages/lib/server/repository/TrackingRepository.interface.ts @@ -0,0 +1,5 @@ +import type { Tracking } from "@calcom/prisma/client"; + +export interface TrackingRepositoryInterface { + findByBookingUid(bookingUid: string): Promise; +} diff --git a/packages/prisma/selects/booking.ts b/packages/prisma/selects/booking.ts index 2cc5c8223f..1941b881ac 100644 --- a/packages/prisma/selects/booking.ts +++ b/packages/prisma/selects/booking.ts @@ -10,6 +10,7 @@ export const bookingMinimalSelect = { endTime: true, attendees: true, metadata: true, + createdAt: true, } satisfies Prisma.BookingSelect; export const bookingAuthorizationCheckSelect = {