diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts index 1e3c511925..d215803b9a 100644 --- a/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts @@ -387,7 +387,6 @@ describe("Organizations User OOO Endpoints", () => { } // test sort expect(data[1].id).toEqual(oooCreatedViaApiId); - }); }); diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts index 8ecff65beb..766baefa58 100644 --- a/packages/app-store/salesforce/lib/CrmService.ts +++ b/packages/app-store/salesforce/lib/CrmService.ts @@ -910,6 +910,11 @@ export default class SalesforceCRMService implements CRM { const fieldsToWriteOn = Object.keys(onBookingWriteToRecordFields); const existingFields = await this.ensureFieldsExistOnObject(fieldsToWriteOn, personRecordType); + if (!existingFields.length) { + this.log.warn(`No fields found for record type ${personRecordType}`); + return; + } + const personRecord = await this.fetchPersonRecord(contactId, existingFields, personRecordType); if (!personRecord) { this.log.warn(`No personRecord found for contactId ${contactId}`); diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 1af0d6de31..9c4280c4be 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -9,6 +9,8 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod"; import { getLocationFromApp, MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; +import CRMScheduler from "@calcom/core/crmManager/tasker/crmScheduler"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { @@ -215,7 +217,9 @@ export default class EventManager { return result.type.includes("_calendar"); }; - results.push(...(await this.createAllCRMEvents(clonedCalEvent))); + const createdCRMEvents = await this.createAllCRMEvents(clonedCalEvent); + + results.push(...createdCRMEvents); // References can be any type: calendar/video const referencesToCreate = results.map((result) => { @@ -970,8 +974,26 @@ export default class EventManager { private async createAllCRMEvents(event: CalendarEvent) { const createdEvents = []; + + const featureRepo = new FeaturesRepository(); + const isTaskerEnabledForSalesforceCrm = event.team?.id + ? await featureRepo.checkIfTeamHasFeature(event.team.id, "salesforce-crm-tasker") + : false; + const uid = getUid(event); for (const credential of this.crmCredentials) { + if (isTaskerEnabledForSalesforceCrm) { + if (!event.uid) { + console.error( + `Missing bookingId when scheduling CRM event creation on event type ${event?.eventTypeId}` + ); + continue; + } + + await CRMScheduler.createEvent({ bookingUid: event.uid }); + continue; + } + const currentAppOption = this.getAppOptionsFromEventMetadata(credential); const crm = new CrmManager(credential, currentAppOption); diff --git a/packages/core/crmManager/tasker/crmScheduler.ts b/packages/core/crmManager/tasker/crmScheduler.ts new file mode 100644 index 0000000000..c37e23e6af --- /dev/null +++ b/packages/core/crmManager/tasker/crmScheduler.ts @@ -0,0 +1,9 @@ +import tasker from "@calcom/features/tasker"; + +class CRMScheduler { + static async createEvent({ bookingUid }: { bookingUid: string }) { + return tasker.create("createCRMEvent", { bookingUid }); + } +} + +export default CRMScheduler; diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index a03ca9ed31..bf4145e927 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -17,4 +17,5 @@ export type AppFlags = { attributes: boolean; "organizer-request-email-v2": boolean; "domain-wide-delegation": boolean; + "salesforce-crm-tasker": boolean; }; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 1b248362ef..ca2672d829 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -16,6 +16,7 @@ const initialData: AppFlags = { attributes: false, "organizer-request-email-v2": false, "domain-wide-delegation": false, + "salesforce-crm-tasker": false, }; if (process.env.NEXT_PUBLIC_IS_E2E) { diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index f144947357..c326af3434 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -17,6 +17,7 @@ type TaskPayloads = { translateEventTypeData: z.infer< typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema >; + createCRMEvent: z.infer; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string) => Promise; diff --git a/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts b/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts new file mode 100644 index 0000000000..cf8f20d171 --- /dev/null +++ b/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts @@ -0,0 +1,258 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { BookingStatus } from "@calcom/prisma/enums"; + +import { createCRMEvent } from "../createCRMEvent"; + +interface User { + id: number; + email: string; + name: string; +} + +interface EventTypeMetadata { + apps: { + salesforce: { + enabled: boolean; + credentialId: number; + appCategories: string[]; + }; + }; +} + +interface Booking { + id: number; + uid: string; + status: BookingStatus; + title: string; + startTime: Date; + endTime: Date; + user: User; + eventType: { + metadata: EventTypeMetadata; + }; +} + +interface CRMCredential { + id: number; + type: string; + key: { + access_token?: string; + refresh_token?: string; + expiry_date?: number; + key1?: string; + }; + userId: number; +} + +interface CRMManagerInterface { + createEvent: (event: unknown) => Promise<{ id: string }>; +} + +vi.mock("../lib/buildCalendarEvent", () => ({ + default: vi.fn().mockResolvedValue({ + title: "Test Event", + description: "Test Description", + startTime: new Date(), + endTime: new Date(), + }), +})); + +const mockCreateEvent = vi.fn().mockResolvedValue({ id: "sf-event-123" }); +vi.mock("@calcom/core/crmManager/crmManager", () => ({ + default: class MockCrmManager { + private credential: CRMCredential; + + constructor(credential: CRMCredential) { + this.credential = credential; + } + + createEvent = mockCreateEvent; + + getManager(): CRMManagerInterface { + return { + createEvent: this.createEvent, + }; + } + }, +})); + +describe("createCRMEvent", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateEvent.mockClear(); + // Clear Prisma mocks + prismaMock.booking.findUnique.mockReset(); + prismaMock.credential.findUnique.mockReset(); + prismaMock.bookingReference.createMany.mockReset(); + }); + + it("should successfully create a Salesforce CRM event", async () => { + const mockBooking: Booking = { + id: 1, + uid: "booking-123", + status: BookingStatus.ACCEPTED, + title: "Test Booking", + startTime: new Date(), + endTime: new Date(), + user: { + id: 1, + email: "test@example.com", + name: "Test User", + }, + eventType: { + metadata: { + apps: { + salesforce: { + enabled: true, + credentialId: 1, + appCategories: ["crm"], + }, + }, + }, + }, + }; + + const mockCredential: CRMCredential = { + id: 1, + type: "salesforce_crm", + key: { + access_token: "mock_token", + refresh_token: "mock_refresh", + expiry_date: new Date().getTime() + 3600000, + }, + userId: 1, + }; + + // Set up Prisma mocks with proper return values + prismaMock.booking.findUnique.mockResolvedValueOnce(mockBooking); + prismaMock.credential.findUnique.mockResolvedValueOnce(mockCredential); + prismaMock.bookingReference.createMany.mockResolvedValueOnce({ count: 1 }); + + const payload = JSON.stringify({ + bookingUid: "booking-123", + }); + + await createCRMEvent(payload); + + expect(mockCreateEvent).toHaveBeenCalled(); + expect(prismaMock.bookingReference.createMany).toHaveBeenCalledWith({ + data: [ + { + type: "salesforce_crm", + uid: "sf-event-123", + meetingId: "sf-event-123", + credentialId: 1, + bookingId: 1, + }, + ], + }); + }); + + it("should throw error for invalid payload", async () => { + const invalidPayload = JSON.stringify({ + invalidField: "test", + }); + + await expect(createCRMEvent(invalidPayload)).rejects.toThrow("malformed payload in createCRMEvent"); + }); + + it("should throw error when booking is not found", async () => { + prismaMock.booking.findUnique.mockResolvedValue(null); + + const payload = JSON.stringify({ + bookingUid: "non-existent-booking", + }); + + await expect(createCRMEvent(payload)).rejects.toThrow("booking not found"); + }); + + it("should handle case when Salesforce CRM is not enabled", async () => { + const mockBooking: Booking = { + id: 1, + uid: "booking-123", + status: BookingStatus.ACCEPTED, + title: "Test Booking", + startTime: new Date(), + endTime: new Date(), + user: { + id: 1, + email: "test@example.com", + name: "Test User", + }, + eventType: { + metadata: { + apps: { + salesforce: { + enabled: false, + credentialId: 1, + appCategories: ["crm"], + }, + }, + }, + }, + }; + + prismaMock.booking.findUnique.mockResolvedValue(mockBooking); + + const payload = JSON.stringify({ + bookingUid: "booking-123", + }); + + await createCRMEvent(payload); + + expect(prismaMock.bookingReference.createMany).toHaveBeenCalledWith({ + data: [], + }); + }); + + it("should handle CRM creation error gracefully", async () => { + const mockBooking: Booking = { + id: 1, + uid: "booking-123", + status: BookingStatus.ACCEPTED, + title: "Test Booking", + startTime: new Date(), + endTime: new Date(), + user: { + id: 1, + email: "test@example.com", + name: "Test User", + }, + eventType: { + metadata: { + apps: { + salesforce: { + enabled: true, + credentialId: 1, + appCategories: ["crm"], + }, + }, + }, + }, + }; + + const mockCredential: CRMCredential = { + id: 1, + type: "salesforce_crm", + key: { key1: "value1" }, + userId: 1, + }; + + prismaMock.booking.findUnique.mockResolvedValue(mockBooking); + prismaMock.credential.findFirst.mockResolvedValue(mockCredential); + mockCreateEvent.mockRejectedValue(new Error("Salesforce API error")); + + const payload = JSON.stringify({ + bookingUid: "booking-123", + }); + + await createCRMEvent(payload); + + expect(prismaMock.bookingReference.createMany).toHaveBeenCalledWith({ + data: [], + }); + }); +}); diff --git a/packages/features/tasker/tasks/crm/createCRMEvent.ts b/packages/features/tasker/tasks/crm/createCRMEvent.ts new file mode 100644 index 0000000000..dc43619107 --- /dev/null +++ b/packages/features/tasker/tasks/crm/createCRMEvent.ts @@ -0,0 +1,144 @@ +import type { Prisma } from "@prisma/client"; + +import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import buildCalendarEvent from "./lib/buildCalendarEvent"; +import { createCRMEventSchema } from "./schema"; + +const log = logger.getSubLogger({ prefix: [`[[tasker] createCRMEvent`] }); + +export async function createCRMEvent(payload: string): Promise { + try { + const parsedPayload = createCRMEventSchema.safeParse(JSON.parse(payload)); + + if (!parsedPayload.success) { + throw new Error(`malformed payload in createCRMEvent: ${parsedPayload.error}`); + } + const { bookingUid } = parsedPayload.data; + + const booking = await prisma.booking.findUnique({ + where: { + uid: bookingUid, + status: BookingStatus.ACCEPTED, + }, + include: { + user: { + select: { + name: true, + email: true, + locale: true, + username: true, + timeZone: true, + }, + }, + eventType: { + select: { + metadata: true, + }, + }, + references: { + select: { + type: true, + }, + }, + }, + }); + + if (!booking) { + throw new Error(`booking not found for uid: ${bookingUid}`); + } + + if (!booking.user) { + throw new Error(`user not found for uid: ${bookingUid}`); + } + + const eventTypeMetadata = EventTypeMetaDataSchema.safeParse(booking.eventType?.metadata); + + if (!eventTypeMetadata.success) { + throw new Error(`malformed event type metadata: ${eventTypeMetadata.error}`); + } + + const eventTypeAppMetadata = eventTypeMetadata.data?.apps; + + if (!eventTypeAppMetadata) { + throw new Error(`event type app metadata not found for booking ${bookingUid}`); + } + + const calendarEvent = await buildCalendarEvent(bookingUid); + + const bookingReferencesToCreate: Prisma.BookingReferenceUncheckedCreateInput[] = []; + + // Find enabled CRM apps for the event type + for (const appSlug of Object.keys(eventTypeAppMetadata)) { + try { + const appData = eventTypeAppMetadata[appSlug as keyof typeof eventTypeAppMetadata]; + const appParse = eventTypeAppCardZod.safeParse(appData); + + if (!appParse.success) { + log.error(`Error parsing event type app data for bookingUid ${bookingUid}`, appParse?.error); + continue; + } + + const app = appParse.data; + + if ( + !app.appCategories || + !app.appCategories.some((category: string) => category === "crm") || + !app.enabled || + !app.credentialId + ) + continue; + + const crmCredential = await prisma.credential.findUnique({ + where: { + id: app.credentialId, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + if (!crmCredential) { + throw new Error(`Credential not found for credentialId: ${app.credentialId}`); + } + + const CrmManager = (await import("@calcom/core/crmManager/crmManager")).default; + + const crm = new CrmManager(crmCredential, app); + + const results = await crm.createEvent(calendarEvent).catch((error) => { + log.error(`Error creating crm event for credentialId ${app.credentialId}`, error); + }); + + if (results) { + bookingReferencesToCreate.push({ + type: crmCredential.type, + uid: results.id, + meetingId: results.id, + credentialId: crmCredential.id, + bookingId: booking.id, + }); + } + } catch (error) { + log.error(`Error processing CRM app ${appSlug} for booking ${bookingUid}:`, error); + // Continue with next app even if one fails + continue; + } + } + + await prisma.bookingReference.createMany({ + data: bookingReferencesToCreate, + }); + } catch (error) { + log.error(`Error in createCRMEvent for payload: ${payload}:`, error); + throw error; + } +} diff --git a/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts b/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts new file mode 100644 index 0000000000..d1ea41f6cc --- /dev/null +++ b/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts @@ -0,0 +1,90 @@ +import { addVideoCallDataToEvent } from "@calcom/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent"; +import { getTranslation } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +const buildCalendarEvent: (bookingUid: string) => Promise = async (bookingUid: string) => { + const booking = await prisma.booking.findFirst({ + where: { + uid: bookingUid, + }, + include: { + user: { + select: { + name: true, + email: true, + locale: true, + username: true, + timeZone: true, + timeFormat: true, + }, + }, + eventType: { + select: { + slug: true, + }, + }, + attendees: { + select: { + email: true, + locale: true, + name: true, + timeZone: true, + phoneNumber: true, + }, + }, + references: true, + }, + }); + + if (!booking) { + throw new Error(`booking not found for bookings: ${bookingUid}`); + } + + if (!booking.user) { + throw new Error(`organizer not found for booking: ${bookingUid}`); + } + + if (!booking.eventType) { + throw new Error(`event type not found for booking ${bookingUid}`); + } + + const organizerT = await getTranslation(booking.user?.locale ?? "en", "common"); + + const attendeePromises = []; + for (const attendee of booking.attendees) { + attendeePromises.push( + getTranslation(attendee.locale ?? "en", "common").then((tAttendee) => ({ + email: attendee.email, + name: attendee.name, + timeZone: attendee.timeZone, + language: { translate: tAttendee, locale: attendee.locale ?? "en" }, + phoneNumber: attendee.phoneNumber || undefined, + })) + ); + } + + const attendeeList = await Promise.all(attendeePromises); + + let calendarEvent: CalendarEvent = { + type: booking.eventType.slug, + title: booking.title, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: booking.user.email, + name: booking.user.name || "Nameless", + username: booking.user?.username || "No username", + language: { translate: organizerT, locale: booking.user?.locale ?? "en" }, + timeZone: booking.user.timeZone, + }, + attendees: attendeeList, + location: booking.location, + }; + + calendarEvent = addVideoCallDataToEvent(booking.references, calendarEvent); + + return calendarEvent; +}; + +export default buildCalendarEvent; diff --git a/packages/features/tasker/tasks/crm/schema.ts b/packages/features/tasker/tasks/crm/schema.ts new file mode 100644 index 0000000000..bf2a095679 --- /dev/null +++ b/packages/features/tasker/tasks/crm/schema.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const createCRMEventSchema = z.object({ + bookingUid: z.string(), +}); diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index e8adead8dc..1c9af9fd6d 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -19,6 +19,7 @@ const tasks: Record Promise> = { sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))), translateEventTypeData: () => import("./translateEventTypeData").then((module) => module.translateEventTypeData), + createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent), }; export default tasks; diff --git a/packages/prisma/migrations/20241230140747531_add_salesforce_crm_tasker/migration.sql b/packages/prisma/migrations/20241230140747531_add_salesforce_crm_tasker/migration.sql new file mode 100644 index 0000000000..c535803aab --- /dev/null +++ b/packages/prisma/migrations/20241230140747531_add_salesforce_crm_tasker/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'salesforce-crm-tasker', + false, + 'Whether to use tasker for Salesforce CRM event creation or not on a team/user basis.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING;