Skip to content

Commit

Permalink
perf: Move CRM event creation to tasker (#18370)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* detailed customer card (#18511)

Co-authored-by: Omar López <[email protected]>

* 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 <[email protected]>
Co-authored-by: Anik Dhabal Babu <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

This reverts commit 4902c6a.

Co-authored-by: Alex van Andel <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* Update yarn.lock

* nit: let tasker handle payload stringification

---------

Co-authored-by: Udit Takkar <[email protected]>
Co-authored-by: Alex van Andel <[email protected]>
Co-authored-by: Morgan <[email protected]>
Co-authored-by: Morgan Vernay <[email protected]>
Co-authored-by: Omar López <[email protected]>
Co-authored-by: sean-brydon <[email protected]>
Co-authored-by: Nizzy <[email protected]>
Co-authored-by: Benny Joo <[email protected]>
Co-authored-by: Peer Richelsen <[email protected]>
Co-authored-by: Anik Dhabal Babu <[email protected]>
Co-authored-by: Carina Wollendorfer <[email protected]>
Co-authored-by: CarinaWolli <[email protected]>
Co-authored-by: Keith Williams <[email protected]>
Co-authored-by: GitHub Actions <[email protected]>
Co-authored-by: Kartik Saini <[email protected]>
Co-authored-by: Calcom Bot <[email protected]>
Co-authored-by: Replexica <[email protected]>
  • Loading branch information
18 people authored Jan 17, 2025
1 parent 9e5665f commit 6561b92
Show file tree
Hide file tree
Showing 13 changed files with 547 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,6 @@ describe("Organizations User OOO Endpoints", () => {
}
// test sort
expect(data[1].id).toEqual(oooCreatedViaApiId);

});
});

Expand Down
5 changes: 5 additions & 0 deletions packages/app-store/salesforce/lib/CrmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
24 changes: 23 additions & 1 deletion packages/core/EventManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions packages/core/crmManager/tasker/crmScheduler.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/features/flags/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export type AppFlags = {
attributes: boolean;
"organizer-request-email-v2": boolean;
"domain-wide-delegation": boolean;
"salesforce-crm-tasker": boolean;
};
1 change: 1 addition & 0 deletions packages/features/flags/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/features/tasker/tasker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
258 changes: 258 additions & 0 deletions packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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: [],
});
});
});
Loading

0 comments on commit 6561b92

Please sign in to comment.