perf: Move CRM event creation to tasker (#18370)

* Upgrade jsforce to 3.6.2

* Refactor connecting to Salesforce

* Revert yarn.lock changes

* Add `TASKER_ENABLE_CRM_EVENT_CREATION` to .env

* Add createCRMEvent scheduler

* Schedule CRM event creating in `EventManager`

* Add calendar event builder for CRM tasks

* Do not write to person record if fields don't exist

* Change type to expect string from tasker

* Create CRM event

* Create booking references

* Type fixes

* Migrate callback endpoint

* Add jsforce node dependency

* Migrate add endpoint

* Import

* Import package into crmService

* Use new package types

* Type fix

* Update vite config

* Push updated lockfile

* Attempt to bump platform/libraries to unlock jsforce

* Also update lockfile, naturally

* bump platform libraries

* feat: salesforce to tasker improvements (#18419)

* feat: salesforce to tasker

* refactor: event manager

* tests: add unit tests for create CRM Event

* Update vite.config

* Add jsforce to vite config

* Revert mint.json changes

* Default to not enabling

* Revert yarn.lock changes

* Remove `TASKER_ENABLE_CRM_EVENT_CREATION` variable

* Revert yarn.lock changes

* feat: Round Robin weights future members toggle (#17782)

Co-authored-by: Omar López <zomars@me.com>

* detailed customer card (#18511)

Co-authored-by: Omar López <zomars@me.com>

* chore: app router - all sub-pages in `/apps` (#16976)

* chore: apps/[slug] remove pages router

* remove apps/[slug] pages from /future

* chore: apps/installed remove pages router

* chore: apps/installation remove pages router

* remove Head element

* fix metadata

* fix test

* fix another test

* chore: apps/categories remove pages router

* revert unneeded changes

* update middleware

* Remove <Head>

* remove unused import and code

* remove unused import and code again

* fix

* fix category page

* add split icon

* add /routing paths to middleware matcher

* wip

* remove HeadSeo from App.tsx

* clean up head-seo test

* add generateAppMetadata

* use generateAppMetadata in apps/[slug] page

* delete file

* remove log

* fix

* fix

* fix apps/installed pages

* fix cateogires pages

* fix

* fix imports

* wip

* fix

* fix

* fix metadata

* fix

* redirect /apps/routing-forms to /routing

* replace all usages of /apps/routing-forms to /routing

* better naming

* /routing -> /routing/forms

* fix

* fix

* fix

* fix

* remove backPath as it is irrelevant when withoutMain is true

* fix type checks

* fix type check in apps/[slug]

* refactors

* fix

* fix test

* fix

* fix

* fix

* Replace multiple leading slashes with a single slash

* migrate routing-forms too

* add re routing

* fix

* add redirection

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>

* chore: app router 404 page (#18597)

* wip

* wip

* fix not found page

* render middleware for /settings pages

* fix

* remove global-error page

* add metadata to not-found page

* make not-found page static

* remove 404

* adding not-found to middleware is not necessary

* add every routes to config.matcher

* fix test

* fix style

* use i18n string

* fix tests

* fix

* fix

* revert unneeded changes

* fix

* fix

* fix

* fix style

* fix

* remove 404

* remove log

* fix

* fix

* fix

* fix

* better naming

* parallel testing

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>

* feat: render custom error page for unexpected sever error + remove` pages/_error` (#18606)

* remove page/_error

* refactor app/error

* fix: app/not-found cannot be a static page (#18610)

* Async False (#18611)

* chore: redirect to /500 if pathname does not exist + better error handling (#18615)

* fix lint error

* fix booking page and better error handling

* chore: gracefully handle 404s from pages router's dynamic pages + tests (#18618)

* restore pages/_error

* set custom header in pages/_error

* handle it in middleware

* add test

* remove logs

* better test description

* chore: try using custom 404 in pages/_error (#18622)

* fix: parsing teamId (#18623)

* chore: restore error pages for pages router (#18625)

* disable emails to all guests (#18628)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* revert: "feat: bulk shorten links with dub.links.createMany (#18539)" (#18587)

This reverts commit 4902c6a0f8.

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* chore: release v4.8.18

* fix: disable sending sms when email is present (#18632)

* fix: disable sending sms when email is present

* fix: update test

* fix: main lint errors (#18634)

* fix: error-page.tsx related lint errors

* fix: lint no continue-on-error

* Adding more lint fixes

* Bring back annotate code linting results

* Bring back linting continue-on-error

* Slimline lint

* feat: do not show automation webhooks in webhook list (#18607)

* fix: make ESLint work and fix lint errors that were undetected before (#18639)

* fix eslint config

* fix

* add it to dev dep

* fix

* sync eslint version

* force ts-node to compile our ESLint plugin's TS files into CommonJS (which ESLint requires)

* fix some lint errors

* fix lint errors

* remove duplicate classname

* make @typescript-eslint/ban-types a warn for packages/trpc files

* fix lint errors in trpc

* fix lint errors in trpc - 2

* fix

* fix

* fix lint warnings

* chore: clean up config.matcher in middleware (#18638)

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* Allow lint to error but continue (unblock pipeline)

* fix: potential fix for flaky layout shift (#18651)

* potential fix: layout shift

* fix lint error

* feat: update translations via @replexica (#18598)

* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>

* Update yarn.lock

* nit: let tasker handle payload stringification

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Nizzy <140507264+nizzyabi@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: GitHub Actions <github-actions@github.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Calcom Bot <109866826+calcom-bot@users.noreply.github.com>
Co-authored-by: Replexica <support@replexica.com>
This commit is contained in:
13 files changed
+547 -2

No files matched your search

@@ -387,7 +387,6 @@ describe("Organizations User OOO Endpoints", () => {
}
// test sort
expect(data[1].id).toEqual(oooCreatedViaApiId);
});
});
@@ -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}`);
+23 -1
View File
@@ -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);
@@ -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;
+1
View File
@@ -17,4 +17,5 @@ export type AppFlags = {
attributes: boolean;
"organizer-request-email-v2": boolean;
"domain-wide-delegation": boolean;
"salesforce-crm-tasker": boolean;
};
+1
View File
@@ -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) {
+1
View File
@@ -17,6 +17,7 @@ type TaskPayloads = {
translateEventTypeData: z.infer<
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
>;
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
};
export type TaskTypes = keyof TaskPayloads;
export type TaskHandler = (payload: string) => Promise<void>;
@@ -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: [],
});
});
});
@@ -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<void> {
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;
}
}
@@ -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<CalendarEvent> = 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;
@@ -0,0 +1,5 @@
import z from "zod";
export const createCRMEventSchema = z.object({
bookingUid: z.string(),
});
+1
View File
@@ -19,6 +19,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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;
@@ -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;