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>
This commit is contained in:
Pallav
2026-01-19 17:43:25 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot] Amit Sharma
parent a4f8571f59
commit 7f46ce1851
5 changed files with 242 additions and 46 deletions
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader
icon="user-check"
title={t("set_organizer_as_contact_owner")}
labelFor="set-organizer-as-owner">
<Switch
size="sm"
labelOnLeading
checked={setOrganizerAsOwner}
onCheckedChange={(checked: boolean): void => {
setAppData("setOrganizerAsOwner", checked);
if (!checked) {
setAppData("overwriteContactOwner", false);
}
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
{setOrganizerAsOwner && (
<Section.SubSection>
<Section.SubSectionHeader
icon="refresh-cw"
title={t("overwrite_existing_contact_owner")}
labelFor="overwrite-contact-owner">
<Switch
size="sm"
labelOnLeading
checked={overwriteContactOwner}
onCheckedChange={(checked: boolean): void => {
setAppData("overwriteContactOwner", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
)}
<Section.SubSection>
<WriteToObjectSettings
@@ -80,10 +117,10 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
optionLabel={t("on_booking_write_to_event_object")}
optionEnabled={onBookingWriteToEventObject}
writeToObjectData={onBookingWriteToEventObjectFields}
optionSwitchOnChange={(checked) => {
optionSwitchOnChange={(checked: boolean): void => {
setAppData("onBookingWriteToEventObject", checked);
}}
updateWriteToObjectData={(data) => setAppData("onBookingWriteToEventObjectFields", data)}
updateWriteToObjectData={(data): void => setAppData("onBookingWriteToEventObjectFields", data)}
supportedFieldTypes={[
CrmFieldType.TEXT,
CrmFieldType.DATE,
@@ -24,7 +24,11 @@ const {
properties: { coreApi: { getAll: ReturnType<typeof vi.fn> } };
contacts: {
searchApi: { doSearch: ReturnType<typeof vi.fn> };
basicApi: { create: ReturnType<typeof vi.fn> };
basicApi: {
create: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
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({});
+79 -37
View File
@@ -69,15 +69,17 @@ class HubspotCalendarService implements CRM {
.join("<br><br>");
const organizerName = event.organizer.name || event.organizer.email;
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email
})`;
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email})`;
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${event.attendees[0].timeZone
}<br><br>${event.additionalNotes
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${event.additionalNotes
}<br><br>`
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${
event.attendees[0].timeZone
}<br><br>${
event.additionalNotes
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${
event.additionalNotes
}<br><br>`
: ""
}
}
${userFieldsHtml}<br><br>
<b>${event.organizer.language.translate("where")}:</b> ${location}<br><br>
${plainText ? `<b>${event.organizer.language.translate("description")}</b><br>${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<string | undefined> => {
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<string | null> {
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<void> {
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<string | null> {
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<void> {
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);
}
}
}
/**
+2
View File
@@ -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(),
});