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:
co-authored by
cubic-dev-ai[bot]
Amit Sharma
parent
a4f8571f59
commit
7f46ce1851
@@ -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({});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user