diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3d8f5c74ee..9a0e2dc522 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/packages/app-store/hubspot/_metadata.ts b/packages/app-store/hubspot/_metadata.ts index b3738e2b15..d447302cbf 100644 --- a/packages/app-store/hubspot/_metadata.ts +++ b/packages/app-store/hubspot/_metadata.ts @@ -1,5 +1,5 @@ +import process from "node:process"; import type { AppMeta } from "@calcom/types/App"; - import _package from "./package.json"; export const metadata = { diff --git a/packages/app-store/hubspot/api/add.ts b/packages/app-store/hubspot/api/add.ts index f0943dc7ad..1eb8ee847e 100644 --- a/packages/app-store/hubspot/api/add.ts +++ b/packages/app-store/hubspot/api/add.ts @@ -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"; diff --git a/packages/app-store/hubspot/api/callback.ts b/packages/app-store/hubspot/api/callback.ts index 61fe1fbdbc..1165b7585b 100644 --- a/packages/app-store/hubspot/api/callback.ts +++ b/packages/app-store/hubspot/api/callback.ts @@ -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"; diff --git a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx index bc721e844b..54261c8be2 100644 --- a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx @@ -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 ( + + {eventType.schedulingType === SchedulingType.ROUND_ROBIN ? ( + <> + + + { + setAppData("roundRobinLeadSkip", checked); + if (checked) { + setAppData("enabled", checked); + } + }} + /> + + + {roundRobinLeadSkip && ( + + + { + setAppData("ifFreeEmailDomainSkipOwnerCheck", checked); + }} + /> + + + + )} + + ) : null} ); diff --git a/packages/app-store/hubspot/index.ts b/packages/app-store/hubspot/index.ts index 5373eb04ef..8fd49c3469 100644 --- a/packages/app-store/hubspot/index.ts +++ b/packages/app-store/hubspot/index.ts @@ -1,3 +1,3 @@ +export { metadata } from "./_metadata"; export * as api from "./api"; export * as lib from "./lib"; -export { metadata } from "./_metadata"; diff --git a/packages/app-store/hubspot/lib/CrmService.test.ts b/packages/app-store/hubspot/lib/CrmService.test.ts index b8ef0f3a56..bbe2bf2857 100644 --- a/packages/app-store/hubspot/lib/CrmService.test.ts +++ b/packages/app-store/hubspot/lib/CrmService.test.ts @@ -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; getById: ReturnType; update: ReturnType; - }; + }; }; objects: { meetings: { @@ -40,7 +39,7 @@ const { }; }; associations: { batchApi: { create: ReturnType } }; - owners: { ownersApi: { getPage: ReturnType } }; + owners: { ownersApi: { getPage: ReturnType; getById: ReturnType } }; }; oauth: { tokensApi: { createToken: ReturnType } }; setAccessToken: ReturnType; @@ -48,6 +47,7 @@ const { mockGetAppKeysFromSlug: ReturnType; mockTrackingRepository: { findByBookingUid: ReturnType }; mockBookingRepository: { findBookingByUid: ReturnType }; + mockCheckIfFreeEmailDomain: ReturnType; } = 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", () => { diff --git a/packages/app-store/hubspot/lib/CrmService.ts b/packages/app-store/hubspot/lib/CrmService.ts index b32ba58cba..56ed0b5da4 100644 --- a/packages/app-store/hubspot/lib/CrmService.ts +++ b/packages/app-store/hubspot/lib/CrmService.ts @@ -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 { + async getContacts({ + emails, + includeOwner, + forRoundRobinSkip, + }: { + emails: string | string[]; + includeOwner?: boolean; + forRoundRobinSkip?: boolean; + }): Promise { 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 { - 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 { + 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 { + const appOptions = this.getAppOptions(); + if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false; + + const response = await checkIfFreeEmailDomain({ email: attendeeEmail }); + return response; + } } /** diff --git a/packages/app-store/hubspot/zod.ts b/packages/app-store/hubspot/zod.ts index 27a26818ba..ea8b1628bc 100644 --- a/packages/app-store/hubspot/zod.ts +++ b/packages/app-store/hubspot/zod.ts @@ -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({