From 7f46ce185145e62abcf43c9e62abe5c2fd0bfd05 Mon Sep 17 00:00:00 2001
From: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
Date: Mon, 19 Jan 2026 23:13:25 +0530
Subject: [PATCH] feat: add HubSpot contact owner toggle (#26552)
* feat: add HubSpot contact owner toggle
* chore
* Update packages/app-store/hubspot/lib/CrmService.ts
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* fix: updated test to verify new behavior
* fix: implement cubic suggestions
* refactor: update contact owner strings and enhance meeting creation logic
* refactor: fix failing unit tests
* refactor: streamline contact owner retrieval and error handling
* update tests
* update text
---------
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
---
apps/web/public/static/locales/en/common.json | 2 +
.../components/EventTypeAppCardInterface.tsx | 47 ++++++-
.../app-store/hubspot/lib/CrmService.test.ts | 121 +++++++++++++++++-
packages/app-store/hubspot/lib/CrmService.ts | 116 +++++++++++------
packages/app-store/hubspot/zod.ts | 2 +
5 files changed, 242 insertions(+), 46 deletions(-)
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(),
});