diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d8ebddd2fb..3655fad31b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4340,5 +4340,7 @@ "pbac_resource_feature_opt_in": "Feature Opt-In", "pbac_desc_view_feature_opt_in": "View feature opt-in settings", "pbac_desc_update_feature_opt_in": "Manage feature opt-in settings", + "set_organizer_as_contact_owner": "Set booking organizer as contact owner", + "overwrite_existing_contact_owner": "Overwrite existing contact owner", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx index 37c82ff6db..bc721e844b 100644 --- a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx @@ -28,6 +28,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ const ignoreGuests = getAppData("ignoreGuests") ?? false; const skipContactCreation = getAppData("skipContactCreation") ?? false; + const setOrganizerAsOwner = getAppData("setOrganizerAsOwner") ?? false; + const overwriteContactOwner = getAppData("overwriteContactOwner") ?? false; const onBookingWriteToEventObject = getAppData("onBookingWriteToEventObject") ?? false; const onBookingWriteToEventObjectFields = getAppData("onBookingWriteToEventObjectFields") ?? {}; @@ -37,7 +39,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`} app={app} teamId={eventType.team?.id || undefined} - switchOnClick={(e) => { + switchOnClick={(e: boolean): void => { updateEnabled(e); }} switchChecked={enabled} @@ -52,7 +54,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ size="sm" labelOnLeading checked={ignoreGuests} - onCheckedChange={(checked) => { + onCheckedChange={(checked: boolean): void => { setAppData("ignoreGuests", checked); }} /> @@ -67,12 +69,47 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ size="sm" labelOnLeading checked={skipContactCreation} - onCheckedChange={(checked) => { + onCheckedChange={(checked: boolean): void => { setAppData("skipContactCreation", checked); }} /> + + + { + setAppData("setOrganizerAsOwner", checked); + if (!checked) { + setAppData("overwriteContactOwner", false); + } + }} + /> + + + {setOrganizerAsOwner && ( + + + { + setAppData("overwriteContactOwner", checked); + }} + /> + + + )} { + optionSwitchOnChange={(checked: boolean): void => { setAppData("onBookingWriteToEventObject", checked); }} - updateWriteToObjectData={(data) => setAppData("onBookingWriteToEventObjectFields", data)} + updateWriteToObjectData={(data): void => setAppData("onBookingWriteToEventObjectFields", data)} supportedFieldTypes={[ CrmFieldType.TEXT, CrmFieldType.DATE, diff --git a/packages/app-store/hubspot/lib/CrmService.test.ts b/packages/app-store/hubspot/lib/CrmService.test.ts index 8339c36b84..b8ef0f3a56 100644 --- a/packages/app-store/hubspot/lib/CrmService.test.ts +++ b/packages/app-store/hubspot/lib/CrmService.test.ts @@ -24,7 +24,11 @@ const { properties: { coreApi: { getAll: ReturnType } }; contacts: { searchApi: { doSearch: ReturnType }; - basicApi: { create: ReturnType }; + basicApi: { + create: ReturnType; + getById: ReturnType; + update: ReturnType; + }; }; objects: { meetings: { @@ -58,6 +62,8 @@ const { }, basicApi: { create: vi.fn(), + getById: vi.fn(), + update: vi.fn(), }, }, objects: { @@ -670,15 +676,26 @@ describe("HubspotCalendarService", () => { } ); }); - - it("should set hubspot owner when organizer email matches", async () => { - mockAppOptions({}); + it("should set contact owner when setOrganizerAsOwner is enabled and organizer email matches", async () => { + mockAppOptions({ setOrganizerAsOwner: true }); // Mock owner lookup - return matching owner mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [{ id: "owner-123", email: "organizer@example.com" }], }); + // Mock contact owner lookup - return no existing owner + mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({ + id: "contact-1", + properties: { hubspot_owner_id: null }, + }); + + // Mock contact update + mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({ + id: "contact-1", + properties: { hubspot_owner_id: "owner-123" }, + }); + mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({ id: "meeting-123", properties: {}, @@ -693,10 +710,106 @@ describe("HubspotCalendarService", () => { await service.createEvent(event, contacts); + // Verify contact owner was set + expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", { + properties: { hubspot_owner_id: "owner-123" }, + }); + + // Verify meeting was created with hubspot_owner_id (meeting owner is always set from organizer) const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0]; expect(createCall.properties.hubspot_owner_id).toBe("owner-123"); }); + it("should overwrite existing contact owner when overwriteContactOwner is true", async () => { + mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: true }); + + mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ + results: [{ id: "owner-123", email: "organizer@example.com" }], + }); + + mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({ + id: "contact-1", + properties: { hubspot_owner_id: "owner-123" }, + }); + + 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); + + expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", { + properties: { hubspot_owner_id: "owner-123" }, + }); + expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled(); + }); + + it("should not overwrite existing contact owner when overwriteContactOwner is false", async () => { + mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: false }); + + mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ + results: [{ id: "owner-123", email: "organizer@example.com" }], + }); + + mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({ + id: "contact-1", + properties: { hubspot_owner_id: "existing-owner-456" }, + }); + + 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); + + expect(mockHubspotClient.crm.contacts.basicApi.getById).toHaveBeenCalledWith("contact-1", [ + "hubspot_owner_id", + ]); + expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled(); + }); + + it("should not set contact owner when organizer has no matching HubSpot owner", async () => { + mockAppOptions({ setOrganizerAsOwner: true }); + + mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [] }); + + 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); + + expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled(); + expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled(); + + const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0]; + expect(createCall.properties.hubspot_owner_id).toBeUndefined(); + }); + it("should create meeting successfully when owner lookup fails due to missing scope", async () => { mockAppOptions({}); diff --git a/packages/app-store/hubspot/lib/CrmService.ts b/packages/app-store/hubspot/lib/CrmService.ts index 63e3e9ac0c..b32ba58cba 100644 --- a/packages/app-store/hubspot/lib/CrmService.ts +++ b/packages/app-store/hubspot/lib/CrmService.ts @@ -69,15 +69,17 @@ class HubspotCalendarService implements CRM { .join("

"); const organizerName = event.organizer.name || event.organizer.email; - const organizerInfo = `${event.organizer.language.translate("organizer")}: ${organizerName} (${event.organizer.email - })`; + const organizerInfo = `${event.organizer.language.translate("organizer")}: ${organizerName} (${event.organizer.email})`; - return `${organizerInfo}

${event.organizer.language.translate("invitee_timezone")}: ${event.attendees[0].timeZone - }

${event.additionalNotes - ? `${event.organizer.language.translate("share_additional_notes")}
${event.additionalNotes - }

` + return `${organizerInfo}

${event.organizer.language.translate("invitee_timezone")}: ${ + event.attendees[0].timeZone + }

${ + event.additionalNotes + ? `${event.organizer.language.translate("share_additional_notes")}
${ + event.additionalNotes + }

` : "" - } + } ${userFieldsHtml}

${event.organizer.language.translate("where")}: ${location}

${plainText ? `${event.organizer.language.translate("description")}
${plainText}` : ""} @@ -87,7 +89,7 @@ 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 }> = []; + const foundFields: Array<{ name: string; type: string; [key: string]: any }> = []; try { const properties = await this.hubspotClient.crm.properties.coreApi.getAll("meetings"); @@ -362,33 +364,6 @@ class HubspotCalendarService implements CRM { return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid); }; - private getHubspotOwnerIdByEmail = async (email: string): Promise => { - try { - const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage( - email, - undefined, - 100, - false - ); - - if (ownersResponse.results && ownersResponse.results.length > 0) { - const matchingOwner = ownersResponse.results.find( - (owner) => owner.email?.toLowerCase() === email.toLowerCase() - ); - - if (matchingOwner) { - this.log.debug("hubspot:owner:found", { ownerId: matchingOwner.id }); - return matchingOwner.id; - } - } - - this.log.debug("hubspot:owner:not_found"); - return undefined; - } catch (error) { - this.log.error("hubspot:owner:lookup_error", { error }); - return undefined; - } - }; private hubspotAuth = async (credential: CredentialPayload) => { const appKeys = await getAppKeysFromSlug("hubspot"); @@ -455,14 +430,25 @@ class HubspotCalendarService implements CRM { const organizerEmail = event.organizer.email; this.log.debug("hubspot:meeting:fetching_owner"); - const hubspotOwnerId = await this.getHubspotOwnerIdByEmail(organizerEmail); + const hubspotOwnerId = await this.getHubspotOwnerIdFromEmail(organizerEmail); - const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId); + const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId ?? undefined); if (meetingEvent) { this.log.debug("meeting:creation:ok", { meetingId: meetingEvent.id, hubspotOwnerId }); const associatedMeeting = await this.hubspotAssociate(meetingEvent, contactIds as any); if (associatedMeeting) { this.log.debug("association:creation:ok", { associatedMeeting }); + + const firstContact = contacts[0]; + const appOptions = this.getAppOptions(); + if (hubspotOwnerId && firstContact?.id && appOptions?.setOrganizerAsOwner) { + await this.setContactOwnerIfAllowed( + firstContact.id, + hubspotOwnerId, + appOptions?.overwriteContactOwner ?? false + ); + } + return Promise.resolve({ uid: meetingEvent.id, id: meetingEvent.id, @@ -581,6 +567,62 @@ class HubspotCalendarService implements CRM { async handleAttendeeNoShow() { console.log("Not implemented"); } + + private async getHubspotOwnerIdFromEmail(email: string): Promise { + try { + const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage( + email, + undefined, + 100, + false + ); + if (ownersResponse.results && ownersResponse.results.length > 0) { + const owner = ownersResponse.results.find((o) => o.email?.toLowerCase() === email.toLowerCase()); + return owner?.id ?? null; + } + return null; + } catch (error) { + this.log.error("Error fetching HubSpot owner:", error); + return null; + } + } + + private async setContactOwner(contactId: string, ownerId: string): Promise { + try { + await this.hubspotClient.crm.contacts.basicApi.update(contactId, { + properties: { hubspot_owner_id: ownerId }, + }); + } catch (error) { + this.log.error("Error setting contact owner:", error); + } + } + + private async getContactOwnerId(contactId: string): Promise { + const contact = await this.hubspotClient.crm.contacts.basicApi.getById(contactId, [ + "hubspot_owner_id", + ]); + return contact.properties.hubspot_owner_id ?? null; + } + + private async setContactOwnerIfAllowed( + contactId: string, + ownerId: string, + overwriteContactOwner: boolean + ): Promise { + if (overwriteContactOwner) { + await this.setContactOwner(contactId, ownerId); + return; + } + + try { + const currentOwnerId = await this.getContactOwnerId(contactId); + if (!currentOwnerId) { + await this.setContactOwner(contactId, ownerId); + } + } catch (error) { + this.log.error("Error fetching contact owner, skipping owner update:", error); + } + } } /** diff --git a/packages/app-store/hubspot/zod.ts b/packages/app-store/hubspot/zod.ts index 712d1b1bea..27a26818ba 100644 --- a/packages/app-store/hubspot/zod.ts +++ b/packages/app-store/hubspot/zod.ts @@ -9,6 +9,8 @@ export { CrmFieldType, WhenToWrite, DateFieldType } from "../_lib/crm-enums"; export const appDataSchema = eventTypeAppCardZod.extend({ ignoreGuests: z.boolean().optional(), skipContactCreation: z.boolean().optional(), + setOrganizerAsOwner: z.boolean().optional(), + overwriteContactOwner: z.boolean().optional(), onBookingWriteToEventObject: z.boolean().optional(), onBookingWriteToEventObjectFields: z.record(z.string(), writeToBookingEntry).optional(), });