From 625a7ec180b41ab4b7288a85d636ab7a78a17fad Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Fri, 31 May 2024 09:54:52 -0400 Subject: [PATCH] refactor: Introduce `crmManager` (#14272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix timezone display on booking page to reflect event availability timezone * migrate fetching event owner's schedule to server side * migrate fetching event owner's schedule to server side * fix e2e test errors * Add WEBAPP_URL_FOR_OAUTH to salesforce auth * In event manager constructor include "_crm" credentials as calendar creds * Change crm apps to type to end with `_crm` * Move sendgrid out of CRM * Add zoho bigin to CRM apps * When getting apps, use slug * Add `crm` variants * Hubspot Oauth use `WEBAPP_URL_FOR_OAUTH` * Refactor creating credentials * Fix empty CRM page * Use credentials with `_crm` * Abstract getAppCategoryTitle * Add integration.handler changes * Init crmManager * Change salesforce to CrmService * Create crmManager * Create contact on new event * Create event * Create new CRM reference * - Fix create new contact for salesforce - Add reschedule to crmManager * Create deleteAllCRMEvents * When searching for credential, look for current credentials in class * On cancel, delete 3rd party events * Add delete method * Type fix * Type fix * Convert Close.com to CrmService * Convert Close.com to CrmService * Move hubspot to CrmService * Convert Pipedrive to CrmService * Rename classes to CrmService * Move ZohoCrm to CrmService * Move Bigin to CrmService * Type return for CrmServices * Fix type errors * Close.com create leads and contacts * Fix tests * Type fix * Zoho bug fixes * Clean up * Type fixes * Remove apiDeletes * Type fixes * Specific typing * Type fix * Type fix * Type fix * Type fix * Type fix * feat: Enable CRM apps on a per event type basis (#14450) * Add Salesforce to be an event type app * Handle new booking, only get enabled CRM credentials * Abstract generating search params * Add close.com to event type * Clean up * Move hubspot to event type * Add pipedrive to event type * Add zoho bigin to event type * Add zoho crm to event type * Remove console.log * Add deleting CRM apps from event type * Delete event type apps * Fix deleting credentials * Add CRM app data to event type metadata * Backwards compatibility: add CRM credential if doesn't exist on event type * Don't include user CRM credentials for backwards comp * Backwards compatibility show CRM app is enabled and dirty field * Clean up * Type fixes * Type fixes * Type fix * Type fix * Remove console.log * Test fix * Upgrade embed-react vite version - dev * Change build can't find error message * Add back omni install prop * Clean up * Refactor `writeAppDataToEventType` * Use eventType repository in writeAppDataToEventType * Clean up old comments * Add error logging * createCRMEvents pass event uid as created event uid * Use `getUid` * Clean up props in create crm event * Small changes to `crmManager` * Fix zoho CRM * refactor crmManager * Undo vite config change * Fix teamId query * Fix bigin error * Remove need for `writeAppDataToEventType` * Add `getAllCredentials` test * Add crmManager tests * Type fixes * Fix type errors * Fix getAllCredentials test * Fix tests * Skip CRM manager tests for now * feat: Skip RR Assignment if Contact Exists In Salesforce (#14556) Co-authored-by: CarinaWolli * Update yarn.lock * @zomars feedback - use new URL for state params * fix: update hook to not produce enabled === undefined * fix: update app card interfaces to use the new enabled from useIsAppEnabled * fix: feedback for crm RR skip (#15160) * code clean up * fix type any * test setup for RR lead skip * code clean up * simplify code * type error * finshed first test for RR lead skip * add seconds test * add test for handleNewBooking * test if teamMember is set * fix missing enabled key * fix tests * fix type error * use setSystemTime instead of getDate * remove nested if --------- Co-authored-by: CarinaWolli * fix type error * fix: remove app metadata from all eventTypes on deleting the app * fix: update hook to not produce enabled === undefined (default to false) --------- Co-authored-by: Shaik-Sirajuddin Co-authored-by: Shaik-Sirajuddin Co-authored-by: Shaik-Sirajuddin <89742297+Shaik-Sirajuddin@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Omar López Co-authored-by: CarinaWolli Co-authored-by: sean-brydon Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Somay Chauhan --- apps/web/public/static/locales/en/common.json | 2 + apps/web/test/lib/getSchedule.test.ts | 285 +++++++ .../utils/bookingScenario/bookingScenario.ts | 138 +++- packages/app-store/_components/AppCard.tsx | 46 +- packages/app-store/_utils/getCrm.ts | 33 + .../_utils/oauth/createOAuthAppCredential.ts | 8 +- .../app-store/_utils/useAddAppMutation.ts | 24 +- packages/app-store/_utils/useIsAppEnabled.ts | 12 +- packages/app-store/apps.browser.generated.tsx | 6 + .../app-store/apps.keys-schemas.generated.ts | 2 + packages/app-store/apps.schemas.generated.ts | 2 + packages/app-store/closecom/api/_getAdd.ts | 5 +- packages/app-store/closecom/api/_postAdd.ts | 7 +- .../app-store/closecom/components/.gitkeep | 0 .../components/EventTypeAppCardInterface.tsx | 27 + packages/app-store/closecom/config.json | 1 + .../lib/{CalendarService.ts => CrmService.ts} | 84 ++- packages/app-store/closecom/lib/index.ts | 2 +- .../app-store/closecom/pages/setup/index.tsx | 20 +- packages/app-store/closecom/zod.ts | 7 + packages/app-store/hubspot/_metadata.ts | 1 + packages/app-store/hubspot/api/callback.ts | 2 +- .../app-store/hubspot/components/.gitkeep | 0 .../components/EventTypeAppCardInterface.tsx | 27 + .../lib/{CalendarService.ts => CrmService.ts} | 186 ++--- packages/app-store/hubspot/lib/index.ts | 2 +- packages/app-store/hubspot/zod.ts | 6 +- packages/app-store/pipedrive-crm/api/add.ts | 1 + .../pipedrive-crm/components/.gitkeep | 0 .../components/EventTypeAppCardInterface.tsx | 27 + packages/app-store/pipedrive-crm/config.json | 1 + .../lib/{CalendarService.ts => CrmService.ts} | 120 +-- packages/app-store/pipedrive-crm/lib/index.ts | 2 +- packages/app-store/pipedrive-crm/zod.ts | 4 +- packages/app-store/salesforce/api/callback.ts | 3 +- .../components/EventTypeAppCardInterface.tsx | 55 ++ packages/app-store/salesforce/config.json | 1 + .../lib/{CalendarService.ts => CrmService.ts} | 186 ++--- packages/app-store/salesforce/lib/index.ts | 2 +- packages/app-store/salesforce/zod.ts | 6 +- packages/app-store/types.d.ts | 10 +- packages/app-store/zoho-bigin/api/callback.ts | 3 +- .../app-store/zoho-bigin/components/.gitkeep | 0 .../components/EventTypeAppCardInterface.tsx | 27 + packages/app-store/zoho-bigin/config.json | 1 + .../lib/{CalendarService.ts => CrmService.ts} | 72 +- packages/app-store/zoho-bigin/lib/index.ts | 2 +- packages/app-store/zoho-bigin/zod.ts | 5 +- packages/app-store/zohocrm/api/callback.ts | 1 + .../app-store/zohocrm/components/.gitkeep | 0 .../components/EventTypeAppCardInterface.tsx | 27 + packages/app-store/zohocrm/config.json | 1 + .../lib/{CalendarService.ts => CrmService.ts} | 136 ++-- packages/app-store/zohocrm/lib/index.ts | 2 +- packages/app-store/zohocrm/zod.ts | 4 +- packages/core/EventManager.ts | 98 ++- packages/core/crmManager/crmManager.test.ts | 97 +++ packages/core/crmManager/crmManager.ts | 66 ++ packages/embeds/embed-react/package.json | 2 +- .../Booker/components/hooks/useBookings.ts | 4 +- .../features/bookings/Booker/utils/event.ts | 3 + .../booking-to-mutation-input-mapper.tsx | 3 + .../getAllCredentials.test.ts | 699 ++++++++++++++++++ .../getAllCredentials.ts | 57 +- .../bookings/lib/handleCancelBooking.ts | 17 +- .../handleCancelBooking/test/webhook.test.ts | 9 +- .../features/bookings/lib/handleNewBooking.ts | 75 +- .../collective-scheduling.test.ts | 123 ++- .../schedules/lib/use-schedule/useSchedule.ts | 3 + packages/lib/CloseCom.ts | 15 +- packages/lib/CloseComeUtils.ts | 1 + packages/lib/server/repository/eventType.ts | 25 + .../atoms/booker/BookerWebWrapper.tsx | 15 +- .../atoms/hooks/useHandleBookEvent.ts | 3 + packages/prisma/zod-utils.ts | 6 +- .../deleteCredential.handler.ts | 79 +- .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 92 ++- packages/types/CrmService.d.ts | 33 + packages/types/Event.d.ts | 3 +- yarn.lock | 42 +- 81 files changed, 2611 insertions(+), 594 deletions(-) create mode 100644 packages/app-store/_utils/getCrm.ts create mode 100644 packages/app-store/closecom/components/.gitkeep create mode 100644 packages/app-store/closecom/components/EventTypeAppCardInterface.tsx rename packages/app-store/closecom/lib/{CalendarService.ts => CrmService.ts} (66%) create mode 100644 packages/app-store/closecom/zod.ts create mode 100644 packages/app-store/hubspot/components/.gitkeep create mode 100644 packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx rename packages/app-store/hubspot/lib/{CalendarService.ts => CrmService.ts} (71%) create mode 100644 packages/app-store/pipedrive-crm/components/.gitkeep create mode 100644 packages/app-store/pipedrive-crm/components/EventTypeAppCardInterface.tsx rename packages/app-store/pipedrive-crm/lib/{CalendarService.ts => CrmService.ts} (58%) create mode 100644 packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx rename packages/app-store/salesforce/lib/{CalendarService.ts => CrmService.ts} (70%) create mode 100644 packages/app-store/zoho-bigin/components/.gitkeep create mode 100644 packages/app-store/zoho-bigin/components/EventTypeAppCardInterface.tsx rename packages/app-store/zoho-bigin/lib/{CalendarService.ts => CrmService.ts} (85%) create mode 100644 packages/app-store/zohocrm/components/.gitkeep create mode 100644 packages/app-store/zohocrm/components/EventTypeAppCardInterface.tsx rename packages/app-store/zohocrm/lib/{CalendarService.ts => CrmService.ts} (68%) create mode 100644 packages/core/crmManager/crmManager.test.ts create mode 100644 packages/core/crmManager/crmManager.ts create mode 100644 packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts create mode 100644 packages/types/CrmService.d.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index cfc8f25591..702758fbbe 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2408,6 +2408,8 @@ "disconnect_account_hint": "Disconnecting your connected account will change the way you log in. You will only be able to login to your account using email + password", "cookie_consent_checkbox": "I consent to our privacy policy and cookie usage", "make_a_call": "Make a Call", + "skip_rr_assignment_label": "Skip round robin assignment if contact exists in Salesforce", + "skip_rr_description": "URL must contain the contacts email as a parameter ex. ?email=contactEmail", "select_account_header": "Select Account", "select_account_description": "Install {{appName}} on your personal account or on a team account.", "select_event_types_header": "Select Event Types", diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 9c17ed72c4..afa55bd817 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -9,6 +9,8 @@ import { getScenarioData, Timezones, TestData, + createCredentials, + mockCrmApp, } from "../utils/bookingScenario/bookingScenario"; import { describe, vi, test } from "vitest"; @@ -86,6 +88,289 @@ describe("getSchedule", () => { }); }); + describe("Round robin lead skip - CRM", async () => { + test("correctly get slots for event with only round robin hosts", async () => { + vi.setSystemTime("2024-05-21T00:00:13Z"); + + const plus1DateString = "2024-05-22"; + const plus2DateString = "2024-05-23"; + + const crmCredential = { + id: 1, + type: "salesforce_crm", + key: { + clientId: "test-client-id", + }, + userId: 1, + teamId: null, + appId: "salesforce", + invalid: false, + user: { email: "test@test.com" }, + }; + + await createCredentials([crmCredential]); + + mockCrmApp("salesforce", { + getContacts: [ + { + id: "contact-id", + email: "test@test.com", + ownerEmail: "example@example.com", + }, + ], + createContacts: [{ id: "contact-id", email: "test@test.com" }], + }); + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + hosts: [ + { + userId: 101, + isFixed: false, + }, + { + userId: 102, + isFixed: false, + }, + ], + schedulingType: "ROUND_ROBIN", + metadata: { + apps: { + salesforce: { + enabled: true, + appCategories: ["crm"], + roundRobinLeadSkip: true, + }, + }, + }, + }, + ], + users: [ + { + ...TestData.users.example, + email: "example@example.com", + id: 101, + schedules: [TestData.schedules.IstEveningShift], + }, + { + ...TestData.users.example, + email: "example1@example.com", + id: 102, + schedules: [TestData.schedules.IstMorningShift], + defaultScheduleId: 2, + }, + ], + bookings: [], + }); + + const scheduleWithLeadSkip = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: true, + bookerEmail: "test@test.com", + }, + }); + + expect(scheduleWithLeadSkip.teamMember).toBe("example@example.com"); + + // only slots where example@example.com is available + expect(scheduleWithLeadSkip).toHaveTimeSlots( + [`11:30:00.000Z`, `12:30:00.000Z`, `13:30:00.000Z`, `14:30:00.000Z`, `15:30:00.000Z`], + { + dateString: plus2DateString, + } + ); + + const scheduleWithoutLeadSkip = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: true, + bookerEmail: "testtest@test.com", + }, + }); + + expect(scheduleWithoutLeadSkip.teamMember).toBe(undefined); + + // slots where either one of the rr hosts is available + expect(scheduleWithoutLeadSkip).toHaveTimeSlots( + [ + `04:30:00.000Z`, + `05:30:00.000Z`, + `06:30:00.000Z`, + `07:30:00.000Z`, + `08:30:00.000Z`, + `09:30:00.000Z`, + `10:30:00.000Z`, + `11:30:00.000Z`, + `12:30:00.000Z`, + `13:30:00.000Z`, + `14:30:00.000Z`, + `15:30:00.000Z`, + ], + { + dateString: plus2DateString, + } + ); + }); + test("correctly get slots for event with round robin and fixed hosts", async () => { + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + + const crmCredential = { + id: 1, + type: "salesforce_crm", + key: { + clientId: "test-client-id", + }, + userId: 1, + teamId: null, + appId: "salesforce", + invalid: false, + user: { email: "test@test.com" }, + }; + + await createCredentials([crmCredential]); + + mockCrmApp("salesforce", { + getContacts: [ + { + id: "contact-id", + email: "test@test.com", + ownerEmail: "example@example.com", + }, + { + id: "contact-id-1", + email: "test1@test.com", + ownerEmail: "example1@example.com", + }, + ], + createContacts: [{ id: "contact-id", email: "test@test.com" }], + }); + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + hosts: [ + { + userId: 101, + isFixed: true, + }, + { + userId: 102, + isFixed: false, + }, + { + userId: 103, + isFixed: false, + }, + ], + schedulingType: "ROUND_ROBIN", + metadata: { + apps: { + salesforce: { + enabled: true, + appCategories: ["crm"], + roundRobinLeadSkip: true, + }, + }, + }, + }, + ], + users: [ + { + ...TestData.users.example, + email: "example@example.com", + id: 101, + schedules: [TestData.schedules.IstMidShift], + }, + { + ...TestData.users.example, + email: "example1@example.com", + id: 102, + schedules: [TestData.schedules.IstMorningShift], + defaultScheduleId: 2, + }, + { + ...TestData.users.example, + email: "example2@example.com", + id: 103, + schedules: [TestData.schedules.IstEveningShift], + + defaultScheduleId: 3, + }, + ], + bookings: [], + }); + + const scheduleFixedHostLead = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: true, + bookerEmail: "test@test.com", + }, + }); + + expect(scheduleFixedHostLead.teamMember).toBe("example@example.com"); + + // show normal slots, example@example + one RR host needs to be available + expect(scheduleFixedHostLead).toHaveTimeSlots( + [ + `07:30:00.000Z`, + `08:30:00.000Z`, + `09:30:00.000Z`, + `10:30:00.000Z`, + `11:30:00.000Z`, + `12:30:00.000Z`, + `13:30:00.000Z`, + ], + { + dateString: plus2DateString, + } + ); + + const scheduleRRHostLead = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: true, + bookerEmail: "test1@test.com", + }, + }); + + expect(scheduleRRHostLead.teamMember).toBe("example1@example.com"); + + // slots where example@example (fixed host) + example1@example.com are available together + expect(scheduleRRHostLead).toHaveTimeSlots( + [`07:30:00.000Z`, `08:30:00.000Z`, `09:30:00.000Z`, `10:30:00.000Z`, `11:30:00.000Z`], + { + dateString: plus2DateString, + } + ); + }); + }); + describe("User Event", () => { test("correctly identifies unavailable slots from Cal Bookings in different status", async () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 9743c6ef9d..c7e1f9acf3 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -136,6 +136,7 @@ export type InputEventType = { bookingLimits?: IntervalLimit; durationLimits?: IntervalLimit; owner?: number; + metadata?: any; } & Partial>; type AttendeeBookingSeatInput = Pick; @@ -169,23 +170,35 @@ export const Timezones = { async function addHostsToDb(eventTypes: InputEventType[]) { for (const eventType of eventTypes) { - if (eventType.hosts && eventType.hosts.length > 0) { - await prismock.host.createMany({ - data: eventType.hosts.map((host) => ({ - userId: host.userId, - eventTypeId: eventType.id, - isFixed: host.isFixed ?? false, - })), + if (!eventType.hosts?.length) continue; + for (const host of eventType.hosts) { + const data: Prisma.HostCreateInput = { + eventType: { + connect: { + id: eventType.id, + }, + }, + isFixed: host.isFixed ?? false, + user: { + connect: { + id: host.userId, + }, + }, + }; + + await prismock.host.create({ + data, }); } } } -async function addEventTypesToDb( +export async function addEventTypesToDb( eventTypes: (Omit< Prisma.EventTypeCreateInput, "users" | "worflows" | "destinationCalendar" | "schedule" > & { + id?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any users?: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -194,6 +207,7 @@ async function addEventTypesToDb( destinationCalendar?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; + metadata?: any; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); @@ -247,7 +261,7 @@ async function addEventTypesToDb( return allEventTypes; } -async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { +export async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { const baseEventType = { title: "Base EventType Title", slug: "base-event-type-slug", @@ -465,7 +479,9 @@ async function addWorkflows(workflows: InputWorkflow[]) { await addWorkflowsToDb(workflows); } -async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[] })[]) { +export async function addUsersToDb( + users: (Prisma.UserCreateInput & { schedules: Prisma.ScheduleCreateInput[]; id?: number })[] +) { log.silly("TestData: Creating Users", JSON.stringify(users)); await prismock.user.createMany({ data: users, @@ -491,7 +507,7 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma ); } -async function addTeamsToDb(teams: NonNullable[number]["team"][]) { +export async function addTeamsToDb(teams: NonNullable[number]["team"][]) { log.silly("TestData: Creating Teams", JSON.stringify(teams)); await prismock.team.createMany({ data: teams, @@ -640,6 +656,21 @@ export async function createOrganization(orgData: { return org; } +export async function createCredentials( + credentialData: { + type: string; + key: any; + id?: number; + userId?: number | null; + teamId?: number | null; + }[] +) { + const credentials = await prismock.credential.createMany({ + data: credentialData, + }); + return credentials; +} + // async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) { // await prismaMock.payment.createMany({ // data: payments, @@ -845,6 +876,23 @@ export const TestData = { ], timeZone: Timezones["+5:30"], }, + /** + * Has an overlap with IstMorningShift and IstEveningShift + */ + IstMidShift: { + name: "12:30AM to 8PM in India - 7:00AM to 14:30PM in GMT", + availability: [ + { + // userId: null, + // eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T12:30:00.000Z"), + endTime: new Date("1970-01-01T20:00:00.000Z"), + date: null, + }, + ], + timeZone: Timezones["+5:30"], + }, /** * Has an overlap with IstMorningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) */ @@ -1444,6 +1492,74 @@ export function mockErrorOnVideoMeetingCreation({ }); } +export function mockCrmApp( + metadataLookupKey: string, + crmData?: { + createContacts?: { + id: string; + email: string; + }[]; + getContacts?: { + id: string; + email: string; + ownerEmail: string; + }[]; + } +) { + let contactsCreated: { + id: string; + email: string; + }[] = []; + let contactsQueried: { + id: string; + email: string; + ownerEmail: string; + }[] = []; + const eventsCreated: boolean[] = []; + const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; + const appMock = appStoreMock.default[metadataLookupKey as keyof typeof appStoreMock.default]; + appMock && + `mockResolvedValue` in appMock && + appMock.mockResolvedValue({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + CrmService: class { + constructor() { + log.debug("Create CrmSerive"); + } + + createContact() { + if (crmData?.createContacts) { + contactsCreated = crmData.createContacts; + return Promise.resolve(crmData?.createContacts); + } + } + + getContacts(email: string) { + if (crmData?.getContacts) { + contactsQueried = crmData?.getContacts; + const contactsOfEmail = contactsQueried.filter((contact) => contact.email === email); + + return Promise.resolve(contactsOfEmail); + } + } + + createEvent() { + eventsCreated.push(true); + return Promise.resolve({}); + } + }, + }, + }); + + return { + contactsCreated, + contactsQueried, + eventsCreated, + }; +} + export function getBooker({ name, email }: { name: string; email: string }) { return { name, diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx index 4bfaa71db2..376e2d25ae 100644 --- a/packages/app-store/_components/AppCard.tsx +++ b/packages/app-store/_components/AppCard.tsx @@ -21,6 +21,7 @@ export default function AppCard({ disableSwitch, switchTooltip, hideSettingsIcon = false, + hideAppCardOptions = false, }: { app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner }; description?: React.ReactNode; @@ -33,6 +34,7 @@ export default function AppCard({ disableSwitch?: boolean; switchTooltip?: string; hideSettingsIcon?: boolean; + hideAppCardOptions?: boolean; }) { const { t } = useTranslation(); const [animationRef] = useAutoAnimate(); @@ -119,29 +121,31 @@ export default function AppCard({ -
- {app?.isInstalled && switchChecked &&
} + {hideAppCardOptions ? null : ( +
+ {app?.isInstalled && switchChecked &&
} - {app?.isInstalled && switchChecked ? ( - app.isSetupAlready === undefined || app.isSetupAlready ? ( -
- {!hideSettingsIcon && ( - -
+
+ ) + ) : null} +
+ )} ); } diff --git a/packages/app-store/_utils/getCrm.ts b/packages/app-store/_utils/getCrm.ts new file mode 100644 index 0000000000..ddb310bac8 --- /dev/null +++ b/packages/app-store/_utils/getCrm.ts @@ -0,0 +1,33 @@ +import logger from "@calcom/lib/logger"; +import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CRM } from "@calcom/types/CrmService"; + +import appStore from ".."; + +type Class = new (...args: Args) => I; + +type CrmClass = Class; + +const log = logger.getSubLogger({ prefix: ["CrmManager"] }); +export const getCrm = async (credential: CredentialPayload) => { + if (!credential || !credential.key) return null; + const { type: crmType } = credential; + + const crmName = crmType.split("_")[0]; + + const crmAppImportFn = appStore[crmName as keyof typeof appStore]; + + if (!crmAppImportFn) { + log.warn(`crm of type ${crmType} is not implemented`); + return null; + } + + const crmApp = await crmAppImportFn(); + + if (crmApp && "lib" in crmApp && "CrmService" in crmApp.lib) { + const CrmService = crmApp.lib.CrmService as CrmClass; + return new CrmService(credential); + } +}; + +export default getCrm; diff --git a/packages/app-store/_utils/oauth/createOAuthAppCredential.ts b/packages/app-store/_utils/oauth/createOAuthAppCredential.ts index 3e334e534a..6d10992e9b 100644 --- a/packages/app-store/_utils/oauth/createOAuthAppCredential.ts +++ b/packages/app-store/_utils/oauth/createOAuthAppCredential.ts @@ -44,7 +44,7 @@ const createOAuthAppCredential = async ( if (!team) throw new Error("User does not belong to the team"); - await prisma.credential.create({ + return await prisma.credential.create({ data: { type: appData.type, key: key || {}, @@ -52,13 +52,11 @@ const createOAuthAppCredential = async ( appId: appData.appId, }, }); - - return; } await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId }); - await prisma.credential.create({ + return await prisma.credential.create({ data: { type: appData.type, key: key || {}, @@ -66,8 +64,6 @@ const createOAuthAppCredential = async ( appId: appData.appId, }, }); - - return; }; export default createOAuthAppCredential; diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index 1d61f289d3..43f6396af2 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -89,7 +89,7 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta }; const stateStr = encodeURIComponent(JSON.stringify(state)); - const searchParams = `?state=${stateStr}${teamId ? `&teamId=${teamId}` : ""}`; + const searchParams = generateSearchParamString({ stateStr, teamId, returnTo }); const res = await fetch(`/api/integrations/${type}/add${searchParams}`); @@ -123,3 +123,25 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta } export default useAddAppMutation; +const generateSearchParamString = ({ + stateStr, + teamId, + returnTo, +}: { + stateStr: string; + teamId?: number; + returnTo?: string; +}) => { + const url = new URL("https://example.com"); // Base URL can be anything since we only care about the search params + + url.searchParams.append("state", stateStr); + if (teamId !== undefined) { + url.searchParams.append("teamId", teamId.toString()); + } + if (returnTo) { + url.searchParams.append("returnTo", returnTo); + } + + // Return the search string part of the URL + return url.search; +}; diff --git a/packages/app-store/_utils/useIsAppEnabled.ts b/packages/app-store/_utils/useIsAppEnabled.ts index c400cd4031..72e206df28 100644 --- a/packages/app-store/_utils/useIsAppEnabled.ts +++ b/packages/app-store/_utils/useIsAppEnabled.ts @@ -7,14 +7,18 @@ import type { EventTypeAppCardApp } from "../types"; function useIsAppEnabled(app: EventTypeAppCardApp) { const { getAppData, setAppData } = useAppContextWithSchema(); const [enabled, setEnabled] = useState(() => { + const isAppEnabled = getAppData("enabled"); + if (!app.credentialOwner) { - return getAppData("enabled"); + return isAppEnabled ?? false; // Default to false if undefined } + const credentialId = getAppData("credentialId"); const isAppEnabledForCredential = - app.userCredentialIds.some((id) => id === credentialId) || - app.credentialOwner.credentialId === credentialId; - return isAppEnabledForCredential; + isAppEnabled && + (app.userCredentialIds.some((id) => id === credentialId) || + app.credentialOwner.credentialId === credentialId); + return isAppEnabledForCredential ?? false; // Default to false if undefined }); const updateEnabled = (newValue: boolean) => { diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index cec2a72e07..c8da389854 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -23,17 +23,21 @@ export const AppSettingsComponentsMap = { export const EventTypeAddonMap = { alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")), basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")), + closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")), fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")), ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")), giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")), gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")), + hubspot: dynamic(() => import("./hubspot/components/EventTypeAppCardInterface")), matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")), metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")), "mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")), paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")), + "pipedrive-crm": dynamic(() => import("./pipedrive-crm/components/EventTypeAppCardInterface")), plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")), posthog: dynamic(() => import("./posthog/components/EventTypeAppCardInterface")), qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")), + salesforce: dynamic(() => import("./salesforce/components/EventTypeAppCardInterface")), stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")), "booking-pages-tag": dynamic(() => import("./templates/booking-pages-tag/components/EventTypeAppCardInterface") @@ -41,6 +45,8 @@ export const EventTypeAddonMap = { "event-type-app-card": dynamic(() => import("./templates/event-type-app-card/components/EventTypeAppCardInterface") ), + "zoho-bigin": dynamic(() => import("./zoho-bigin/components/EventTypeAppCardInterface")), + zohocrm: dynamic(() => import("./zohocrm/components/EventTypeAppCardInterface")), }; export const EventTypeSettingsMap = { alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index a745fcb137..b9210fd074 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -4,6 +4,7 @@ **/ import { appKeysSchema as alby_zod_ts } from "./alby/zod"; import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod"; +import { appKeysSchema as closecom_zod_ts } from "./closecom/zod"; import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appKeysSchema as fathom_zod_ts } from "./fathom/zod"; import { appKeysSchema as feishucalendar_zod_ts } from "./feishucalendar/zod"; @@ -46,6 +47,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; export const appKeysSchemas = { alby: alby_zod_ts, basecamp3: basecamp3_zod_ts, + closecom: closecom_zod_ts, dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, feishucalendar: feishucalendar_zod_ts, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 431f929c15..d9aa333c4d 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -4,6 +4,7 @@ **/ import { appDataSchema as alby_zod_ts } from "./alby/zod"; import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod"; +import { appDataSchema as closecom_zod_ts } from "./closecom/zod"; import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appDataSchema as fathom_zod_ts } from "./fathom/zod"; import { appDataSchema as feishucalendar_zod_ts } from "./feishucalendar/zod"; @@ -46,6 +47,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; export const appDataSchemas = { alby: alby_zod_ts, basecamp3: basecamp3_zod_ts, + closecom: closecom_zod_ts, dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, feishucalendar: feishucalendar_zod_ts, diff --git a/packages/app-store/closecom/api/_getAdd.ts b/packages/app-store/closecom/api/_getAdd.ts index 2b95ec6eef..2701c40ce2 100644 --- a/packages/app-store/closecom/api/_getAdd.ts +++ b/packages/app-store/closecom/api/_getAdd.ts @@ -6,5 +6,8 @@ import { checkInstalled } from "../../_utils/installation"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = checkSession(req); await checkInstalled("closecom", session.user?.id); - return res.status(200).json({ url: "/apps/closecom/setup" }); + + const returnTo = req.query.returnTo; + + return res.status(200).json({ url: `/apps/closecom/setup${returnTo ? `?returnTo=${returnTo}` : ""}` }); } diff --git a/packages/app-store/closecom/api/_postAdd.ts b/packages/app-store/closecom/api/_postAdd.ts index 8c96fd57d4..8d8c2f746e 100644 --- a/packages/app-store/closecom/api/_postAdd.ts +++ b/packages/app-store/closecom/api/_postAdd.ts @@ -28,13 +28,18 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) { try { await prisma.credential.create({ data, + select: { + id: true, + }, }); } catch (reason) { logger.error("Could not add Close.com app", reason); return res.status(500).json({ message: "Could not add Close.com app" }); } - return res.status(200).json({ url: getInstalledAppPath({ variant: "other", slug: "closecom" }) }); + return res.status(200).json({ + url: req.query.returnTo ? req.query.returnTo : getInstalledAppPath({ variant: "crm", slug: "closecom" }), + }); } export default defaultResponder(getHandler); diff --git a/packages/app-store/closecom/components/.gitkeep b/packages/app-store/closecom/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/closecom/components/EventTypeAppCardInterface.tsx b/packages/app-store/closecom/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..53a8297d6f --- /dev/null +++ b/packages/app-store/closecom/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,27 @@ +import { usePathname } from "next/navigation"; + +import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const pathname = usePathname(); + + const { enabled, updateEnabled } = useIsAppEnabled(app); + + return ( + { + updateEnabled(e); + }} + switchChecked={enabled} + hideAppCardOptions + /> + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/closecom/config.json b/packages/app-store/closecom/config.json index 5224a8ab02..c35465c204 100644 --- a/packages/app-store/closecom/config.json +++ b/packages/app-store/closecom/config.json @@ -9,6 +9,7 @@ "variant": "crm", "categories": ["crm"], "publisher": "Cal.com, Inc.", + "extendsFeature": "EventType", "email": "help@cal.com", "description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.", "__createdUsingCli": true, diff --git a/packages/app-store/closecom/lib/CalendarService.ts b/packages/app-store/closecom/lib/CrmService.ts similarity index 66% rename from packages/app-store/closecom/lib/CalendarService.ts rename to packages/app-store/closecom/lib/CrmService.ts index a53fc403c1..86a4ddc900 100644 --- a/packages/app-store/closecom/lib/CalendarService.ts +++ b/packages/app-store/closecom/lib/CrmService.ts @@ -5,14 +5,9 @@ import CloseCom from "@calcom/lib/CloseCom"; import { getCustomActivityTypeInstanceData } from "@calcom/lib/CloseComeUtils"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import logger from "@calcom/lib/logger"; -import type { - Calendar, - CalendarEvent, - EventBusyDate, - IntegrationCalendar, - NewCalendarEventType, -} from "@calcom/types/Calendar"; +import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CRM, ContactCreateInput, CrmEvent, Contact } from "@calcom/types/CrmService"; const apiKeySchema = z.object({ encrypted: z.string(), @@ -51,7 +46,7 @@ const calComCustomActivityFields: CloseComFieldOptions = [ * Close.com as part of this integration, a new generic Lead will be created in order * to assign every contact created by this process, and it is named "From Cal.com" */ -export default class CloseComCalendarService implements Calendar { +export default class CloseComCRMService implements CRM { private integrationName = ""; private closeCom: CloseCom; private log: typeof logger; @@ -91,7 +86,7 @@ export default class CloseComCalendarService implements Calendar { return this.closeCom.activity.custom.delete(uid); }; - async createEvent(event: CalendarEvent): Promise { + async createEvent(event: CalendarEvent): Promise { const customActivityTypeInstanceData = await getCustomActivityTypeInstanceData( event, calComCustomActivityFields, @@ -110,27 +105,76 @@ export default class CloseComCalendarService implements Calendar { additionalInfo: { customActivityTypeInstanceData, }, + success: true, }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - async updateEvent(uid: string, event: CalendarEvent): Promise { - return await this.closeComUpdateCustomActivity(uid, event); + async updateEvent(uid: string, event: CalendarEvent): Promise { + const updatedEvent = await this.closeComUpdateCustomActivity(uid, event); + return { + id: updatedEvent.id, + }; } async deleteEvent(uid: string): Promise { - return await this.closeComDeleteCustomActivity(uid); + await this.closeComDeleteCustomActivity(uid); } - async getAvailability( - _dateFrom: string, - _dateTo: string, - _selectedCalendars: IntegrationCalendar[] - ): Promise { - return Promise.resolve([]); + async getContacts(emails: string | string[]): Promise { + const contactsQuery = await this.closeCom.contact.search({ + emails: Array.isArray(emails) ? emails : [emails], + }); + + return contactsQuery.data.map((contact) => { + return { + id: contact.id, + email: contact.emails[0].email, + name: contact.name, + }; + }); } - async listCalendars(_event?: CalendarEvent): Promise { - return Promise.resolve([]); + async createContacts(contactsToCreate: ContactCreateInput[]): Promise { + // In Close.com contacts need to be attached to a lead + // Assume all attendees in an event belong under a lead + + const contacts = []; + + // Create main lead + const lead = await this.closeCom.lead.create({ + contactName: contactsToCreate[0].name, + contactEmail: contactsToCreate[0].email, + }); + + contacts.push({ + id: lead.contacts[0].id, + email: lead.contacts[0].emails[0].email, + }); + + // Check if we need to crate more contacts under the lead + if (contactsToCreate.length > 1) { + const createContactPromise = []; + for (const contact of contactsToCreate) { + createContactPromise.push( + this.closeCom.contact.create({ + leadId: lead.id, + person: { + email: contact.email, + name: contact.name, + }, + }) + ); + const createdContacts = await Promise.all(createContactPromise); + for (const createdContact of createdContacts) { + contacts.push({ + id: createdContact.id, + email: createdContact.emails[0].email, + }); + } + } + } + + return contacts; } } diff --git a/packages/app-store/closecom/lib/index.ts b/packages/app-store/closecom/lib/index.ts index e168c149df..ac64aeaddb 100644 --- a/packages/app-store/closecom/lib/index.ts +++ b/packages/app-store/closecom/lib/index.ts @@ -1 +1 @@ -export { default as CalendarService } from "./CalendarService"; +export { default as CrmService } from "./CrmService"; diff --git a/packages/app-store/closecom/pages/setup/index.tsx b/packages/app-store/closecom/pages/setup/index.tsx index 43978e4225..789fdd2456 100644 --- a/packages/app-store/closecom/pages/setup/index.tsx +++ b/packages/app-store/closecom/pages/setup/index.tsx @@ -6,6 +6,7 @@ import { Toaster } from "react-hot-toast"; import z from "zod"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { Button, Form, showToast, TextField } from "@calcom/ui"; const formSchema = z.object({ @@ -15,6 +16,7 @@ const formSchema = z.object({ export default function CloseComSetup() { const { t } = useLocale(); const router = useRouter(); + const query = useRouterQuery(); const [testPassed, setTestPassed] = useState(undefined); const [testLoading, setTestLoading] = useState(false); @@ -63,13 +65,17 @@ export default function CloseComSetup() {
{ - const res = await fetch("/api/integrations/closecom/add", { - method: "POST", - body: JSON.stringify(values), - headers: { - "Content-Type": "application/json", - }, - }); + const { returnTo } = query; + const res = await fetch( + `/api/integrations/closecom/add${returnTo ? `?returnTo=${returnTo}` : ""}`, + { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + } + ); const json = await res.json(); if (res.ok) { diff --git a/packages/app-store/closecom/zod.ts b/packages/app-store/closecom/zod.ts new file mode 100644 index 0000000000..b2245912f0 --- /dev/null +++ b/packages/app-store/closecom/zod.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; + +export const appDataSchema = eventTypeAppCardZod; + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/hubspot/_metadata.ts b/packages/app-store/hubspot/_metadata.ts index 7212d9635d..b3738e2b15 100644 --- a/packages/app-store/hubspot/_metadata.ts +++ b/packages/app-store/hubspot/_metadata.ts @@ -14,6 +14,7 @@ export const metadata = { categories: ["crm"], label: "HubSpot CRM", slug: "hubspot", + extendsFeature: "EventType", title: "HubSpot CRM", email: "help@cal.com", dirName: "hubspot", diff --git a/packages/app-store/hubspot/api/callback.ts b/packages/app-store/hubspot/api/callback.ts index 14016534b8..f391bb186d 100644 --- a/packages/app-store/hubspot/api/callback.ts +++ b/packages/app-store/hubspot/api/callback.ts @@ -21,6 +21,7 @@ export interface HubspotToken extends TokenResponseIF { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; + const state = decodeOAuthState(req); if (code && typeof code !== "string") { res.status(400).json({ message: "`code` must be a string" }); @@ -50,7 +51,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: metadata.slug, type: metadata.type }, hubspotToken, req); - const state = decodeOAuthState(req); res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "hubspot" }) ); diff --git a/packages/app-store/hubspot/components/.gitkeep b/packages/app-store/hubspot/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..53a8297d6f --- /dev/null +++ b/packages/app-store/hubspot/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,27 @@ +import { usePathname } from "next/navigation"; + +import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const pathname = usePathname(); + + const { enabled, updateEnabled } = useIsAppEnabled(app); + + return ( + { + updateEnabled(e); + }} + switchChecked={enabled} + hideAppCardOptions + /> + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/hubspot/lib/CalendarService.ts b/packages/app-store/hubspot/lib/CrmService.ts similarity index 71% rename from packages/app-store/hubspot/lib/CalendarService.ts rename to packages/app-store/hubspot/lib/CrmService.ts index 2d1cf3bdd3..7cbbe1f9a6 100644 --- a/packages/app-store/hubspot/lib/CalendarService.ts +++ b/packages/app-store/hubspot/lib/CrmService.ts @@ -12,15 +12,9 @@ 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 { - Calendar, - CalendarEvent, - EventBusyDate, - IntegrationCalendar, - NewCalendarEventType, - Person, -} from "@calcom/types/Calendar"; +import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CRM, ContactCreateInput, Contact, CrmEvent } from "@calcom/types/CrmService"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; @@ -28,11 +22,11 @@ import type { HubspotToken } from "../api/callback"; const hubspotClient = new hubspot.Client(); -interface CustomPlublicObjectInput extends SimplePublicObjectInput { +interface CustomPublicObjectInput extends SimplePublicObjectInput { id?: string; } -export default class HubspotCalendarService implements Calendar { +export default class HubspotCalendarService implements CRM { private url = ""; private integrationName = ""; private auth: Promise<{ getToken: () => Promise }>; @@ -48,56 +42,6 @@ export default class HubspotCalendarService implements Calendar { this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } - private hubspotContactCreate = async (attendees: Person[]) => { - const simplePublicObjectInputs: SimplePublicObjectInput[] = attendees.map((attendee) => { - const [firstname, lastname] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""]; - return { - properties: { - firstname, - lastname, - email: attendee.email, - }, - }; - }); - return Promise.all( - simplePublicObjectInputs.map((contact) => - hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => { - // If multiple events are created, subsequent events may fail due to - // contact was created by previous event creation, so we introduce a - // fallback taking advantage of the error message providing the contact id - if (error.body.message.includes("Contact already exists. Existing ID:")) { - const split = error.body.message.split("Contact already exists. Existing ID: "); - return { id: split[1] }; - } else { - throw error; - } - }) - ) - ); - }; - - private hubspotContactSearch = async (event: CalendarEvent) => { - const publicObjectSearchRequest: PublicObjectSearchRequest = { - filterGroups: event.attendees.map((attendee) => ({ - filters: [ - { - value: attendee.email, - propertyName: "email", - operator: "EQ", - }, - ], - })), - sorts: ["hs_object_id"], - properties: ["hs_object_id", "email"], - limit: 10, - after: 0, - }; - - return await hubspotClient.crm.contacts.searchApi - .doSearch(publicObjectSearchRequest) - .then((apiResponse) => apiResponse.results); - }; - private getHubspotMeetingBody = (event: CalendarEvent): string => { return `${event.organizer.language.translate("invitee_timezone")}: ${ event.attendees[0].timeZone @@ -211,7 +155,7 @@ export default class HubspotCalendarService implements Calendar { }; }; - async handleMeetingCreation(event: CalendarEvent, contacts: CustomPlublicObjectInput[]) { + async handleMeetingCreation(event: CalendarEvent, contacts: Contact[]) { const contactIds: { id?: string }[] = contacts.map((contact) => ({ id: contact.id })); const meetingEvent = await this.hubspotCreateMeeting(event); if (meetingEvent) { @@ -234,49 +178,11 @@ export default class HubspotCalendarService implements Calendar { return Promise.reject("Something went wrong when creating a meeting in HubSpot"); } - async createEvent(event: CalendarEvent): Promise { + async createEvent(event: CalendarEvent, contacts: Contact[]): Promise { const auth = await this.auth; await auth.getToken(); - const contacts = await this.hubspotContactSearch(event); - if (contacts.length) { - if (contacts.length == event.attendees.length) { - // All attendees do exist in HubSpot - this.log.debug("contact:search:all", { event, contacts }); - return await this.handleMeetingCreation(event, contacts); - } else { - // Some attendees don't exist in HubSpot - // Get the existing contacts' email to filter out - this.log.debug("contact:search:notAll", { event, contacts }); - const existingContacts = contacts.map((contact) => contact.properties.email); - this.log.debug("contact:filter:existing", { existingContacts }); - // Get non existing contacts filtering out existing from attendees - const nonExistingContacts = event.attendees.filter( - (attendee) => !existingContacts.includes(attendee.email) - ); - this.log.debug("contact:filter:nonExisting", { nonExistingContacts }); - // Only create contacts in HubSpot that were not present in the previous contact search - const createContacts = await this.hubspotContactCreate(nonExistingContacts); - this.log.debug("contact:created", { createContacts }); - // Continue with meeting creation and association only when all contacts are present in HubSpot - if (createContacts.length) { - this.log.debug("contact:creation:ok"); - return await this.handleMeetingCreation( - event, - createContacts.concat(contacts) as SimplePublicObjectInput[] - ); - } - return Promise.reject("Something went wrong when creating non-existing attendees in HubSpot"); - } - } else { - this.log.debug("contact:search:none", { event, contacts }); - const createContacts = await this.hubspotContactCreate(event.attendees); - this.log.debug("contact:created", { createContacts }); - if (createContacts.length) { - this.log.debug("contact:creation:ok"); - return await this.handleMeetingCreation(event, createContacts as SimplePublicObjectInput[]); - } - } - return Promise.reject("Something went wrong when searching/creating the attendees in HubSpot"); + + return await this.handleMeetingCreation(event, contacts); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -292,15 +198,75 @@ export default class HubspotCalendarService implements Calendar { return await this.hubspotDeleteMeeting(uid); } - async getAvailability( - _dateFrom: string, - _dateTo: string, - _selectedCalendars: IntegrationCalendar[] - ): Promise { - return Promise.resolve([]); + async getContacts(emails: string | string[]): Promise { + const auth = await this.auth; + await auth.getToken(); + + const emailArray = Array.isArray(emails) ? emails : [emails]; + + const publicObjectSearchRequest: PublicObjectSearchRequest = { + filterGroups: emailArray.map((attendeeEmail) => ({ + filters: [ + { + value: attendeeEmail, + propertyName: "email", + operator: "EQ", + }, + ], + })), + sorts: ["hs_object_id"], + properties: ["hs_object_id", "email"], + limit: 10, + after: 0, + }; + + const contacts = await hubspotClient.crm.contacts.searchApi + .doSearch(publicObjectSearchRequest) + .then((apiResponse) => apiResponse.results); + + return contacts.map((contact) => { + return { + id: contact.id, + email: contact.properties.email, + }; + }); } - async listCalendars(_event?: CalendarEvent): Promise { - return Promise.resolve([]); + async createContacts(contactsToCreate: ContactCreateInput[]): Promise { + const auth = await this.auth; + await auth.getToken(); + + const simplePublicObjectInputs = contactsToCreate.map((attendee) => { + const [firstname, lastname] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""]; + return { + properties: { + firstname, + lastname, + email: attendee.email, + }, + }; + }); + const createdContacts = await Promise.all( + simplePublicObjectInputs.map((contact) => + hubspotClient.crm.contacts.basicApi.create(contact).catch((error) => { + // If multiple events are created, subsequent events may fail due to + // contact was created by previous event creation, so we introduce a + // fallback taking advantage of the error message providing the contact id + if (error.body.message.includes("Contact already exists. Existing ID:")) { + const split = error.body.message.split("Contact already exists. Existing ID: "); + return { id: split[1], properties: contact.properties }; + } else { + throw error; + } + }) + ) + ); + + return createdContacts.map((contact) => { + return { + id: contact.id, + email: contact.properties.email, + }; + }); } } diff --git a/packages/app-store/hubspot/lib/index.ts b/packages/app-store/hubspot/lib/index.ts index e168c149df..ac64aeaddb 100644 --- a/packages/app-store/hubspot/lib/index.ts +++ b/packages/app-store/hubspot/lib/index.ts @@ -1 +1 @@ -export { default as CalendarService } from "./CalendarService"; +export { default as CrmService } from "./CrmService"; diff --git a/packages/app-store/hubspot/zod.ts b/packages/app-store/hubspot/zod.ts index fe378bc4bc..8cfdc5ee22 100644 --- a/packages/app-store/hubspot/zod.ts +++ b/packages/app-store/hubspot/zod.ts @@ -1,8 +1,10 @@ import { z } from "zod"; +import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; + +export const appDataSchema = eventTypeAppCardZod; + export const appKeysSchema = z.object({ client_id: z.string().min(1), client_secret: z.string().min(1), }); - -export const appDataSchema = z.object({}); diff --git a/packages/app-store/pipedrive-crm/api/add.ts b/packages/app-store/pipedrive-crm/api/add.ts index c1546139a6..43a22a15ab 100644 --- a/packages/app-store/pipedrive-crm/api/add.ts +++ b/packages/app-store/pipedrive-crm/api/add.ts @@ -28,6 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) key: {}, teamId: Number(teamId), }); + const tenantId = teamId ? teamId : userId; res.status(200).json({ url: `https://oauth.pipedrive.com/oauth/authorize?client_id=${appKeys.client_id}&redirect_uri=https://app.revert.dev/oauth-callback/pipedrive&state={%22tenantId%22:%22${tenantId}%22,%22revertPublicToken%22:%22${process.env.REVERT_PUBLIC_TOKEN}%22}`, diff --git a/packages/app-store/pipedrive-crm/components/.gitkeep b/packages/app-store/pipedrive-crm/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/pipedrive-crm/components/EventTypeAppCardInterface.tsx b/packages/app-store/pipedrive-crm/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..53a8297d6f --- /dev/null +++ b/packages/app-store/pipedrive-crm/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,27 @@ +import { usePathname } from "next/navigation"; + +import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const pathname = usePathname(); + + const { enabled, updateEnabled } = useIsAppEnabled(app); + + return ( + { + updateEnabled(e); + }} + switchChecked={enabled} + hideAppCardOptions + /> + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/pipedrive-crm/config.json b/packages/app-store/pipedrive-crm/config.json index 013e361fa9..e4832b8b9e 100644 --- a/packages/app-store/pipedrive-crm/config.json +++ b/packages/app-store/pipedrive-crm/config.json @@ -7,6 +7,7 @@ "url": "https://revert.dev", "variant": "crm", "categories": ["crm"], + "extendsFeature": "EventType", "publisher": "Revert.dev ", "email": "jatin@revert.dev", "description": "Founded in 2010, Pipedrive is an easy and effective sales CRM that drives small business growth.\r\rToday, Pipedrive is used by revenue teams at more than 100,000 companies worldwide. Pipedrive is headquartered in New York and has offices across Europe and the US.\r\rThe company is backed by majority holder Vista Equity Partners, Bessemer Venture Partners, Insight Partners, Atomico, and DTCP.\r\rLearn more at www.pipedrive.com.", diff --git a/packages/app-store/pipedrive-crm/lib/CalendarService.ts b/packages/app-store/pipedrive-crm/lib/CrmService.ts similarity index 58% rename from packages/app-store/pipedrive-crm/lib/CalendarService.ts rename to packages/app-store/pipedrive-crm/lib/CrmService.ts index a3c80cd0a7..ee5dac16a0 100644 --- a/packages/app-store/pipedrive-crm/lib/CalendarService.ts +++ b/packages/app-store/pipedrive-crm/lib/CrmService.ts @@ -1,14 +1,13 @@ import { getLocation } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import type { - Calendar, CalendarEvent, EventBusyDate, IntegrationCalendar, NewCalendarEventType, - Person, } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { ContactCreateInput, CRM, Contact } from "@calcom/types/CrmService"; import appConfig from "../config.json"; @@ -34,7 +33,7 @@ type ContactCreateResult = { }; }; -export default class PipedriveCalendarService implements Calendar { +export default class PipedriveCrmService implements CRM { private log: typeof logger; private tenantId: string; private revertApiKey: string; @@ -46,8 +45,8 @@ export default class PipedriveCalendarService implements Calendar { this.log = logger.getSubLogger({ prefix: [`[[lib] ${appConfig.slug}`] }); } - private createContacts = async (attendees: Person[]) => { - const result = attendees.map(async (attendee) => { + async createContacts(contactsToCreate: ContactCreateInput[]): Promise { + const result = contactsToCreate.map(async (attendee) => { const headers = new Headers(); headers.append("x-revert-api-token", this.revertApiKey); headers.append("x-revert-t-id", this.tenantId); @@ -74,17 +73,21 @@ export default class PipedriveCalendarService implements Calendar { return Promise.reject(error); } }); - return await Promise.all(result); - }; - private contactSearch = async (event: CalendarEvent) => { - const result = event.attendees.map(async (attendee) => { + const results = await Promise.all(result); + return results.map((result) => result.result); + } + + async getContacts(email: string | string[]): Promise { + const emailArray = Array.isArray(email) ? email : [email]; + + const result = emailArray.map(async (attendeeEmail) => { const headers = new Headers(); headers.append("x-revert-api-token", this.revertApiKey); headers.append("x-revert-t-id", this.tenantId); headers.append("Content-Type", "application/json"); - const bodyRaw = JSON.stringify({ searchCriteria: attendee.email }); + const bodyRaw = JSON.stringify({ searchCriteria: attendeeEmail }); const requestOptions = { method: "POST", @@ -100,8 +103,9 @@ export default class PipedriveCalendarService implements Calendar { return { status: "error", results: [] }; } }); - return await Promise.all(result); - }; + const results = await Promise.all(result); + return results[0].results; + } private getMeetingBody = (event: CalendarEvent): string => { return `${event.organizer.language.translate("invitee_timezone")}: ${ @@ -111,7 +115,7 @@ export default class PipedriveCalendarService implements Calendar { }`; }; - private createPipedriveEvent = async (event: CalendarEvent, contacts: CalendarEvent["attendees"]) => { + private createPipedriveEvent = async (event: CalendarEvent, contacts: Contact[]) => { const eventPayload = { subject: event.title, startDateTime: event.startTime, @@ -173,7 +177,7 @@ export default class PipedriveCalendarService implements Calendar { return await fetch(`${this.revertApiUrl}crm/events/${uid}`, requestOptions); }; - async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) { + async handleEventCreation(event: CalendarEvent, contacts: Contact[]) { const meetingEvent = await (await this.createPipedriveEvent(event, contacts)).json(); if (meetingEvent && meetingEvent.status === "ok") { this.log.debug("event:creation:ok", { meetingEvent }); @@ -190,92 +194,8 @@ export default class PipedriveCalendarService implements Calendar { return Promise.reject("Something went wrong when creating a meeting in PipedriveCRM"); } - async createEvent(event: CalendarEvent): Promise { - let contacts = await this.contactSearch(event); - contacts = contacts.filter((c) => c.results.length >= 1); - if (contacts && contacts.length) { - if (contacts.length === event.attendees.length) { - // all contacts are in Pipedrive CRM already. - this.log.debug("contact:search:all", { event, contacts: contacts }); - const existingPeople = contacts.map((c) => { - return { - id: Number(c.results[0].id), - name: `${c.results[0].firstName} ${c.results[0].lastName}`, - email: c.results[0].email, - timeZone: event.attendees[0].timeZone, - language: event.attendees[0].language, - }; - }); - return await this.handleEventCreation(event, existingPeople); - } else { - // Some attendees don't exist in PipedriveCRM - // Get the existing contacts' email to filter out - this.log.debug("contact:search:notAll", { event, contacts }); - const existingContacts = contacts.map((contact) => contact.results[0].email); - this.log.debug("contact:filter:existing", { existingContacts }); - // Get non existing contacts filtering out existing from attendees - const nonExistingContacts: Person[] = event.attendees.filter( - (attendee) => !existingContacts.includes(attendee.email) - ); - this.log.debug("contact:filter:nonExisting", { nonExistingContacts }); - // Only create contacts in PipedriveCRM that were not present in the previous contact search - const createdContacts = await this.createContacts(nonExistingContacts); - this.log.debug("contact:created", { createdContacts }); - // Continue with event creation and association only when all contacts are present in Pipedrive - if (createdContacts[0] && createdContacts[0].status === "ok") { - this.log.debug("contact:creation:ok"); - const existingPeople = contacts.map((c) => { - return { - id: Number(c.results[0].id), - name: c.results[0].name, - email: c.results[0].email, - timeZone: nonExistingContacts[0].timeZone, - language: nonExistingContacts[0].language, - }; - }); - const newlyCreatedPeople = createdContacts.map((c) => { - return { - id: Number(c.result.id), - name: c.result.name, - email: c.result.email, - timeZone: nonExistingContacts[0].timeZone, - language: nonExistingContacts[0].language, - }; - }); - const allContacts = existingPeople.concat(newlyCreatedPeople); - // ensure the order of attendees is maintained. - allContacts.sort((a, b) => { - const indexA = event.attendees.findIndex((c) => c.email === a.email); - const indexB = event.attendees.findIndex((c) => c.email === b.email); - return indexA - indexB; - }); - return await this.handleEventCreation(event, allContacts); - } - return Promise.reject({ - calError: "Something went wrong when creating non-existing attendees in PipedriveCRM", - }); - } - } else { - this.log.debug("contact:search:none", { event, contacts }); - const createdContacts = await this.createContacts(event.attendees); - this.log.debug("contact:created", { createdContacts }); - if (createdContacts[0] && createdContacts[0].status === "ok") { - this.log.debug("contact:creation:ok"); - const newContacts = createdContacts.map((c) => { - return { - id: Number(c.result.id), - name: c.result.name, - email: c.result.email, - timeZone: event.attendees[0].timeZone, - language: event.attendees[0].language, - }; - }); - return await this.handleEventCreation(event, newContacts); - } - } - return Promise.reject({ - calError: "Something went wrong when searching/creating the attendees in PipedriveCRM", - }); + async createEvent(event: CalendarEvent, contacts: Contact[]): Promise { + return await this.handleEventCreation(event, contacts); } async updateEvent(uid: string, event: CalendarEvent): Promise { diff --git a/packages/app-store/pipedrive-crm/lib/index.ts b/packages/app-store/pipedrive-crm/lib/index.ts index e168c149df..ac64aeaddb 100644 --- a/packages/app-store/pipedrive-crm/lib/index.ts +++ b/packages/app-store/pipedrive-crm/lib/index.ts @@ -1 +1 @@ -export { default as CalendarService } from "./CalendarService"; +export { default as CrmService } from "./CrmService"; diff --git a/packages/app-store/pipedrive-crm/zod.ts b/packages/app-store/pipedrive-crm/zod.ts index fe378bc4bc..81ae9e0eb2 100644 --- a/packages/app-store/pipedrive-crm/zod.ts +++ b/packages/app-store/pipedrive-crm/zod.ts @@ -1,8 +1,10 @@ import { z } from "zod"; +import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; + export const appKeysSchema = z.object({ client_id: z.string().min(1), client_secret: z.string().min(1), }); -export const appDataSchema = z.object({}); +export const appDataSchema = eventTypeAppCardZod; diff --git a/packages/app-store/salesforce/api/callback.ts b/packages/app-store/salesforce/api/callback.ts index a764389404..67ab7001ac 100644 --- a/packages/app-store/salesforce/api/callback.ts +++ b/packages/app-store/salesforce/api/callback.ts @@ -15,6 +15,7 @@ let consumer_secret = ""; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; + const state = decodeOAuthState(req); if (code === undefined && typeof code !== "string") { res.status(400).json({ message: "`code` must be a string" }); @@ -41,8 +42,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, salesforceTokenInfo, req); - const state = decodeOAuthState(req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { return res.redirect(state.appOnboardingRedirectUrl); } diff --git a/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..643d630d48 --- /dev/null +++ b/packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,55 @@ +import { usePathname } from "next/navigation"; + +import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; +import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; +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 { Switch, Alert } from "@calcom/ui"; + +import type { appDataSchema } from "../zod"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const pathname = usePathname(); + + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); + const { enabled, updateEnabled } = useIsAppEnabled(app); + const isRoundRobinLeadSkipEnabled = getAppData("roundRobinLeadSkip"); + const { t } = useLocale(); + + return ( + { + updateEnabled(e); + }} + switchChecked={enabled} + hideSettingsIcon> + <> + {eventType.schedulingType === SchedulingType.ROUND_ROBIN ? ( +
+ { + setAppData("roundRobinLeadSkip", checked); + if (checked) { + // temporary solution, enabled should always be already set + setAppData("enabled", checked); + } + }} + /> + +
+ ) : null} + +
+ ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/salesforce/config.json b/packages/app-store/salesforce/config.json index d159830f94..7ff03a6ab6 100644 --- a/packages/app-store/salesforce/config.json +++ b/packages/app-store/salesforce/config.json @@ -7,6 +7,7 @@ "url": "https://cal.com/", "variant": "crm", "categories": ["crm"], + "extendsFeature": "EventType", "publisher": "Cal.com, Inc.", "email": "help@cal.com", "description": "Salesforce (Sales Cloud) is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day.", diff --git a/packages/app-store/salesforce/lib/CalendarService.ts b/packages/app-store/salesforce/lib/CrmService.ts similarity index 70% rename from packages/app-store/salesforce/lib/CalendarService.ts rename to packages/app-store/salesforce/lib/CrmService.ts index 4ba50ec028..a31b3f4b69 100644 --- a/packages/app-store/salesforce/lib/CalendarService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -8,14 +8,9 @@ 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 { - Calendar, - CalendarEvent, - IntegrationCalendar, - NewCalendarEventType, - Person, -} from "@calcom/types/Calendar"; +import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse"; @@ -38,6 +33,13 @@ const sfApiErrors = { INVALID_EVENTWHOIDS: "INVALID_FIELD: No such column 'EventWhoIds' on sobject of type Event", }; +type ContactRecord = { + Id: string; + Email: string; + OwnerId: string; + [key: string]: any; +}; + const salesforceTokenSchema = z.object({ id: z.string(), issued_at: z.string(), @@ -48,7 +50,7 @@ const salesforceTokenSchema = z.object({ token_type: z.string(), }); -export default class SalesforceCalendarService implements Calendar { +export default class SalesforceCRMService implements CRM { private integrationName = ""; private conn: Promise; private log: typeof logger; @@ -89,7 +91,6 @@ export default class SalesforceCalendarService implements Calendar { refresh_token: credentialKey.refresh_token, }), }); - if (!response.ok) { const message = `${response.statusText}: ${JSON.stringify(await response.json())}`; throw new Error(message); @@ -118,37 +119,14 @@ export default class SalesforceCalendarService implements Calendar { }); }; - private salesforceContactCreate = async (attendees: Person[]) => { + private getSalesforceUserFromEmail = async (email: string) => { const conn = await this.conn; - const createdContacts = await Promise.all( - attendees.map(async (attendee) => { - const [FirstName, LastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""]; - return await conn - .sobject("Contact") - .create({ - FirstName, - LastName: LastName || "", - Email: attendee.email, - }) - .then((result) => { - if (result.success) { - return { Id: result.id, Email: attendee.email }; - } - }); - }) - ); - return createdContacts.filter( - (contact): contact is Omit => contact !== undefined - ); + return await conn.query(`SELECT Id, Email FROM User WHERE Email = '${email}'`); }; - private salesforceContactSearch = async (event: CalendarEvent) => { + private getSalesforceUserFromOwnerId = async (ownerId: string) => { const conn = await this.conn; - const search: ContactSearchResult[] = await conn.sobject("Contact").find( - event.attendees.map((att) => ({ Email: att.email })), - ["Id", "Email"] - ); - return search; + return await conn.query(`SELECT Id, Email FROM User WHERE Id = '${ownerId}'`); }; private getSalesforceEventBody = (event: CalendarEvent): string => { @@ -178,12 +156,9 @@ export default class SalesforceCalendarService implements Calendar { }); }; - private salesforceCreateEvent = async ( - event: CalendarEvent, - contacts: Omit[] - ) => { + private salesforceCreateEvent = async (event: CalendarEvent, contacts: Contact[]) => { const createdEvent = await this.salesforceCreateEventApiCall(event, { - EventWhoIds: contacts.map((contact) => contact.Id), + EventWhoIds: contacts.map((contact) => contact.id), }).catch(async (reason) => { if (reason === sfApiErrors.INVALID_EVENTWHOIDS) { this.calWarnings.push( @@ -224,7 +199,7 @@ export default class SalesforceCalendarService implements Calendar { return await conn.sobject("Event").delete(uid); }; - async handleEventCreation(event: CalendarEvent, contacts: Omit[]) { + async handleEventCreation(event: CalendarEvent, contacts: Contact[]) { const sfEvent = await this.salesforceCreateEvent(event, contacts); if (sfEvent.success) { this.log.debug("event:creation:ok", { sfEvent }); @@ -243,51 +218,23 @@ export default class SalesforceCalendarService implements Calendar { }); } - async createEvent(event: CalendarEvent): Promise { - const contacts = await this.salesforceContactSearch(event); - if (contacts.length) { - if (contacts.length == event.attendees.length) { - // All attendees do exist in Salesforce - this.log.debug("contact:search:all", { event, contacts }); - return await this.handleEventCreation(event, contacts); - } else { - // Some attendees don't exist in Salesforce - // Get the existing contacts' email to filter out - this.log.debug("contact:search:notAll", { event, contacts }); - const existingContacts = contacts.map((contact) => contact.Email); - this.log.debug("contact:filter:existing", { existingContacts }); - // Get non existing contacts filtering out existing from attendees - const nonExistingContacts = event.attendees.filter( - (attendee) => !existingContacts.includes(attendee.email) - ); - this.log.debug("contact:filter:nonExisting", { nonExistingContacts }); - // Only create contacts in Salesforce that were not present in the previous contact search - const createContacts = await this.salesforceContactCreate(nonExistingContacts); - this.log.debug("contact:created", { createContacts }); - // Continue with event creation and association only when all contacts are present in Salesforce - if (createContacts.length) { - this.log.debug("contact:creation:ok"); - return await this.handleEventCreation(event, createContacts.concat(contacts)); - } - return Promise.reject({ - calError: "Something went wrong when creating non-existing attendees in Salesforce", - }); - } - } else { - this.log.debug("contact:search:none", { event, contacts }); - const createContacts = await this.salesforceContactCreate(event.attendees); - this.log.debug("contact:created", { createContacts }); - if (createContacts.length) { - this.log.debug("contact:creation:ok"); - return await this.handleEventCreation(event, createContacts); - } + async createEvent(event: CalendarEvent, contacts: Contact[]): Promise { + const sfEvent = await this.salesforceCreateEvent(event, contacts); + if (sfEvent.success) { + return Promise.resolve({ + uid: sfEvent.id, + id: sfEvent.id, + type: "salesforce_other_calendar", + password: "", + url: "", + additionalInfo: { contacts, sfEvent, calWarnings: this.calWarnings }, + }); } - return Promise.reject({ - calError: "Something went wrong when searching/creating the attendees in Salesforce", - }); + this.log.debug("event:creation:notOk", { event, sfEvent, contacts }); + return Promise.reject("Something went wrong when creating an event in Salesforce"); } - async updateEvent(uid: string, event: CalendarEvent): Promise { + async updateEvent(uid: string, event: CalendarEvent): Promise { const updatedEvent = await this.salesforceUpdateEvent(uid, event); if (updatedEvent.success) { return Promise.resolve({ @@ -303,7 +250,7 @@ export default class SalesforceCalendarService implements Calendar { } } - async deleteEvent(uid: string) { + public async deleteEvent(uid: string) { const deletedEvent = await this.salesforceDeleteEvent(uid); if (deletedEvent.success) { Promise.resolve(); @@ -312,11 +259,72 @@ export default class SalesforceCalendarService implements Calendar { } } - async getAvailability(_dateFrom: string, _dateTo: string, _selectedCalendars: IntegrationCalendar[]) { - return Promise.resolve([]); + async getContacts(email: string | string[], includeOwner?: boolean) { + const conn = await this.conn; + const emails = Array.isArray(email) ? email : [email]; + const soql = `SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('${emails.join("','")}')`; + const results = await conn.query(soql); + + if (!results || !results.records.length) return []; + + const records = results.records as ContactRecord[]; + + if (includeOwner) { + const ownerIds: Set = new Set(); + records.forEach((record) => { + ownerIds.add(record.OwnerId); + }); + + const ownersQuery = (await Promise.all( + Array.from(ownerIds).map(async (ownerId) => { + return this.getSalesforceUserFromOwnerId(ownerId); + }) + )) as { records: ContactRecord[] }[]; + const contactsWithOwners = records.map((record) => { + const ownerEmail = ownersQuery.find((user) => user.records[0].Id === record.OwnerId)?.records[0] + .Email; + return { id: record.Id, email: record.Email, ownerId: record.OwnerId, ownerEmail }; + }); + return contactsWithOwners; + } + + return records + ? records.map((record) => ({ + id: record.Id, + email: record.Email, + })) + : []; } - async listCalendars(_event?: CalendarEvent) { - return Promise.resolve([]); + async createContacts(contactsToCreate: { email: string; name: string }[], organizerEmail?: string) { + const conn = await this.conn; + + // See if the organizer exists in the CRM + let organizerId: string; + if (organizerEmail) { + const userQuery = await this.getSalesforceUserFromEmail(organizerEmail); + if (userQuery) { + organizerId = (userQuery.records[0] as { Email: string; Id: string }).Id; + } + } + const createdContacts = await Promise.all( + contactsToCreate.map(async (attendee) => { + const [FirstName, LastName] = attendee.name ? attendee.name.split(" ") : [attendee.email, ""]; + return await conn + .sobject("Contact") + .create({ + FirstName, + LastName: LastName || "-", + Email: attendee.email, + ...(organizerId && { OwnerId: organizerId }), + }) + .then((result) => { + if (result.success) { + return { id: result.id, email: attendee.email }; + } + }); + }) + ); + return createdContacts.filter((contact): contact is Contact => contact !== undefined); } } diff --git a/packages/app-store/salesforce/lib/index.ts b/packages/app-store/salesforce/lib/index.ts index e168c149df..ac64aeaddb 100644 --- a/packages/app-store/salesforce/lib/index.ts +++ b/packages/app-store/salesforce/lib/index.ts @@ -1 +1 @@ -export { default as CalendarService } from "./CalendarService"; +export { default as CrmService } from "./CrmService"; diff --git a/packages/app-store/salesforce/zod.ts b/packages/app-store/salesforce/zod.ts index d305b76272..5228818b87 100644 --- a/packages/app-store/salesforce/zod.ts +++ b/packages/app-store/salesforce/zod.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -export const appDataSchema = z.object({}); +import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; + +export const appDataSchema = eventTypeAppCardZod.extend({ + roundRobinLeadSkip: z.boolean().optional(), +}); export const appKeysSchema = z.object({ consumer_key: z.string().min(1), diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts index 22a6101dc7..0eb598525f 100644 --- a/packages/app-store/types.d.ts +++ b/packages/app-store/types.d.ts @@ -49,7 +49,15 @@ export type EventTypeAppCardComponentProps = { // Limit what data should be accessible to apps eventType: Pick< z.infer, - "id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot" | "team" + | "id" + | "title" + | "description" + | "teamId" + | "length" + | "recurringEvent" + | "seatsPerTimeSlot" + | "team" + | "schedulingType" > & { URL: string; }; diff --git a/packages/app-store/zoho-bigin/api/callback.ts b/packages/app-store/zoho-bigin/api/callback.ts index 96a3ad3a05..60d939cf1d 100644 --- a/packages/app-store/zoho-bigin/api/callback.ts +++ b/packages/app-store/zoho-bigin/api/callback.ts @@ -13,6 +13,7 @@ import appConfig from "../config.json"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code, "accounts-server": accountsServer } = req.query; + const state = decodeOAuthState(req); if (code && typeof code !== "string") { res.status(400).json({ message: "`code` must be a string" }); @@ -54,8 +55,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, tokenInfo.data, req); - const state = decodeOAuthState(req); - if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { return res.redirect(state.appOnboardingRedirectUrl); } diff --git a/packages/app-store/zoho-bigin/components/.gitkeep b/packages/app-store/zoho-bigin/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/zoho-bigin/components/EventTypeAppCardInterface.tsx b/packages/app-store/zoho-bigin/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..53a8297d6f --- /dev/null +++ b/packages/app-store/zoho-bigin/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,27 @@ +import { usePathname } from "next/navigation"; + +import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const pathname = usePathname(); + + const { enabled, updateEnabled } = useIsAppEnabled(app); + + return ( + { + updateEnabled(e); + }} + switchChecked={enabled} + hideAppCardOptions + /> + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/zoho-bigin/config.json b/packages/app-store/zoho-bigin/config.json index d4a2851271..67538280c4 100644 --- a/packages/app-store/zoho-bigin/config.json +++ b/packages/app-store/zoho-bigin/config.json @@ -7,6 +7,7 @@ "url": "https://github.com/ShaneMaglangit", "variant": "crm", "categories": ["crm"], + "extendsFeature": "EventType", "publisher": "Shane Maglangit", "email": "help@cal.com", "description": "Bigin easily transforms your day-to-day customer processes into actionable pipelines. From qualifying leads to closing deals to managing important after-sales operations—Bigin connects your different teams to work together so that you can offer the best possible experience to your customers. Say goodbye to missing follow-ups, manual data entry, lack of team communication, and information silos.", diff --git a/packages/app-store/zoho-bigin/lib/CalendarService.ts b/packages/app-store/zoho-bigin/lib/CrmService.ts similarity index 85% rename from packages/app-store/zoho-bigin/lib/CalendarService.ts rename to packages/app-store/zoho-bigin/lib/CrmService.ts index 163c74b86f..d556ad853e 100644 --- a/packages/app-store/zoho-bigin/lib/CalendarService.ts +++ b/packages/app-store/zoho-bigin/lib/CrmService.ts @@ -5,14 +5,13 @@ import { getLocation } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { - Calendar, CalendarEvent, EventBusyDate, IntegrationCalendar, NewCalendarEventType, - Person, } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { Contact, ContactCreateInput, CRM } from "@calcom/types/CrmService"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; @@ -30,10 +29,11 @@ export type BiginToken = { }; export type BiginContact = { + id: string; email: string; }; -export default class BiginCalendarService implements Calendar { +export default class BiginCrmService implements CRM { private readonly integrationName = "zoho-bigin"; private readonly auth: { getToken: () => Promise }; private log: typeof logger; @@ -119,20 +119,20 @@ export default class BiginCalendarService implements Calendar { * Creates Zoho Bigin Contact records for every attendee added in event bookings. * Returns the results of all contact creation operations. */ - private async createContacts(attendees: Person[]) { + async createContacts(contactsToCreate: ContactCreateInput[]) { const token = await this.auth.getToken(); - const contacts = attendees.map((attendee) => { - const nameParts = attendee.name.split(" "); + const contacts = contactsToCreate.map((contact) => { + const nameParts = contact.name.split(" "); const firstName = nameParts[0]; const lastName = nameParts.length > 1 ? nameParts.slice(1).join(" ") : "-"; return { First_Name: firstName, Last_Name: lastName, - Email: attendee.email, + Email: contact.email, }; }); - return axios({ + const response = await axios({ method: "post", url: token.api_domain + this.contactsSlug, headers: { @@ -141,26 +141,42 @@ export default class BiginCalendarService implements Calendar { }, data: JSON.stringify({ data: contacts }), }); + + return response + ? response.data.map((contact: BiginContact) => { + return { + id: contact.id, + email: contact.email, + }; + }) + : []; } /*** * Finds existing Zoho Bigin Contact record based on email address. Returns a list of contacts objects that matched. */ - private async contactSearch(event: CalendarEvent) { + async getContacts(emails: string | string[]) { const token = await this.auth.getToken(); - const searchCriteria = `(${event.attendees - .map((attendee) => `(Email:equals:${encodeURI(attendee.email)})`) - .join("or")})`; + const emailsArray = Array.isArray(emails) ? emails : [emails]; - return await axios({ + const searchCriteria = `(${emailsArray.map((email) => `(Email:equals:${encodeURI(email)})`).join("or")})`; + + const response = await axios({ method: "get", url: `${token.api_domain}${this.contactsSlug}/search?criteria=${searchCriteria}`, headers: { authorization: `Zoho-oauthtoken ${token.access_token}`, }, - }) - .then((data) => data.data) - .catch((e) => this.log.error("Error searching contact:", JSON.stringify(e), e.response?.data)); + }).catch((e) => this.log.error("Error searching contact:", JSON.stringify(e), e.response?.data)); + + return response + ? response.data.map((contact: BiginContact) => { + return { + id: contact.id, + email: contact.email, + }; + }) + : []; } /*** @@ -192,7 +208,7 @@ export default class BiginCalendarService implements Calendar { /*** * Handles orchestrating the creation of new events in Zoho Bigin. */ - async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) { + async handleEventCreation(event: CalendarEvent, contacts: Contact[]) { const meetingEvent = await this.createBiginEvent(event); if (meetingEvent.data && meetingEvent.data.length && meetingEvent.data[0].status === "success") { this.log.debug("event:creation:ok", { meetingEvent }); @@ -216,26 +232,8 @@ export default class BiginCalendarService implements Calendar { * Creates contacts and event records for new bookings. * Initially creates all new attendees as contacts, then creates the event. */ - async createEvent(event: CalendarEvent): Promise { - const contacts = (await this.contactSearch(event))?.data || []; - - const existingContacts = contacts.map((contact: BiginContact) => contact.email); - const newContacts: Person[] = event.attendees.filter( - (attendee) => !existingContacts.includes(attendee.email) - ); - - if (newContacts.length === 0) { - return await this.handleEventCreation(event, event.attendees); - } - - const createContacts = await this.createContacts(newContacts); - if (createContacts.data?.data[0].status === "success") { - return await this.handleEventCreation(event, event.attendees); - } - - return Promise.reject({ - calError: "Something went wrong when creating non-existing attendees in Zoho Bigin", - }); + async createEvent(event: CalendarEvent, contacts: Contact[]): Promise { + return await this.handleEventCreation(event, contacts); } /*** diff --git a/packages/app-store/zoho-bigin/lib/index.ts b/packages/app-store/zoho-bigin/lib/index.ts index e168c149df..ac64aeaddb 100644 --- a/packages/app-store/zoho-bigin/lib/index.ts +++ b/packages/app-store/zoho-bigin/lib/index.ts @@ -1 +1 @@ -export { default as CalendarService } from "./CalendarService"; +export { default as CrmService } from "./CrmService"; diff --git a/packages/app-store/zoho-bigin/zod.ts b/packages/app-store/zoho-bigin/zod.ts index b2621bfca9..81ae9e0eb2 100644 --- a/packages/app-store/zoho-bigin/zod.ts +++ b/packages/app-store/zoho-bigin/zod.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; + export const appKeysSchema = z.object({ client_id: z.string().min(1), client_secret: z.string().min(1), }); -export const appDataSchema = z.object({}); + +export const appDataSchema = eventTypeAppCardZod; diff --git a/packages/app-store/zohocrm/api/callback.ts b/packages/app-store/zohocrm/api/callback.ts index bcbed269fa..935460d63f 100644 --- a/packages/app-store/zohocrm/api/callback.ts +++ b/packages/app-store/zohocrm/api/callback.ts @@ -16,6 +16,7 @@ let client_secret = ""; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; + if (code === undefined && typeof code !== "string") { res.status(400).json({ message: "`code` must be a string" }); return; diff --git a/packages/app-store/zohocrm/components/.gitkeep b/packages/app-store/zohocrm/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/zohocrm/components/EventTypeAppCardInterface.tsx b/packages/app-store/zohocrm/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..53a8297d6f --- /dev/null +++ b/packages/app-store/zohocrm/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,27 @@ +import { usePathname } from "next/navigation"; + +import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const pathname = usePathname(); + + const { enabled, updateEnabled } = useIsAppEnabled(app); + + return ( + { + updateEnabled(e); + }} + switchChecked={enabled} + hideAppCardOptions + /> + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/zohocrm/config.json b/packages/app-store/zohocrm/config.json index 2a0cf837ca..bea2e59c67 100644 --- a/packages/app-store/zohocrm/config.json +++ b/packages/app-store/zohocrm/config.json @@ -7,6 +7,7 @@ "url": "https://github.com/jatinsandilya", "variant": "crm", "categories": ["crm"], + "extendsFeature": "EventType", "publisher": "Jatin Sandilya", "email": "help@cal.com", "description": "Zoho CRM is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day", diff --git a/packages/app-store/zohocrm/lib/CalendarService.ts b/packages/app-store/zohocrm/lib/CrmService.ts similarity index 68% rename from packages/app-store/zohocrm/lib/CalendarService.ts rename to packages/app-store/zohocrm/lib/CrmService.ts index ab3474a349..21929a6989 100644 --- a/packages/app-store/zohocrm/lib/CalendarService.ts +++ b/packages/app-store/zohocrm/lib/CrmService.ts @@ -5,15 +5,9 @@ import { getLocation } from "@calcom/lib/CalEventParser"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; -import type { - Calendar, - CalendarEvent, - EventBusyDate, - IntegrationCalendar, - NewCalendarEventType, - Person, -} from "@calcom/types/Calendar"; +import type { CalendarEvent, NewCalendarEventType } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CRM, Contact, ContactCreateInput } from "@calcom/types/CrmService"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; @@ -30,7 +24,8 @@ export type ZohoToken = { }; export type ZohoContact = { - Email: string; + id: string; + email: string; }; /** @@ -50,7 +45,7 @@ const toISO8601String = (date: Date) => { Math.abs(tzo) % 60 )}`; }; -export default class ZohoCrmCalendarService implements Calendar { +export default class ZohoCrmCrmService implements CRM { private integrationName = ""; private auth: Promise<{ getToken: () => Promise }>; private log: typeof logger; @@ -59,21 +54,25 @@ export default class ZohoCrmCalendarService implements Calendar { private accessToken = ""; constructor(credential: CredentialPayload) { - this.integrationName = "zohocrm_other_calendar"; + this.integrationName = "zohocrm_crm"; this.auth = this.zohoCrmAuth(credential).then((r) => r); this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } - private createContacts = async (attendees: Person[]) => { - const contacts = attendees.map((attendee) => { - const [firstname, lastname] = !!attendee.name ? attendee.name.split(" ") : [attendee.email, "-"]; + async createContacts(contactsToCreate: ContactCreateInput[]) { + const auth = await this.auth; + await auth.getToken(); + const contacts = contactsToCreate.map((contactToCreate) => { + const [firstname, lastname] = !!contactToCreate.name + ? contactToCreate.name.split(" ") + : [contactToCreate.email, "-"]; return { First_Name: firstname, Last_Name: lastname || "-", - Email: attendee.email, + Email: contactToCreate.email, }; }); - return axios({ + const response = await axios({ method: "post", url: `https://www.zohoapis.com/crm/v3/Contacts`, headers: { @@ -82,14 +81,24 @@ export default class ZohoCrmCalendarService implements Calendar { }, data: JSON.stringify({ data: contacts }), }); - }; - private contactSearch = async (event: CalendarEvent) => { - const searchCriteria = `(${event.attendees - .map((attendee) => `(Email:equals:${encodeURI(attendee.email)})`) - .join("or")})`; + const { data } = response; + return data.data.map((contact: ZohoContact) => { + return { + id: contact.id, + email: contact.email, + }; + }); + } - return await axios({ + async getContacts(emails: string | string[]) { + const auth = await this.auth; + await auth.getToken(); + const emailsArray = Array.isArray(emails) ? emails : [emails]; + + const searchCriteria = `(${emailsArray.map((email) => `(Email:equals:${encodeURI(email)})`).join("or")})`; + + const response = await axios({ method: "get", url: `https://www.zohoapis.com/crm/v3/Contacts/search?criteria=${searchCriteria}`, headers: { @@ -97,8 +106,19 @@ export default class ZohoCrmCalendarService implements Calendar { }, }) .then((data) => data.data) - .catch((e) => this.log.error(e, e.response?.data)); - }; + .catch((e) => { + this.log.error(e, e.response?.data); + }); + + return response + ? response.data.map((contact: ZohoContact) => { + return { + id: contact.id, + email: contact.email, + }; + }) + : []; + } private getMeetingBody = (event: CalendarEvent): string => { return `${event.organizer.language.translate("invitee_timezone")}: ${ @@ -108,14 +128,14 @@ export default class ZohoCrmCalendarService implements Calendar { }`; }; - private createZohoEvent = async (event: CalendarEvent, contacts: CalendarEvent["attendees"]) => { + private createZohoEvent = async (event: CalendarEvent, contacts: Contact[]) => { const zohoEvent = { Event_Title: event.title, Start_DateTime: toISO8601String(new Date(event.startTime)), End_DateTime: toISO8601String(new Date(event.endTime)), Description: this.getMeetingBody(event), Venue: getLocation(event), - Who_Id: contacts[0], // Link the first attendee as the primary Who_Id + Who_Id: contacts[0].id, // Link the first attendee as the primary Who_Id }; return axios({ @@ -175,7 +195,7 @@ export default class ZohoCrmCalendarService implements Calendar { throw new HttpError({ statusCode: 400, message: "Zoho CRM client_secret missing." }); const credentialKey = credential.key as unknown as ZohoToken; const isTokenValid = (token: ZohoToken) => { - const isValid = token && token.access_token && token.expiryDate && token.expiryDate < Date.now(); + const isValid = token && token.access_token && token.expiryDate && token.expiryDate > Date.now(); if (isValid) { this.accessToken = token.access_token; } @@ -235,7 +255,7 @@ export default class ZohoCrmCalendarService implements Calendar { }; }; - async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) { + async handleEventCreation(event: CalendarEvent, contacts: Contact[]) { const meetingEvent = await this.createZohoEvent(event, contacts); if (meetingEvent.data && meetingEvent.data.length && meetingEvent.data[0].status === "success") { this.log.debug("event:creation:ok", { meetingEvent }); @@ -252,54 +272,10 @@ export default class ZohoCrmCalendarService implements Calendar { return Promise.reject("Something went wrong when creating a meeting in ZohoCRM"); } - async createEvent(event: CalendarEvent): Promise { + async createEvent(event: CalendarEvent, contacts: Contact[]): Promise { const auth = await this.auth; await auth.getToken(); - const contacts = await this.contactSearch(event); - if (contacts.data && contacts.data.length) { - if (contacts.data.length === event.attendees.length) { - // all contacts are in Zoho CRM already. - this.log.debug("contact:search:all", { event, contacts: contacts.data }); - return await this.handleEventCreation(event, contacts.data); - } else { - // Some attendees don't exist in ZohoCRM - // Get the existing contacts' email to filter out - this.log.debug("contact:search:notAll", { event, contacts }); - const existingContacts = contacts.data.map((contact: ZohoContact) => contact.Email); - this.log.debug("contact:filter:existing", { existingContacts }); - // Get non existing contacts filtering out existing from attendees - const nonExistingContacts: Person[] = event.attendees.filter( - (attendee) => !existingContacts.includes(attendee.email) - ); - this.log.debug("contact:filter:nonExisting", { nonExistingContacts }); - // Only create contacts in ZohoCRM that were not present in the previous contact search - const createContacts = await this.createContacts(nonExistingContacts); - this.log.debug("contact:created", { createContacts }); - // Continue with event creation and association only when all contacts are present in Zoho - if (createContacts.data?.data[0].status === "success") { - this.log.debug("contact:creation:ok"); - return await this.handleEventCreation( - event, - [createContacts.data?.data[0]?.details].concat(contacts.data) - ); - } - return Promise.reject({ - calError: "Something went wrong when creating non-existing attendees in ZohoCRM", - }); - } - } else { - this.log.debug("contact:search:none", { event, contacts }); - const createContacts = await this.createContacts(event.attendees); - this.log.debug("contact:created", { createContacts }); - if (createContacts.data?.data[0].status === "success") { - this.log.debug("contact:creation:ok"); - return await this.handleEventCreation(event, [createContacts.data.data[0].details]); - } - } - - return Promise.reject({ - calError: "Something went wrong when searching/creating the attendees in ZohoCRM", - }); + return await this.handleEventCreation(event, contacts); } async updateEvent(uid: string, event: CalendarEvent): Promise { @@ -313,16 +289,4 @@ export default class ZohoCrmCalendarService implements Calendar { await auth.getToken(); return await this.deleteMeeting(uid); } - - async getAvailability( - _dateFrom: string, - _dateTo: string, - _selectedCalendars: IntegrationCalendar[] - ): Promise { - return Promise.resolve([]); - } - - async listCalendars(_event?: CalendarEvent): Promise { - return Promise.resolve([]); - } } diff --git a/packages/app-store/zohocrm/lib/index.ts b/packages/app-store/zohocrm/lib/index.ts index e168c149df..ac64aeaddb 100644 --- a/packages/app-store/zohocrm/lib/index.ts +++ b/packages/app-store/zohocrm/lib/index.ts @@ -1 +1 @@ -export { default as CalendarService } from "./CalendarService"; +export { default as CrmService } from "./CrmService"; diff --git a/packages/app-store/zohocrm/zod.ts b/packages/app-store/zohocrm/zod.ts index fe378bc4bc..81ae9e0eb2 100644 --- a/packages/app-store/zohocrm/zod.ts +++ b/packages/app-store/zohocrm/zod.ts @@ -1,8 +1,10 @@ import { z } from "zod"; +import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; + export const appKeysSchema = z.object({ client_id: z.string().min(1), client_secret: z.string().min(1), }); -export const appDataSchema = z.object({}); +export const appDataSchema = eventTypeAppCardZod; diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 662a1de8e6..955ef44554 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -9,6 +9,7 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod"; import { getEventLocationTypeFromApp, MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; +import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { getPiiFreeDestinationCalendar, @@ -31,6 +32,7 @@ import type { } from "@calcom/types/EventManager"; import { createEvent, updateEvent, deleteEvent } from "./CalendarManager"; +import CrmManager from "./crmManager/crmManager"; import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient"; const log = logger.getSubLogger({ prefix: ["EventManager"] }); @@ -81,6 +83,7 @@ type createdEventSchema = z.infer; export default class EventManager { calendarCredentials: CredentialPayload[]; videoCredentials: CredentialPayload[]; + crmCredentials: CredentialPayload[]; /** * Takes an array of credentials and initializes a new instance of the EventManager. @@ -97,7 +100,7 @@ export default class EventManager { // (type closecom_other_calendar) this.calendarCredentials = appCredentials.filter( // Backwards compatibility until CRM manager is implemented - (cred) => cred.type.endsWith("_calendar") || cred.type.endsWith("_crm") + (cred) => cred.type.endsWith("_calendar") && !cred.type.includes("other_calendar") ); this.videoCredentials = appCredentials .filter((cred) => cred.type.endsWith("_video") || cred.type.endsWith("_conferencing")) @@ -107,6 +110,9 @@ export default class EventManager { .sort((a, b) => { return b.id - a.id; }); + this.crmCredentials = appCredentials.filter( + (cred) => cred.type.endsWith("_crm") || cred.type.endsWith("_other_calendar") + ); } /** @@ -175,6 +181,8 @@ export default class EventManager { return result.type.includes("_calendar"); }; + results.push(...(await this.createAllCRMEvents(clonedCalEvent))); + // References can be any type: calendar/video const referencesToCreate = results.map((result) => { let thirdPartyRecurringEventId; @@ -196,7 +204,7 @@ export default class EventManager { meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password, meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url, externalCalendarId: isCalendarType ? result.externalId : undefined, - credentialId: isCalendarType ? result.credentialId : undefined, + credentialId: result?.credentialId || undefined, }; }); @@ -443,6 +451,8 @@ export default class EventManager { // Update all calendar events. results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId))); } + + results.push(...(await this.updateAllCRMEvents(evt, booking))); } } const bookingPayment = booking?.payment; @@ -494,10 +504,11 @@ export default class EventManager { }) { const calendarReferences = [], videoReferences = [], + crmReferences = [], allPromises = []; for (const reference of bookingReferences) { - if (reference.type.includes("_calendar")) { + if (reference.type.includes("_calendar") && !reference.type.includes("other_calendar")) { calendarReferences.push(reference); allPromises.push( this.deleteCalendarEventForBookingReference({ @@ -516,6 +527,11 @@ export default class EventManager { }) ); } + + if (reference.type.includes("_crm") || reference.type.includes("other_calendar")) { + crmReferences.push(reference); + allPromises.push(this.deleteCRMEvent({ reference })); + } } log.debug("deleteEventsAndMeetings", safeStringify({ calendarReferences, videoReferences })); @@ -565,10 +581,7 @@ export default class EventManager { * Not ideal but, if we don't find a destination calendar, * fallback to the first connected calendar - Shouldn't be a CRM calendar */ - // Backwards compatibility until CRM manager is created - const [credential] = this.calendarCredentials.filter( - (cred) => !cred.type.endsWith("other_calendar") || !cred.type.endsWith("crm") - ); + const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar")); if (credential) { const createdEvent = await createEvent(credential, event); log.silly("Created Calendar event", safeStringify({ createdEvent })); @@ -672,8 +685,7 @@ export default class EventManager { createdEvents = createdEvents.concat( await Promise.all( this.calendarCredentials - // Backwards compatibility until CRM manager is created - .filter((cred) => cred.type.includes("other_calendar") || cred.type.includes("crm")) + .filter((cred) => cred.type.includes("other_calendar")) .map(async (cred) => await createEvent(cred, event)) ) ); @@ -845,8 +857,7 @@ export default class EventManager { // Taking care of non-traditional calendar integrations result = result.concat( this.calendarCredentials - // Backwards compatibility until CRM manager is created - .filter((cred) => cred.type.includes("other_calendar") && cred.type.includes("crm")) + .filter((cred) => cred.type.includes("other_calendar")) .map(async (cred) => { const calendarReference = booking.references.find((ref) => ref.type === cred.type); @@ -907,4 +918,69 @@ export default class EventManager { ); } } + + private async createAllCRMEvents(event: CalendarEvent) { + const createdEvents = []; + const uid = getUid(event); + for (const credential of this.crmCredentials) { + const crm = new CrmManager(credential); + + let success = true; + const createdEvent = await crm.createEvent(event).catch((error) => { + success = false; + log.warn(`Error creating crm event for ${credential.type}`, error); + }); + + createdEvents.push({ + type: credential.type, + appName: credential.appId || "", + uid, + success, + createdEvent: { + id: createdEvent?.id || "", + type: credential.type, + credentialId: credential.id, + }, + id: createdEvent?.id || "", + originalEvent: event, + credentialId: credential.id, + }); + } + return createdEvents; + } + + private async updateAllCRMEvents(event: CalendarEvent, booking: PartialBooking) { + const updatedEvents = []; + + // Loop through all booking references and update the corresponding CRM event + for (const reference of booking.references) { + const credential = this.crmCredentials.find((cred) => cred.id === reference.credentialId); + let success = true; + if (credential) { + const crm = new CrmManager(credential); + const updatedEvent = await crm.updateEvent(reference.uid, event).catch((error) => { + success = false; + log.warn(`Error updating crm event for ${credential.type}`, error); + }); + + updatedEvents.push({ + type: credential.type, + appName: credential.appId || "", + success, + uid: updatedEvent?.id || "", + originalEvent: event, + }); + } + } + + return updatedEvents; + } + + private async deleteCRMEvent({ reference }: { reference: PartialReference }) { + const credential = this.crmCredentials.find((cred) => cred.id === reference.credentialId); + if (credential) { + const crm = new CrmManager(credential); + await crm.deleteEvent(reference.uid); + } + } } diff --git a/packages/core/crmManager/crmManager.test.ts b/packages/core/crmManager/crmManager.test.ts new file mode 100644 index 0000000000..0973d476ac --- /dev/null +++ b/packages/core/crmManager/crmManager.test.ts @@ -0,0 +1,97 @@ +import type { TFunction } from "next-i18next"; +import { describe, expect, test, vi } from "vitest"; + +import { getCrm } from "@calcom/app-store/_utils/getCrm"; +import { mockCrmApp } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +import CrmManager from "./crmManager"; + +// vi.mock("@calcom/app-store/_utils/getCrm"); + +describe.skip("crmManager tests", () => { + test("Set crmService if not set", async () => { + const spy = vi.spyOn(CrmManager.prototype as any, "getCrmService"); + const crmManager = new CrmManager({ + id: 1, + type: "credential_crm", + key: {}, + userId: 1, + teamId: null, + appId: "crm-app", + invalid: false, + user: { email: "test@test.com" }, + }); + expect(crmManager.crmService).toBe(null); + + crmManager.getContacts(["test@test.com"]); + + expect(spy).toBeCalledTimes(1); + }); + describe("creating events", () => { + test("If the contact exists, create the event", async () => { + const tFunc = vi.fn(() => "foo"); + vi.spyOn(getCrm).mockReturnValue({ + getContacts: () => [ + { + id: "contact-id", + email: "test@test.com", + }, + ], + createContacts: [{ id: "contact-id", email: "test@test.com" }], + }); + // This mock is defaulting to non implemented mock return + const mockedCrmApp = mockCrmApp("salesforce", { + getContacts: [ + { + id: "contact-id", + email: "test@test.com", + }, + ], + createContacts: [{ id: "contact-id", email: "test@test.com" }], + }); + + const crmManager = new CrmManager({ + id: 1, + type: "salesforce_crm", + key: { + clientId: "test-client-id", + }, + userId: 1, + teamId: null, + appId: "salesforce", + invalid: false, + user: { email: "test@test.com" }, + }); + + crmManager.createEvent({ + title: "Test Meeting", + type: "test-meeting", + description: "Test Description", + startTime: Date(), + endTime: Date(), + organizer: { + email: "organizer@test.com", + name: "Organizer", + timeZone: "America/New_York", + language: { + locale: "en", + translate: tFunc as TFunction, + }, + }, + attendees: [ + { + email: "test@test.com", + name: "Test", + timeZone: "America/New_York", + language: { + locale: "en", + translate: tFunc as TFunction, + }, + }, + ], + }); + + console.log(mockedCrmApp); + }); + }); +}); diff --git a/packages/core/crmManager/crmManager.ts b/packages/core/crmManager/crmManager.ts new file mode 100644 index 0000000000..35a9977cc0 --- /dev/null +++ b/packages/core/crmManager/crmManager.ts @@ -0,0 +1,66 @@ +import getCrm from "@calcom/app-store/_utils/getCrm"; +import logger from "@calcom/lib/logger"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CRM, ContactCreateInput } from "@calcom/types/CrmService"; + +const log = logger.getSubLogger({ prefix: ["CrmManager"] }); +export default class CrmManager { + crmService: CRM | null | undefined = null; + credential: CredentialPayload; + constructor(credential: CredentialPayload) { + this.credential = credential; + } + + private async getCrmService(credential: CredentialPayload) { + if (this.crmService) return this.crmService; + const crmService = await getCrm(credential); + this.crmService = crmService; + + if (this.crmService === null) { + console.log("💀 Error initializing CRM service"); + log.error("CRM service initialization failed"); + } + + return crmService; + } + + public async createEvent(event: CalendarEvent) { + const crmService = await this.getCrmService(this.credential); + // First see if the attendees already exist in the crm + let contacts = (await this.getContacts(event.attendees.map((a) => a.email))) || []; + // Ensure that all attendees are in the crm + if (contacts.length == event.attendees.length) { + return await crmService?.createEvent(event, contacts); + } + // Figure out which contacts to create + const contactsToCreate = event.attendees.filter( + (attendee) => !contacts.some((contact) => contact.email === attendee.email) + ); + const createdContacts = await this.createContacts(contactsToCreate); + contacts = contacts.concat(createdContacts); + return await crmService?.createEvent(event, contacts); + } + + public async updateEvent(uid: string, event: CalendarEvent) { + const crmService = await this.getCrmService(this.credential); + return await crmService?.updateEvent(uid, event); + } + + public async deleteEvent(uid: string) { + const crmService = await this.getCrmService(this.credential); + return await crmService?.deleteEvent(uid); + } + + public async getContacts(emailOrEmails: string | string[], includeOwner?: boolean) { + const crmService = await this.getCrmService(this.credential); + const contacts = await crmService?.getContacts(emailOrEmails, includeOwner); + return contacts; + } + + public async createContacts(contactsToCreate: ContactCreateInput[]) { + const crmService = await this.getCrmService(this.credential); + const createdContacts = (await crmService?.createContacts(contactsToCreate)) || []; + return createdContacts; + } +} diff --git a/packages/embeds/embed-react/package.json b/packages/embeds/embed-react/package.json index 43ae9f67ab..7ea873ef5a 100644 --- a/packages/embeds/embed-react/package.json +++ b/packages/embeds/embed-react/package.json @@ -51,7 +51,7 @@ "eslint": "^8.34.0", "npm-run-all": "^4.1.5", "typescript": "^4.9.4", - "vite": "^4.1.2" + "vite": "^4.5.2" }, "dependencies": { "@calcom/embed-core": "workspace:*", diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 8ada139610..c348ccfb5e 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -25,6 +25,7 @@ export interface IUseBookings { hashedLink?: string | null; bookingForm: UseBookingFormReturnType["bookingForm"]; metadata: Record; + teamMemberEmail?: string; } export interface IUseBookingLoadingStates { @@ -39,7 +40,7 @@ export interface IUseBookingErrors { } export type UseBookingsReturnType = ReturnType; -export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBookings) => { +export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemberEmail }: IUseBookings) => { const router = useRouter(); const eventSlug = useBookerStore((state) => state.eventSlug); const rescheduleUid = useBookerStore((state) => state.rescheduleUid); @@ -229,6 +230,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo bookingForm, hashedLink, metadata, + teamMemberEmail, handleInstantBooking: createInstantBookingMutation.mutate, handleRecBooking: createRecurringBookingMutation.mutate, handleBooking: createBookingMutation.mutate, diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index a41d4e5181..ae4cf7c09e 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -65,6 +65,7 @@ export const useScheduleForEvent = ({ dayCount, selectedDate, orgSlug, + bookerEmail, }: { prefetchNextMonth?: boolean; username?: string | null; @@ -76,6 +77,7 @@ export const useScheduleForEvent = ({ dayCount?: number | null; selectedDate?: string | null; orgSlug?: string; + bookerEmail?: string; } = {}) => { const { timezone } = useTimePreferences(); const event = useEvent(); @@ -105,6 +107,7 @@ export const useScheduleForEvent = ({ duration: durationFromStore ?? duration, isTeamEvent: pathname?.indexOf("/team/") !== -1 || isTeam, orgSlug, + bookerEmail, }); return { diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index 4369f71453..206b073113 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -19,6 +19,7 @@ export type BookingOptions = { bookingUid?: string; seatReferenceUid?: string; hashedLink?: string | null; + teamMemberEmail?: string; orgSlug?: string; }; @@ -35,6 +36,7 @@ export const mapBookingToMutationInput = ({ bookingUid, seatReferenceUid, hashedLink, + teamMemberEmail, orgSlug, }: BookingOptions): BookingCreateBody => { return { @@ -55,6 +57,7 @@ export const mapBookingToMutationInput = ({ bookingUid, seatReferenceUid, hashedLink, + teamMemberEmail, orgSlug, }; }; diff --git a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts new file mode 100644 index 0000000000..1f76809d09 --- /dev/null +++ b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.test.ts @@ -0,0 +1,699 @@ +import { describe, test, expect, vi } from "vitest"; + +import { UserRepository } from "@calcom/lib/server/repository/user"; +import { + createCredentials, + addTeamsToDb, + addEventTypesToDb, + addUsersToDb, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +// vi.mock("@calcom/lib/server/repository/user", () => { +// return { +// enrichUserWithItsProfile +// } +// }) + +describe("getAllCredentials", () => { + test("Get an individual's credentials", async () => { + vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({ + profile: null, + }); + + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const userCredential = { + id: 1, + type: "user-credential", + userId: 1, + teamId: null, + key: {}, + appId: "user-credential", + invalid: false, + }; + await createCredentials([ + userCredential, + { type: "other-user-credential", userId: 2, key: {} }, + { type: "team-credential", teamId: 1, key: {} }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [{ ...userCredential, user: { email: "test@test.com" } }], + }, + { + userId: 1, + team: null, + parentId: null, + metadata: {}, + } + ); + + expect(credentials).toHaveLength(1); + + expect(credentials).toContainEqual(expect.objectContaining({ type: "user-credential" })); + }); + + describe("Handle CRM credentials", () => { + describe("If CRM is enabled on the event type", () => { + describe("With _crm credentials", () => { + test("For users", async () => { + vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({ + profile: null, + }); + + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + const otherCredential = { + id: 2, + type: "other_credential", + userId: 1, + teamId: null, + key: {}, + appId: "other", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + type: "salesforce_crm", + teamId: 1, + key: {}, + }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [ + { ...crmCredential, user: { email: "test@test.com" } }, + { + ...otherCredential, + user: { email: "test@test.com" }, + }, + ], + }, + { + userId: 1, + team: null, + parentId: null, + metadata: { + apps: { + salesforce: { + enabled: true, + credentialId: 1, + appCategories: ["crm"], + }, + }, + }, + } + ); + + expect(credentials).toHaveLength(2); + + expect(credentials).toContainEqual(expect.objectContaining({ userId: 1, type: "salesforce_crm" })); + }); + test("For teams", async () => { + vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({ + profile: null, + }); + + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + id: 3, + type: "salesforce_crm", + teamId: 1, + key: {}, + }, + { + type: "other_credential", + teamId: 1, + key: {}, + }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [{ ...crmCredential, user: { email: "test@test.com" } }], + }, + { + userId: null, + team: { + id: 1, + parentId: null, + }, + parentId: null, + metadata: { + apps: { + salesforce: { + enabled: true, + credentialId: 3, + appCategories: ["crm"], + }, + }, + }, + } + ); + + expect(credentials).toHaveLength(2); + + expect(credentials).toContainEqual(expect.objectContaining({ teamId: 1, type: "salesforce_crm" })); + }); + test("For child of managed event type", async () => { + vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({ + profile: null, + }); + + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const teamId = 1; + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + id: 3, + type: "salesforce_crm", + teamId, + key: {}, + }, + { + type: "other_credential", + teamId, + key: {}, + }, + ]); + + await addTeamsToDb([ + { + id: teamId, + name: "Test team", + slug: "test-team", + }, + ]); + + const testEventType = await addEventTypesToDb([ + { + id: 3, + title: "Test event type", + slug: "test-event-type", + length: 15, + team: { + connect: { + id: teamId, + }, + }, + }, + ]); + + console.log(testEventType); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [{ ...crmCredential, user: { email: "test@test.com" } }], + }, + { + userId: null, + team: { + id: 2, + parentId: 1, + }, + parentId: 3, + metadata: { + apps: { + salesforce: { + enabled: true, + credentialId: 3, + appCategories: ["crm"], + }, + }, + }, + } + ); + + expect(credentials).toHaveLength(2); + + expect(credentials).toContainEqual(expect.objectContaining({ teamId, type: "salesforce_crm" })); + }); + test("For an org user", async () => { + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + const orgId = 3; + vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({ + profile: { organizationId: orgId }, + }); + + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + const otherCredential = { + id: 2, + type: "other_credential", + userId: 1, + teamId: null, + key: {}, + appId: "other", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + id: 3, + type: "salesforce_crm", + teamId: orgId, + key: {}, + }, + { + type: "other_credential", + teamId: orgId, + key: {}, + }, + ]); + + await addTeamsToDb([ + { + id: orgId, + name: "Test team", + slug: "test-team", + }, + ]); + + await addUsersToDb([ + { + id: 1, + email: "test@test.com", + username: "test", + schedules: [], + profiles: { + create: [{ organizationId: orgId, uid: "MOCK_UID", username: "test" }], + }, + }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [ + { ...crmCredential, user: { email: "test@test.com" } }, + { + ...otherCredential, + user: { email: "test@test.com" }, + }, + ], + }, + { + userId: 1, + team: null, + parentId: null, + metadata: { + apps: { + salesforce: { + enabled: true, + credentialId: 3, + appCategories: ["crm"], + }, + }, + }, + } + ); + + expect(credentials).toHaveLength(3); + + expect(credentials).toContainEqual( + expect.objectContaining({ teamId: orgId, type: "salesforce_crm" }) + ); + }); + }); + describe("Default with _other_calendar credentials", () => { + test("For users", async () => { + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const crmCredential = { + id: 1, + type: "salesforce_other_calendar", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + const otherCredential = { + id: 2, + type: "other_credential", + userId: 1, + teamId: null, + key: {}, + appId: "other", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + type: "salesforce_crm", + teamId: 1, + key: {}, + }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [ + { ...crmCredential, user: { email: "test@test.com" } }, + { + ...otherCredential, + user: { email: "test@test.com" }, + }, + ], + }, + { + userId: 1, + team: null, + parentId: null, + metadata: { + apps: {}, + }, + } + ); + + expect(credentials).toHaveLength(2); + + expect(credentials).toContainEqual( + expect.objectContaining({ userId: 1, type: "salesforce_other_calendar" }) + ); + }); + test("For teams", async () => { + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + id: 3, + type: "salesforce_other_calendar", + teamId: 1, + key: {}, + }, + { + type: "other_credential", + teamId: 1, + key: {}, + }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [{ ...crmCredential, user: { email: "test@test.com" } }], + }, + { + userId: null, + team: { + id: 1, + parentId: null, + }, + parentId: null, + metadata: { + apps: {}, + }, + } + ); + + expect(credentials).toHaveLength(2); + + expect(credentials).toContainEqual( + expect.objectContaining({ teamId: 1, type: "salesforce_other_calendar" }) + ); + }); + test("For child of managed event type", async () => { + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + + const teamId = 1; + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + id: 3, + type: "salesforce_other_calendar", + teamId, + key: {}, + }, + { + type: "other_credential", + teamId, + key: {}, + }, + ]); + + await addTeamsToDb([ + { + id: teamId, + name: "Test team", + slug: "test-team", + }, + ]); + + const testEventType = await addEventTypesToDb([ + { + id: 3, + title: "Test event type", + slug: "test-event-type", + length: 15, + team: { + connect: { + id: teamId, + }, + }, + }, + ]); + + console.log(testEventType); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [{ ...crmCredential, user: { email: "test@test.com" } }], + }, + { + userId: null, + team: { + id: 2, + parentId: 1, + }, + parentId: 3, + metadata: { + apps: {}, + }, + } + ); + + expect(credentials).toHaveLength(2); + + expect(credentials).toContainEqual( + expect.objectContaining({ teamId, type: "salesforce_other_calendar" }) + ); + }); + test("For an org user", async () => { + const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials; + const orgId = 3; + vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({ + profile: { organizationId: orgId }, + }); + + const crmCredential = { + id: 1, + type: "salesforce_crm", + userId: 1, + teamId: null, + key: {}, + appId: "salesforce", + invalid: false, + }; + + const otherCredential = { + id: 2, + type: "other_credential", + userId: 1, + teamId: null, + key: {}, + appId: "other", + invalid: false, + }; + + await createCredentials([ + crmCredential, + { + type: "salesforce_crm", + userId: 2, + key: {}, + }, + { + id: 3, + type: "salesforce_other_calendar", + teamId: orgId, + key: {}, + }, + { + type: "other_credential", + teamId: orgId, + key: {}, + }, + ]); + + await addTeamsToDb([ + { + id: orgId, + name: "Test team", + slug: "test-team", + }, + ]); + + await addUsersToDb([ + { + id: 1, + email: "test@test.com", + username: "test", + schedules: [], + profiles: { + create: [{ organizationId: orgId, uid: "MOCK_UID", username: "test" }], + }, + }, + ]); + + const credentials = await getAllCredentials( + { + id: 1, + username: "test", + credentials: [ + { ...crmCredential, user: { email: "test@test.com" } }, + { + ...otherCredential, + user: { email: "test@test.com" }, + }, + ], + }, + { + userId: 1, + team: null, + parentId: null, + metadata: { + apps: {}, + }, + } + ); + + expect(credentials).toHaveLength(3); + + expect(credentials).toContainEqual( + expect.objectContaining({ teamId: orgId, type: "salesforce_other_calendar" }) + ); + }); + }); + }); + }); +}); diff --git a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts index b2a3e9b98b..fc697f154e 100644 --- a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts +++ b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts @@ -1,22 +1,25 @@ -import type { Prisma } from "@prisma/client"; +import type z from "zod"; import { UserRepository } from "@calcom/lib/server/repository/user"; -import type { userSelect } from "@calcom/prisma"; import prisma from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CredentialPayload } from "@calcom/types/Credential"; -type User = Prisma.UserGetPayload; - /** * Gets credentials from the user, team, and org if applicable * */ export const getAllCredentials = async ( user: { id: number; username: string | null; credentials: CredentialPayload[] }, - eventType: { team: { id: number | null } | null; parentId: number | null } | null + eventType: { + userId?: number | null; + team?: { id: number | null; parentId: number | null } | null; + parentId?: number | null; + metadata: z.infer; + } | null ) => { - const allCredentials = user.credentials; + let allCredentials = user.credentials; // If it's a team event type query for team credentials if (eventType?.team?.id) { @@ -72,5 +75,47 @@ export const getAllCredentials = async ( } } + // Only return CRM credentials that are enabled on the event type + const eventTypeAppMetadata = eventType?.metadata?.apps; + console.log(eventTypeAppMetadata); + + // Will be [credentialId]: { enabled: boolean }] + const eventTypeCrmCredentials: Record = {}; + + for (const appKey in eventTypeAppMetadata) { + const app = eventTypeAppMetadata[appKey as keyof typeof eventTypeAppMetadata]; + if (app.appCategories && app.appCategories.some((category: string) => category === "crm")) { + eventTypeCrmCredentials[app.credentialId] = { + enabled: app.enabled, + }; + } + } + + allCredentials = allCredentials.filter((credential) => { + if (!credential.type.includes("_crm") && !credential.type.includes("_other_calendar")) { + return credential; + } + + // Backwards compatibility: All CRM apps are triggered for every event type. Unless disabled on the event type + // Check if the CRM app exists on the event type + if (eventTypeCrmCredentials[credential.id]) { + if (eventTypeCrmCredentials[credential.id].enabled) { + return credential; + } + } else { + // If the CRM app doesn't exist on the event type metadata, check that the credential belongs to the user/team/org and is an old CRM credential + if ( + credential.type.includes("_other_calendar") && + (credential.userId === eventType?.userId || + credential.teamId === eventType?.team?.id || + credential.teamId === eventType?.team?.parentId || + credential.teamId === profile?.organizationId) + ) { + // If the CRM app doesn't exist on the event type metadata, assume it's an older CRM credential + return credential; + } + } + }); + return allCredentials; }; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 360bbc9417..57d2b6d88f 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -26,7 +26,7 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus, WorkflowMethods } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; +import { EventTypeMetaDataSchema, schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; @@ -81,8 +81,11 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine select: { id: true, name: true, + parentId: true, }, }, + parentId: true, + userId: true, recurringEvent: true, title: true, eventName: true, @@ -94,6 +97,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine seatsPerTimeSlot: true, bookingFields: true, seatsShowAttendees: true, + metadata: true, hosts: { select: { user: true, @@ -108,7 +112,6 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine }, }, }, - parentId: true, }, }, uid: true, @@ -435,7 +438,15 @@ async function handler(req: CustomRequest) { bookingToDelete.recurringEventId && allRemainingBookings ); - const credentials = await getAllCredentials(bookingToDelete.user, bookingToDelete.eventType); + + const bookingToDeleteEventTypeMetadata = EventTypeMetaDataSchema.parse( + bookingToDelete.eventType?.metadata || null + ); + + const credentials = await getAllCredentials(bookingToDelete.user, { + ...bookingToDelete.eventType, + metadata: bookingToDeleteEventTypeMetadata, + }); const eventManager = new EventManager({ ...bookingToDelete.user, credentials }); diff --git a/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts index a9eda5e152..f7df0792de 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts @@ -1,7 +1,3 @@ -import { describe } from "vitest"; - -import { BookingStatus } from "@calcom/prisma/enums"; -import { test } from "@calcom/web/test/fixtures/fixtures"; import { BookingLocations, createBookingScenario, @@ -18,6 +14,11 @@ import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/ import { expectBookingCancelledWebhookToHaveBeenFired } from "@calcom/web/test/utils/bookingScenario/expects"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; +import { describe } from "vitest"; + +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + describe("Cancel Booking", () => { setupAndTeardown(); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 8236f9dbe4..6b45ed5b58 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1329,7 +1329,12 @@ async function handler( loggerWithEventDetails ); const luckyUsers: typeof users = []; - const luckyUserPool = availableUsers.filter((user) => !user.isFixed); + const luckyUserPool: IsFixedAwareUser[] = []; + const fixedUserPool: IsFixedAwareUser[] = []; + availableUsers.forEach((user) => { + user.isFixed ? fixedUserPool.push(user) : luckyUserPool.push(user); + }); + const notAvailableLuckyUsers: typeof users = []; loggerWithEventDetails.debug( @@ -1339,6 +1344,17 @@ async function handler( luckyUserPool: luckyUserPool.map((user) => user.id), }) ); + + if (reqBody.teamMemberEmail) { + // If requested user is not a fixed host, assign the lucky user as the team member + if (!fixedUserPool.some((user) => user.email === reqBody.teamMemberEmail)) { + const teamMember = availableUsers.find((user) => user.email === reqBody.teamMemberEmail); + if (teamMember) { + luckyUsers.push(teamMember); + } + } + } + // loop through all non-fixed hosts and get the lucky users while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) { const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", { @@ -1386,13 +1402,11 @@ async function handler( } } // ALL fixed users must be available - if ( - availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length - ) { + if (fixedUserPool.length !== users.filter((user) => user.isFixed).length) { throw new Error(ErrorCode.HostsUnavailableForBooking); } // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. - users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; + users = [...fixedUserPool, ...luckyUsers]; luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) }; } else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) { // all recurring slots except the first one @@ -1409,7 +1423,10 @@ async function handler( throw new Error(ErrorCode.NoAvailableUsersFound); } - const [organizerUser] = users; + // If the team member is requested then they should be the organizer + const organizerUser = reqBody.teamMemberEmail + ? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0] + : users[0]; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); const allCredentials = await getAllCredentials(organizerUser, eventType); @@ -1506,29 +1523,31 @@ async function handler( const teamDestinationCalendars: DestinationCalendar[] = []; // Organizer or user owner of this event type it's not listed as a team member. - const teamMemberPromises = users.slice(1).map(async (user) => { - // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 - // push to teamDestinationCalendars if it's a team event but collective only - if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) { - teamDestinationCalendars.push({ - ...user.destinationCalendar, - externalId: processExternalId(user.destinationCalendar), - }); - } + const teamMemberPromises = users + .filter((user) => user.email !== organizerUser.email) + .map(async (user) => { + // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 + // push to teamDestinationCalendars if it's a team event but collective only + if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) { + teamDestinationCalendars.push({ + ...user.destinationCalendar, + externalId: processExternalId(user.destinationCalendar), + }); + } - return { - id: user.id, - email: user.email ?? "", - name: user.name ?? "", - firstName: "", - lastName: "", - timeZone: user.timeZone, - language: { - translate: await getTranslation(user.locale ?? "en", "common"), - locale: user.locale ?? "en", - }, - }; - }); + return { + id: user.id, + email: user.email ?? "", + name: user.name ?? "", + firstName: "", + lastName: "", + timeZone: user.timeZone, + language: { + translate: await getTranslation(user.locale ?? "en", "common"), + locale: user.locale ?? "en", + }, + }; + }); const teamMembers = await Promise.all(teamMemberPromises); const attendeesList = [...invitee, ...guests]; diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index 928002419b..3c974d3b98 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -1509,7 +1509,128 @@ describe("handleNewBooking", () => { }); }); - test.todo("Round Robin booking"); + describe("Round Robin Assignment", () => { + test(`successfully books contact owner if rr lead skip is enabled`, async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: 1001, + email: "other-team-member-1@example.com", + id: 102, + schedules: [{ ...TestData.schedules.IstWorkHours, id: 1001 }], + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const { eventTypes } = await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + metadata: { + apps: { + salesforce: { + enabled: true, + appCategories: ["crm"], + roundRobinLeadSkip: true, + }, + }, + }, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + }) + ); + + const bookingData = { + eventTypeId: 1, + teamMemberEmail: otherTeamMembers[0].email, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: OrganizerDefaultConferencingAppType }, + }, + }; + + const mockBookingData1 = getMockRequestDataForBooking({ + data: { + ...bookingData, + start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + }, + }); + + const mockBookingData2 = getMockRequestDataForBooking({ + data: { + ...bookingData, + start: `${getDate({ dateIncrement: 2 }).dateString}T05:00:00.000Z`, + end: `${getDate({ dateIncrement: 2 }).dateString}T05:30:00.000Z`, + }, + }); + + const { req: req1 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData1, + }); + + const { req: req2 } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData2, + }); + + const createdBooking1 = await handleNewBooking(req1); + + expect(createdBooking1.userId).toBe(102); + + const createdBooking2 = await handleNewBooking(req2); + expect(createdBooking2.userId).toBe(102); + }); + }); }); describe("Team Plus Paid Events", () => { diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 329278f3e1..48ee3d8eb9 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -16,6 +16,7 @@ export type UseScheduleWithCacheArgs = { rescheduleUid?: string | null; isTeamEvent?: boolean; orgSlug?: string; + bookerEmail?: string; }; export const useSchedule = ({ @@ -32,6 +33,7 @@ export const useSchedule = ({ rescheduleUid, isTeamEvent, orgSlug, + bookerEmail, }: UseScheduleWithCacheArgs) => { const [startTime, endTime] = useTimesForSchedule({ month, @@ -58,6 +60,7 @@ export const useSchedule = ({ duration: duration ? `${duration}` : undefined, rescheduleUid, orgSlug, + bookerEmail, }, { trpc: { diff --git a/packages/lib/CloseCom.ts b/packages/lib/CloseCom.ts index cb2587f953..350c0da97a 100644 --- a/packages/lib/CloseCom.ts +++ b/packages/lib/CloseCom.ts @@ -10,13 +10,21 @@ export type CloseComLead = { export type CloseComFieldOptions = [string, string, boolean, boolean][]; +type CloseComContactEmail = { + email: string; + type: string; +}; + export type CloseComLeadCreateResult = { status_id: string; status_label: string; display_name: string; addresses: { [key: string]: string }[]; name: string; - contacts: { [key: string]: string }[]; + contacts: { + id: string; + emails: CloseComContactEmail[]; + }[]; [key: CloseComCustomActivityCustomField]: string; id: string; }; @@ -35,10 +43,7 @@ export type CloseComCustomActivityTypeCreate = { export type CloseComContactSearch = { data: { __object_type: "contact"; - emails: { - email: string; - type: string; - }[]; + emails: CloseComContactEmail[]; id: string; lead_id: string; name: string; diff --git a/packages/lib/CloseComeUtils.ts b/packages/lib/CloseComeUtils.ts index cae8337777..45e85cb004 100644 --- a/packages/lib/CloseComeUtils.ts +++ b/packages/lib/CloseComeUtils.ts @@ -199,6 +199,7 @@ export async function getCloseComLeadId( description: `Generic Lead for Contacts created by ${APP_NAME}`, } ): Promise { + // TODO: Check for leads against email rather than name const closeComLeadNames = await closeCom.lead.list({ query: { _fields: ["name", "id"] } }); const searchLeadFromCalCom: CloseComLead[] = closeComLeadNames.data.filter( (lead) => lead.name === leadInfo.companyName diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index b348d2d383..2eb92e5376 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -185,4 +185,29 @@ export class EventTypeRepository { }); } } + + static async findAllByUserId({ userId }: { userId: number }) { + return await prisma.eventType.findMany({ + where: { + userId, + }, + }); + } + + static async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) { + return await prisma.eventType.findMany({ + where: { + OR: [ + { + teamId, + }, + { + parent: { + teamId, + }, + }, + ], + }, + }); + } } diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index ba1fb17e91..b8cb2616be 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -55,6 +55,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { const [bookerState, _] = useBookerStore((state) => [state.state, state.setState], shallow); const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); + const { data: session } = useSession(); const routerQuery = useRouterQuery(); const hasSession = !!session; @@ -87,12 +88,6 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { extraOptions: routerQuery, prefillFormParams, }); - const bookings = useBookings({ - event, - hashedLink: props.hashedLink, - bookingForm: bookerForm.bookingForm, - metadata: metadata ?? {}, - }); const calendars = useCalendars({ hasSession }); const verifyEmail = useVerifyEmail({ email: bookerForm.formEmail, @@ -129,6 +124,14 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { month: props.month, duration: props.duration, selectedDate, + bookerEmail: bookerForm.formEmail, + }); + const bookings = useBookings({ + event, + hashedLink: props.hashedLink, + bookingForm: bookerForm.bookingForm, + metadata: metadata ?? {}, + teamMemberEmail: schedule.data?.teamMember, }); const verifyCode = useVerifyCode({ diff --git a/packages/platform/atoms/hooks/useHandleBookEvent.ts b/packages/platform/atoms/hooks/useHandleBookEvent.ts index c52f7d8313..164e2cbe6c 100644 --- a/packages/platform/atoms/hooks/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/useHandleBookEvent.ts @@ -16,6 +16,7 @@ type UseHandleBookingProps = { event: useEventReturnType; metadata: Record; hashedLink?: string | null; + teamMemberEmail?: string; handleBooking: (input: UseCreateBookingInput) => void; handleInstantBooking: (input: BookingCreateBody) => void; handleRecBooking: (input: BookingCreateBody[]) => void; @@ -27,6 +28,7 @@ export const useHandleBookEvent = ({ event, metadata, hashedLink, + teamMemberEmail, handleBooking, handleInstantBooking, handleRecBooking, @@ -79,6 +81,7 @@ export const useHandleBookEvent = ({ username: username || "", metadata: metadata, hashedLink, + teamMemberEmail, orgSlug: orgSlug ? orgSlug : undefined, }; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index f3e969a480..f5c6f37344 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -68,13 +68,15 @@ export type BookerLayoutSettings = z.infer; export const RequiresConfirmationThresholdUnits: z.ZodType = z.enum(["hours", "minutes"]); +export const EventTypeAppMetadataSchema = z.object(appDataSchemas).partial(); + export const EventTypeMetaDataSchema = z .object({ smartContractAddress: z.string().optional(), blockchainId: z.number().optional(), multipleDuration: z.number().array().optional(), giphyThankYouPage: z.string().optional(), - apps: z.object(appDataSchemas).partial().optional(), + apps: EventTypeAppMetadataSchema.optional(), additionalNotesRequired: z.boolean().optional(), disableSuccessPage: z.boolean().optional(), disableStandardEmails: z @@ -229,6 +231,7 @@ export const bookingCreateBodySchema = z.object({ hashedLink: z.string().nullish(), seatReferenceUid: z.string().optional(), orgSlug: z.string().optional(), + teamMemberEmail: z.string().optional(), }); export const requiredCustomInputSchema = z.union([ @@ -275,6 +278,7 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge( .optional(), luckyUsers: z.array(z.number()).optional(), customInputs: z.undefined().optional(), + teamMemberEmail: z.string().optional(), }) ); diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts index 5f2a62c84c..8dd1bd6805 100644 --- a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -1,6 +1,7 @@ import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { DailyLocationType } from "@calcom/core/location"; import { sendCancelledEmails } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; @@ -11,6 +12,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { bookingMinimalSelect, prisma } from "@calcom/prisma"; import { AppCategories, BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils"; import { userMetadata } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -150,11 +152,13 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp } } - // If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings - if (credential.app?.categories.includes(AppCategories.payment)) { + if (credential.app?.categories.includes(AppCategories.crm)) { const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); - const appSlug = credential.app?.slug; - if (appSlug) { + const appSlugToDelete = credential.app?.slug; + + if (appSlugToDelete) { + const appMetadata = removeAppFromEventTypeMetadata(appSlugToDelete, metadata); + await prisma.$transaction(async () => { await prisma.eventType.update({ where: { @@ -165,8 +169,33 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp metadata: { ...metadata, apps: { - ...metadata?.apps, - [appSlug]: undefined, + ...appMetadata, + }, + }, + }, + }); + }); + } + } + + // If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings + if (credential.app?.categories.includes(AppCategories.payment)) { + const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + const appSlug = credential.app?.slug; + if (appSlug) { + const appMetadata = removeAppFromEventTypeMetadata(appSlug, metadata); + + await prisma.$transaction(async () => { + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + hidden: true, + metadata: { + ...metadata, + apps: { + ...appMetadata, }, }, }, @@ -315,6 +344,28 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp } }); } + } else if ( + (appStoreMetadata[credential.app?.slug as keyof typeof appStoreMetadata].extendsFeature = "EventType") + ) { + const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + const appSlug = credential.app?.slug; + if (appSlug) { + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + hidden: true, + metadata: { + ...metadata, + apps: { + ...metadata?.apps, + [appSlug]: undefined, + }, + }, + }, + }); + } } } @@ -391,3 +442,19 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp }, }); }; + +const removeAppFromEventTypeMetadata = ( + appSlugToDelete: string, + eventTypeMetadata: z.infer +) => { + const appMetadata = eventTypeMetadata?.apps + ? Object.entries(eventTypeMetadata.apps).reduce((filteredApps, [appName, appData]) => { + if (appName !== appSlugToDelete) { + filteredApps[appName as keyof typeof eventTypeMetadata.apps] = appData; + } + return filteredApps; + }, {} as z.infer) + : {}; + + return appMetadata; +}; diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 76b2e8c66d..3c0f206ab7 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -24,6 +24,7 @@ export const getScheduleSchema = z // whether to do team event or user event isTeamEvent: z.boolean().optional().default(false), orgSlug: z.string().optional(), + bookerEmail: z.string().optional(), }) .transform((val) => { // Need this so we can pass a single username in the query string form public API diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index f96e2b3f31..0afa92d2c2 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1,7 +1,9 @@ // eslint-disable-next-line no-restricted-imports import { countBy } from "lodash"; import { v4 as uuid } from "uuid"; +import type z from "zod"; +import CrmManager from "@calcom/core/crmManager/crmManager"; import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability"; import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes"; import type { CurrentSeats, IFromUser, IToUser } from "@calcom/core/getUserAvailability"; @@ -26,6 +28,7 @@ import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -314,6 +317,57 @@ export interface IGetAvailableSlots { emoji?: string | undefined; }[] >; + teamMember?: string | undefined; +} + +async function getCRMContactOwnerForRRLeadSkip( + bookerEmail: string, + apps?: z.infer +) { + if (!apps) return; + const crm = await getCRMManagerWithRRLeadSkip(apps); + + if (!crm) return; + + const contact = await crm.getContacts(bookerEmail, true); + if (contact?.length) { + return contact[0].ownerEmail; + } +} + +async function getCRMManagerWithRRLeadSkip(apps: z.infer) { + let crmRoundRobinLeadSkip; + for (const appKey in apps) { + const app = apps[appKey as keyof typeof apps]; + if ( + app.enabled && + typeof app.appCategories === "object" && + app.appCategories.some((category: string) => category === "crm") && + app.roundRobinLeadSkip + ) { + crmRoundRobinLeadSkip = app; + break; + } + } + + if (crmRoundRobinLeadSkip) { + const crmCredential = await prisma.credential.findUnique({ + where: { + id: crmRoundRobinLeadSkip.credentialId, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + if (crmCredential) { + return new CrmManager(crmCredential); + } + } + return; } export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise { @@ -376,13 +430,36 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro } let currentSeats: CurrentSeats | undefined; - let usersWithCredentials = eventType.users.map((user) => ({ - isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, - ...user, - })); - // overwrite if it is a team event & hosts is set, otherwise keep using users. - if (eventType.schedulingType && !!eventType.hosts?.length) { - usersWithCredentials = eventType.hosts.map(({ isFixed, user }) => ({ isFixed, ...user })); + let teamMember: string | undefined; + + const hosts = + eventType.hosts?.length && eventType.schedulingType + ? eventType.hosts + : eventType.users.map((user) => { + return { + isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, + user: user, + }; + }); + + let usersWithCredentials = hosts.map(({ isFixed, user }) => ({ isFixed, ...user })); + + if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && input.bookerEmail) { + const crmContactOwner = await getCRMContactOwnerForRRLeadSkip( + input.bookerEmail, + eventType?.metadata?.apps + ); + const contactOwnerHost = hosts.find((host) => host.user.email === crmContactOwner); + + if (contactOwnerHost) { + teamMember = contactOwnerHost.user.email; + const contactOwnerIsRRHost = !contactOwnerHost.isFixed; + + usersWithCredentials = usersWithCredentials.filter( + (user) => user.email !== contactOwnerHost.user.email && (!contactOwnerIsRRHost || user.isFixed) + ); + usersWithCredentials.push({ ...contactOwnerHost.user, isFixed: true }); + } } const durationToUse = input.duration || 0; @@ -745,6 +822,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro return { slots: withinBoundsSlotsMappedToDate, + teamMember, }; } diff --git a/packages/types/CrmService.d.ts b/packages/types/CrmService.d.ts new file mode 100644 index 0000000000..e3bf894a48 --- /dev/null +++ b/packages/types/CrmService.d.ts @@ -0,0 +1,33 @@ +import type { CalendarEvent } from "./Calendar"; + +export interface CrmData { + id: string; + type: string; + credentialId: number; + password?: string; + url?: string; +} + +export interface ContactCreateInput { + email: string; + name: string; +} + +export interface Contact { + id: string; + email: string; + ownerId?: string; + ownerEmail?: string; +} + +export interface CrmEvent { + id: string; +} + +export interface CRM { + createEvent: (event: CalendarEvent, contacts: Contact[]) => Promise; + updateEvent: (uid: string, event: CalendarEvent) => Promise; + deleteEvent: (uid: string) => Promise; + getContacts: (emails: string | string[], includeOwner?: boolean) => Promise; + createContacts: (contactsToCreate: ContactCreateInput[], organizerEmail?: string) => Promise; +} diff --git a/packages/types/Event.d.ts b/packages/types/Event.d.ts index 407c34c6df..4ab3f0d6dc 100644 --- a/packages/types/Event.d.ts +++ b/packages/types/Event.d.ts @@ -1,5 +1,6 @@ import type { NewCalendarEventType, AdditionalInformation } from "@calcom/types/Calendar"; +import type { CrmData } from "./CrmService"; import type { VideoCallData } from "./VideoApiAdapter"; -export type Event = AdditionalInformation | NewCalendarEventType | VideoCallData; +export type Event = AdditionalInformation | NewCalendarEventType | VideoCallData | CrmData; diff --git a/yarn.lock b/yarn.lock index b25a014a3f..c426536994 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4357,7 +4357,7 @@ __metadata: eslint: ^8.34.0 npm-run-all: ^4.1.5 typescript: ^4.9.4 - vite: ^4.1.2 + vite: ^4.5.2 peerDependencies: react: ^18.2.0 react-dom: ^18.2.0 @@ -46831,6 +46831,46 @@ __metadata: languageName: node linkType: hard +"vite@npm:^4.5.2": + version: 4.5.3 + resolution: "vite@npm:4.5.3" + dependencies: + esbuild: ^0.18.10 + fsevents: ~2.3.2 + postcss: ^8.4.27 + rollup: ^3.27.1 + peerDependencies: + "@types/node": ">= 14" + less: "*" + lightningcss: ^1.21.0 + sass: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: fd3f512ce48ca2a1fe60ad0376283b832de9272725fdbc65064ae9248f792de87b0f27a89573115e23e26784800daca329f8a9234d298ba6f60e808a9c63883c + languageName: node + linkType: hard + "vitest-fetch-mock@npm:^0.2.2": version: 0.2.2 resolution: "vitest-fetch-mock@npm:0.2.2"