feat: hubspot rr skip (#27310)

* feat: hubspot show owner availability for round robin

* fix: translations

* fix: use app name variable for text
This commit is contained in:
Amit Sharma
2026-01-28 16:57:02 +05:30
committed by GitHub
parent ff6fa64b63
commit 5bea18fd5e
9 changed files with 347 additions and 52 deletions
@@ -4465,6 +4465,8 @@
"timestamp": "Timestamp",
"json": "JSON",
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
"crm_book_directly_with_attendee_owner": "If attendee exists in {{appName}}, book directly with the owner",
"crm_if_free_email_domain_skip_owner_check": "If attendee has a free email domain, skip the ownership check and round robin as normal",
"saml_idp_not_authoritative_error": "This SAML identity provider is not authorized to manage your account. Please sign in with your original login method.",
"audit_logs_organization_required": "You must be part of an organization to view audit logs.",
"audit_logs_booking_not_found_or_permission_denied": "Booking not found or you do not have permission to view its audit logs.",
+1 -1
View File
@@ -1,5 +1,5 @@
import process from "node:process";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
+1 -3
View File
@@ -1,8 +1,6 @@
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import * as hubspot from "@hubspot/api-client";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
+2 -4
View File
@@ -1,10 +1,8 @@
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import * as hubspot from "@hubspot/api-client";
import type { TokenResponseIF } from "@hubspot/api-client/lib/codegen/oauth/models/TokenResponseIF";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
@@ -1,18 +1,18 @@
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 { CrmFieldType } from "@calcom/app-store/_lib/crm-enums";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Alert } from "@calcom/ui/components/alert";
import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
import { usePathname } from "next/navigation";
import type { appDataSchema } from "../zod";
import { WhenToWrite } from "../zod";
@@ -32,6 +32,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const overwriteContactOwner = getAppData("overwriteContactOwner") ?? false;
const onBookingWriteToEventObject = getAppData("onBookingWriteToEventObject") ?? false;
const onBookingWriteToEventObjectFields = getAppData("onBookingWriteToEventObjectFields") ?? {};
const roundRobinLeadSkip = getAppData("roundRobinLeadSkip") ?? false;
const ifFreeEmailDomainSkipOwnerCheck = getAppData("ifFreeEmailDomainSkipOwnerCheck") ?? false;
return (
<AppCard
@@ -131,6 +133,47 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
supportedWriteTriggers={[WhenToWrite.EVERY_BOOKING]}
/>
</Section.SubSection>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN ? (
<>
<Section.SubSection>
<Section.SubSectionHeader
icon="users"
title={t("crm_book_directly_with_attendee_owner", { appName: app.name })}
labelFor="book-directly-with-attendee-owner">
<Switch
size="sm"
id="book-directly-with-attendee-owner"
checked={roundRobinLeadSkip}
onCheckedChange={(checked) => {
setAppData("roundRobinLeadSkip", checked);
if (checked) {
setAppData("enabled", checked);
}
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
{roundRobinLeadSkip && (
<Section.SubSection>
<Section.SubSectionHeader
icon="users"
title={t("crm_if_free_email_domain_skip_owner_check")}
labelFor="if-free-email-domain-skip-owner-check">
<Switch
size="sm"
id="if-free-email-domain-skip-owner-check"
checked={ifFreeEmailDomainSkipOwnerCheck}
onCheckedChange={(checked) => {
setAppData("ifFreeEmailDomainSkipOwnerCheck", checked);
}}
/>
</Section.SubSectionHeader>
<Alert severity="info" title={t("skip_rr_description")} />
</Section.SubSection>
)}
</>
) : null}
</Section.Content>
</AppCard>
);
+1 -1
View File
@@ -1,3 +1,3 @@
export { metadata } from "./_metadata";
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
@@ -1,12 +1,10 @@
import type { TFunction } from "i18next";
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM } from "@calcom/types/CrmService";
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import type { TFunction } from "i18next";
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";
@@ -18,6 +16,7 @@ const {
mockGetAppKeysFromSlug,
mockTrackingRepository,
mockBookingRepository,
mockCheckIfFreeEmailDomain,
}: {
mockHubspotClient: {
crm: {
@@ -28,7 +27,7 @@ const {
create: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
};
objects: {
meetings: {
@@ -40,7 +39,7 @@ const {
};
};
associations: { batchApi: { create: ReturnType<typeof vi.fn> } };
owners: { ownersApi: { getPage: ReturnType<typeof vi.fn> } };
owners: { ownersApi: { getPage: ReturnType<typeof vi.fn>; getById: ReturnType<typeof vi.fn> } };
};
oauth: { tokensApi: { createToken: ReturnType<typeof vi.fn> } };
setAccessToken: ReturnType<typeof vi.fn>;
@@ -48,6 +47,7 @@ const {
mockGetAppKeysFromSlug: ReturnType<typeof vi.fn>;
mockTrackingRepository: { findByBookingUid: ReturnType<typeof vi.fn> };
mockBookingRepository: { findBookingByUid: ReturnType<typeof vi.fn> };
mockCheckIfFreeEmailDomain: ReturnType<typeof vi.fn>;
} = vi.hoisted(() => {
const mockHubspotClient = {
crm: {
@@ -83,6 +83,7 @@ const {
owners: {
ownersApi: {
getPage: vi.fn(),
getById: vi.fn(),
},
},
},
@@ -107,7 +108,15 @@ const {
findBookingByUid: vi.fn(),
};
return { mockHubspotClient, mockGetAppKeysFromSlug, mockTrackingRepository, mockBookingRepository };
const mockCheckIfFreeEmailDomain = vi.fn();
return {
mockHubspotClient,
mockGetAppKeysFromSlug,
mockTrackingRepository,
mockBookingRepository,
mockCheckIfFreeEmailDomain,
};
});
vi.mock("@hubspot/api-client", () => {
@@ -144,6 +153,10 @@ vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
},
}));
vi.mock("@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain", () => ({
checkIfFreeEmailDomain: mockCheckIfFreeEmailDomain,
}));
import BuildCrmService from "./CrmService";
describe("HubspotCalendarService", () => {
@@ -284,6 +297,198 @@ describe("HubspotCalendarService", () => {
expect(result).toEqual([]);
});
describe("forRoundRobinSkip param is passed", () => {
it("should return contact with owner info when forRoundRobinSkip is true", async () => {
mockAppOptions({ roundRobinLeadSkip: true });
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "test@example.com",
hubspot_owner_id: "12345",
},
},
],
});
mockHubspotClient.crm.owners.ownersApi.getById.mockResolvedValueOnce({
id: "12345",
email: "owner@example.com",
});
const result = await service.getContacts({
emails: "test@example.com",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "test@example.com",
ownerId: "12345",
ownerEmail: "owner@example.com",
recordType: "CONTACT",
},
]);
expect(mockHubspotClient.crm.contacts.searchApi.doSearch).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["hs_object_id", "email", "hubspot_owner_id"],
})
);
expect(mockHubspotClient.crm.owners.ownersApi.getById).toHaveBeenCalledWith(12345);
});
it("should return contact without ownerEmail when contact has no owner", async () => {
mockAppOptions({ roundRobinLeadSkip: true });
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "test@example.com",
hubspot_owner_id: null,
},
},
],
});
const result = await service.getContacts({
emails: "test@example.com",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "test@example.com",
ownerId: null,
ownerEmail: undefined,
recordType: "CONTACT",
},
]);
expect(mockHubspotClient.crm.owners.ownersApi.getById).not.toHaveBeenCalled();
});
it("should skip owner lookup for free email domains when ifFreeEmailDomainSkipOwnerCheck is enabled", async () => {
mockAppOptions({ roundRobinLeadSkip: true, ifFreeEmailDomainSkipOwnerCheck: true });
mockCheckIfFreeEmailDomain.mockResolvedValueOnce(true);
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "user@gmail.com",
},
},
],
});
const result = await service.getContacts({
emails: "user@gmail.com",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "user@gmail.com",
},
]);
expect(mockCheckIfFreeEmailDomain).toHaveBeenCalledWith({ email: "user@gmail.com" });
expect(mockHubspotClient.crm.contacts.searchApi.doSearch).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["hs_object_id", "email"],
})
);
expect(mockHubspotClient.crm.owners.ownersApi.getById).not.toHaveBeenCalled();
});
it("should include owner lookup for business email domains even when ifFreeEmailDomainSkipOwnerCheck is enabled", async () => {
mockAppOptions({ roundRobinLeadSkip: true, ifFreeEmailDomainSkipOwnerCheck: true });
mockCheckIfFreeEmailDomain.mockResolvedValueOnce(false);
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "user@company.com",
hubspot_owner_id: "45678",
},
},
],
});
mockHubspotClient.crm.owners.ownersApi.getById.mockResolvedValueOnce({
id: "45678",
email: "owner@company.com",
});
const result = await service.getContacts({
emails: "user@company.com",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "user@company.com",
ownerId: "45678",
ownerEmail: "owner@company.com",
recordType: "CONTACT",
},
]);
expect(mockCheckIfFreeEmailDomain).toHaveBeenCalledWith({ email: "user@company.com" });
expect(mockHubspotClient.crm.contacts.searchApi.doSearch).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["hs_object_id", "email", "hubspot_owner_id"],
})
);
});
it("should handle owner lookup API error gracefully", async () => {
mockAppOptions({ roundRobinLeadSkip: true });
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "test@example.com",
hubspot_owner_id: "99999",
},
},
],
});
mockHubspotClient.crm.owners.ownersApi.getById.mockRejectedValueOnce(new Error("Owner not found"));
const result = await service.getContacts({
emails: "test@example.com",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "test@example.com",
ownerId: "99999",
ownerEmail: undefined,
recordType: "CONTACT",
},
]);
});
});
});
describe("createContacts", () => {
+77 -29
View File
@@ -1,4 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { PrismaTrackingRepository } from "@calcom/lib/server/repository/PrismaTrackingRepository";
import prisma from "@calcom/prisma";
import type { CalEventResponses, CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { Contact, ContactCreateInput, CRM, CrmEvent } from "@calcom/types/CrmService";
import * as hubspot from "@hubspot/api-client";
import type { BatchInputPublicAssociation } from "@hubspot/api-client/lib/codegen/crm/associations";
import type { PublicObjectSearchRequest } from "@hubspot/api-client/lib/codegen/crm/contacts";
@@ -7,22 +20,9 @@ import type {
SimplePublicObjectInput,
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
import type { z } from "zod";
import { getLocation } from "@calcom/lib/CalEventParser";
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
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, 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";
@@ -336,11 +336,11 @@ class HubspotCalendarService implements CRM {
hs_meeting_title: event.title,
hs_meeting_body: this.getHubspotMeetingBody(event),
hs_meeting_location: getLocation({
videoCallData: event.videoCallData,
additionalInformation: event.additionalInformation,
location: event.location,
uid: event.uid,
}),
videoCallData: event.videoCallData,
additionalInformation: event.additionalInformation,
location: event.location,
uid: event.uid,
}),
hs_meeting_start_time: new Date(event.startTime).toISOString(),
hs_meeting_end_time: new Date(event.endTime).toISOString(),
hs_meeting_outcome: "RESCHEDULED",
@@ -364,7 +364,6 @@ class HubspotCalendarService implements CRM {
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
};
private hubspotAuth = async (credential: CredentialPayload) => {
const appKeys = await getAppKeysFromSlug("hubspot");
if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id;
@@ -375,11 +374,7 @@ class HubspotCalendarService implements CRM {
let currentToken = credential.key as unknown as HubspotToken;
const isTokenValid = (token: HubspotToken) =>
token &&
token.tokenType &&
token.accessToken &&
token.expiryDate &&
token.expiryDate > Date.now();
token && token.tokenType && token.accessToken && token.expiryDate && token.expiryDate > Date.now();
const refreshAccessToken = async (refreshToken: string) => {
try {
@@ -488,12 +483,25 @@ class HubspotCalendarService implements CRM {
await this.hubspotCancelMeeting(uid);
}
async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
async getContacts({
emails,
includeOwner,
forRoundRobinSkip,
}: {
emails: string | string[];
includeOwner?: boolean;
forRoundRobinSkip?: boolean;
}): Promise<Contact[]> {
const auth = await this.auth;
await auth.getToken();
const emailArray = Array.isArray(emails) ? emails : [emails];
const shouldIncludeOwner = includeOwner || forRoundRobinSkip;
const skipDueToFreeEmail =
forRoundRobinSkip && (await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]));
const publicObjectSearchRequest: PublicObjectSearchRequest = {
filterGroups: emailArray.map((attendeeEmail) => ({
filters: [
@@ -505,7 +513,10 @@ class HubspotCalendarService implements CRM {
],
})),
sorts: ["hs_object_id"],
properties: ["hs_object_id", "email"],
properties:
shouldIncludeOwner && !skipDueToFreeEmail
? ["hs_object_id", "email", "hubspot_owner_id"]
: ["hs_object_id", "email"],
limit: 10,
after: 0,
};
@@ -514,6 +525,27 @@ class HubspotCalendarService implements CRM {
.doSearch(publicObjectSearchRequest)
.then((apiResponse) => apiResponse.results);
if (shouldIncludeOwner && !skipDueToFreeEmail) {
return await Promise.all(
contacts.map(async (contact) => {
const ownerId = contact.properties.hubspot_owner_id;
let ownerEmail: string | undefined;
if (ownerId) {
ownerEmail = await this.getOwnerEmailFromId(ownerId);
}
return {
id: contact.id,
email: contact.properties.email,
ownerId,
ownerEmail,
recordType: "CONTACT",
};
})
);
}
return contacts.map((contact) => {
return {
id: contact.id,
@@ -598,9 +630,7 @@ class HubspotCalendarService implements CRM {
}
private async getContactOwnerId(contactId: string): Promise<string | null> {
const contact = await this.hubspotClient.crm.contacts.basicApi.getById(contactId, [
"hubspot_owner_id",
]);
const contact = await this.hubspotClient.crm.contacts.basicApi.getById(contactId, ["hubspot_owner_id"]);
return contact.properties.hubspot_owner_id ?? null;
}
@@ -623,6 +653,24 @@ class HubspotCalendarService implements CRM {
this.log.error("Error fetching contact owner, skipping owner update:", error);
}
}
private async getOwnerEmailFromId(ownerId: string): Promise<string | undefined> {
try {
const owner = await this.hubspotClient.crm.owners.ownersApi.getById(parseInt(ownerId, 10));
return owner.email ?? undefined;
} catch (error) {
this.log.error("Error fetching owner by ID:", error);
return undefined;
}
}
private async shouldSkipAttendeeIfFreeEmailDomain(attendeeEmail: string): Promise<boolean> {
const appOptions = this.getAppOptions();
if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false;
const response = await checkIfFreeEmailDomain({ email: attendeeEmail });
return response;
}
}
/**
+3 -2
View File
@@ -1,10 +1,9 @@
import { z } from "zod";
import { writeToBookingEntry } from "../_lib/crm-schemas";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export { CrmFieldType, DateFieldType, WhenToWrite } from "../_lib/crm-enums";
export { writeToBookingEntry, writeToRecordEntrySchema } from "../_lib/crm-schemas";
export { CrmFieldType, WhenToWrite, DateFieldType } from "../_lib/crm-enums";
export const appDataSchema = eventTypeAppCardZod.extend({
ignoreGuests: z.boolean().optional(),
@@ -13,6 +12,8 @@ export const appDataSchema = eventTypeAppCardZod.extend({
overwriteContactOwner: z.boolean().optional(),
onBookingWriteToEventObject: z.boolean().optional(),
onBookingWriteToEventObjectFields: z.record(z.string(), writeToBookingEntry).optional(),
roundRobinLeadSkip: z.boolean().optional(),
ifFreeEmailDomainSkipOwnerCheck: z.boolean().optional(),
});
export const appKeysSchema = z.object({