Skip to content

Commit 8dda418

Browse files
authored
feat: Salesforce skip ownership check if attendee email is a free domain (#17916)
* Add email domain array * Create numbered email domain object * Check email domain * Rename function * Add tests * Frontend enable skip ownership check if free email domain * Backend ignore adding ownership to return records if free email domain check is enabled * feat: Only require confirmation for free email domains (#17917) * Add requiresConfirmationForFreeEmail to db * Add option to event type settings * Get requiresConfirmationForFreeEmail for event type page * Include requiresConfirmationForFreeEmail in fetching event type * Pass bookerEmail to `getRequiresConfirmationFlags` * Add free email domain check to `determineRequiresConfirmation` * Add `requiresConfirmationForFreeEmail` to types * Add severity to Watchlist table * Add migration for watchlist severity * Add `getEmailDomainInWatchlist` method to watchlist repository * Use watchlist repository to check for free email domain * Mock watchlist repository in test * Update test * Rename method * Add severity to blocked list * Move check free email domain to async * Type checks * Adjust for promise returned * Fix tests * Fix * Fix tests
1 parent a7f24e7 commit 8dda418

File tree

27 files changed

+4951
-36
lines changed

27 files changed

+4951
-36
lines changed
+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { NextApiRequest } from "next";
22

3-
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
3+
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
44

55
export async function isLockedOrBlocked(req: NextApiRequest) {
66
const user = req.user;
77
if (!user?.email) return false;
8-
return user.locked || (await checkIfEmailInWatchlistController(user.email));
8+
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
99
}

apps/api/v1/test/lib/utils/isLockedOrBlocked.test.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
22

33
import { describe, expect, it, beforeEach } from "vitest";
44

5+
import { WatchlistSeverity } from "@calcom/prisma/enums";
6+
57
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
68

79
describe("isLockedOrBlocked", () => {
@@ -16,6 +18,7 @@ describe("isLockedOrBlocked", () => {
1618
{
1719
type: "DOMAIN",
1820
value: "blocked.com",
21+
severity: WatchlistSeverity.CRITICAL,
1922
createdById: 1,
2023
},
2124
],

apps/web/public/static/locales/en/common.json

+2
Original file line numberDiff line numberDiff line change
@@ -2851,6 +2851,7 @@
28512851
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
28522852
"salesforce_option": "Salesforce Option",
28532853
"lookup_field_name": "Lookup Field Name",
2854+
"salesforce_if_free_email_domain_skip_owner_check": "If attendee has a free email domain, skip the ownership check and round robin as normal",
28542855
"filter_operator_is": "Is",
28552856
"filter_operator_is_not": "Is not",
28562857
"filter_operator_contains": "Contains",
@@ -2866,6 +2867,7 @@
28662867
"rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.",
28672868
"rr_distribution_method_balanced_title": "Load balancing",
28682869
"rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.",
2870+
"require_confirmation_for_free_email": "Only require confirmation for free email providers (Ex. @gmail.com, @outlook.com)",
28692871
"exclude_emails_that_contain": "Exclude emails that contain ...",
28702872
"require_emails_that_contain": "Require emails that contain ...",
28712873
"exclude_emails_match_found_error_message": "Please enter a valid work email address",

packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx

+29-16
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
2626
const isRoundRobinLeadSkipEnabled = getAppData("roundRobinLeadSkip");
2727
const roundRobinSkipCheckRecordOn =
2828
getAppData("roundRobinSkipCheckRecordOn") ?? SalesforceRecordEnum.CONTACT;
29+
const ifFreeEmailDomainSkipOwnerCheck = getAppData("ifFreeEmailDomainSkipOwnerCheck") ?? false;
2930
const isSkipContactCreationEnabled = getAppData("skipContactCreation");
3031
const createLeadIfAccountNull = getAppData("createLeadIfAccountNull");
3132
const createNewContactUnderAccount = getAppData("createNewContactUnderAccount");
@@ -497,22 +498,34 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
497498
}}
498499
/>
499500
{isRoundRobinLeadSkipEnabled ? (
500-
<div className="my-4 ml-2">
501-
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
502-
{t("salesforce_check_owner_of")}
503-
</label>
504-
<Select
505-
className="mt-2 w-60"
506-
options={checkOwnerOptions}
507-
value={checkOwnerSelectedOption}
508-
onChange={(e) => {
509-
if (e) {
510-
setCheckOwnerSelectedOption(e);
511-
setAppData("roundRobinSkipCheckRecordOn", e.value);
512-
}
513-
}}
514-
/>
515-
</div>
501+
<>
502+
<div className="my-4 ml-2">
503+
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
504+
{t("salesforce_check_owner_of")}
505+
</label>
506+
<Select
507+
className="mt-2 w-60"
508+
options={checkOwnerOptions}
509+
value={checkOwnerSelectedOption}
510+
onChange={(e) => {
511+
if (e) {
512+
setCheckOwnerSelectedOption(e);
513+
setAppData("roundRobinSkipCheckRecordOn", e.value);
514+
}
515+
}}
516+
/>
517+
</div>
518+
<div className="my-4">
519+
<Switch
520+
label={t("salesforce_if_free_email_domain_skip_owner_check")}
521+
labelOnLeading
522+
checked={ifFreeEmailDomainSkipOwnerCheck}
523+
onCheckedChange={(checked) => {
524+
setAppData("ifFreeEmailDomainSkipOwnerCheck", checked);
525+
}}
526+
/>
527+
</div>
528+
</>
516529
) : null}
517530
<Alert className="mt-2" severity="neutral" title={t("skip_rr_description")} />
518531
</div>

packages/app-store/salesforce/lib/CrmService.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from "zod";
66
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
77
import { getLocation } from "@calcom/lib/CalEventParser";
88
import { WEBAPP_URL } from "@calcom/lib/constants";
9+
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
910
import logger from "@calcom/lib/logger";
1011
import { safeStringify } from "@calcom/lib/safeStringify";
1112
import { prisma } from "@calcom/prisma";
@@ -431,7 +432,10 @@ export default class SalesforceCRMService implements CRM {
431432
}
432433

433434
// Handle owner information
434-
if (includeOwner || forRoundRobinSkip) {
435+
if (
436+
(includeOwner || forRoundRobinSkip) &&
437+
!(await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]))
438+
) {
435439
const ownerIds: Set<string> = new Set();
436440

437441
if (accountOwnerId) {
@@ -1242,4 +1246,12 @@ export default class SalesforceCRMService implements CRM {
12421246
if (companyValue === onBookingWriteToRecordFields[companyFieldName]) return;
12431247
return companyValue;
12441248
}
1249+
1250+
private async shouldSkipAttendeeIfFreeEmailDomain(attendeeEmail: string) {
1251+
const appOptions = this.getAppOptions();
1252+
if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false;
1253+
1254+
const response = await checkIfFreeEmailDomain(attendeeEmail);
1255+
return response;
1256+
}
12451257
}

packages/app-store/salesforce/zod.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const appDataSchema = eventTypeAppCardZod.extend({
2222
.nativeEnum(SalesforceRecordEnum)
2323
.default(SalesforceRecordEnum.CONTACT)
2424
.optional(),
25+
ifFreeEmailDomainSkipOwnerCheck: z.boolean().optional(),
2526
skipContactCreation: z.boolean().optional(),
2627
createEventOn: z.nativeEnum(SalesforceRecordEnum).default(SalesforceRecordEnum.CONTACT).optional(),
2728
createNewContactUnderAccount: z.boolean().optional(),

packages/features/auth/signup/handlers/calcomHandler.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
66
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
77
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
88
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
9-
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
9+
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
1010
import { WEBAPP_URL } from "@calcom/lib/constants";
1111
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
1212
import { HttpError } from "@calcom/lib/http-error";
@@ -39,7 +39,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
3939
})
4040
.parse(req.body);
4141

42-
const shouldLockByDefault = await checkIfEmailInWatchlistController(_email);
42+
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(_email);
4343

4444
log.debug("handler", { email: _email });
4545

packages/features/bookings/lib/handleNewBooking.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -759,12 +759,13 @@ async function handler(
759759
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
760760
const allCredentials = await getAllCredentials(organizerUser, eventType);
761761

762-
const { userReschedulingIsOwner, isConfirmedByDefault } = getRequiresConfirmationFlags({
762+
const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({
763763
eventType,
764764
bookingStartTime: reqBody.start,
765765
userId,
766766
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
767767
paymentAppData,
768+
bookerEmail,
768769
});
769770

770771
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.

packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
5656
periodCountCalendarDays: true,
5757
lockTimeZoneToggleOnBookingPage: true,
5858
requiresConfirmation: true,
59+
requiresConfirmationForFreeEmail: true,
5960
requiresBookerEmailVerification: true,
6061
maxLeadThreshold: true,
6162
minimumBookingNotice: true,

packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
import dayjs from "@calcom/dayjs";
2+
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
23

34
import type { getEventTypeResponse } from "./getEventTypesFromDB";
45

5-
type EventType = Pick<getEventTypeResponse, "metadata" | "requiresConfirmation">;
6+
type EventType = Pick<
7+
getEventTypeResponse,
8+
"metadata" | "requiresConfirmation" | "requiresConfirmationForFreeEmail"
9+
>;
610
type PaymentAppData = { price: number };
711

8-
export function getRequiresConfirmationFlags({
12+
export async function getRequiresConfirmationFlags({
913
eventType,
1014
bookingStartTime,
1115
userId,
1216
paymentAppData,
1317
originalRescheduledBookingOrganizerId,
18+
bookerEmail,
1419
}: {
1520
eventType: EventType;
1621
bookingStartTime: string;
1722
userId: number | undefined;
1823
paymentAppData: PaymentAppData;
1924
originalRescheduledBookingOrganizerId: number | undefined;
25+
bookerEmail: string;
2026
}) {
21-
const requiresConfirmation = determineRequiresConfirmation(eventType, bookingStartTime);
27+
const requiresConfirmation = await determineRequiresConfirmation(eventType, bookingStartTime, bookerEmail);
2228
const userReschedulingIsOwner = isUserReschedulingOwner(userId, originalRescheduledBookingOrganizerId);
2329
const isConfirmedByDefault = determineIsConfirmedByDefault(
2430
requiresConfirmation,
@@ -38,9 +44,18 @@ export function getRequiresConfirmationFlags({
3844
};
3945
}
4046

41-
function determineRequiresConfirmation(eventType: EventType, bookingStartTime: string): boolean {
47+
async function determineRequiresConfirmation(
48+
eventType: EventType,
49+
bookingStartTime: string,
50+
bookerEmail: string
51+
): Promise<boolean> {
4252
let requiresConfirmation = eventType?.requiresConfirmation;
4353
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
54+
const requiresConfirmationForFreeEmail = eventType?.requiresConfirmationForFreeEmail;
55+
56+
if (requiresConfirmationForFreeEmail) {
57+
requiresConfirmation = await checkIfFreeEmailDomain(bookerEmail);
58+
}
4459

4560
if (rcThreshold) {
4661
const timeDifference = dayjs(dayjs(bookingStartTime).utc().format()).diff(dayjs(), rcThreshold.unit);

packages/features/eventtypes/components/tabs/advanced/RequiresConfirmationController.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export default function RequiresConfirmationController({
7272
);
7373

7474
const requiresConfirmationWillBlockSlot = formMethods.getValues("requiresConfirmationWillBlockSlot");
75+
const requiresConfirmationForFreeEmail = formMethods.getValues("requiresConfirmationForFreeEmail");
7576

7677
return (
7778
<div className="block items-start sm:flex">
@@ -241,6 +242,21 @@ export default function RequiresConfirmationController({
241242
});
242243
}}
243244
/>
245+
<CheckboxField
246+
checked={requiresConfirmationForFreeEmail}
247+
descriptionAsLabel
248+
description={t("require_confirmation_for_free_email")}
249+
className={customClassNames?.conditionalConfirmationRadio?.checkbox}
250+
descriptionClassName={
251+
customClassNames?.conditionalConfirmationRadio?.checkboxDescription
252+
}
253+
onChange={(e) => {
254+
// We set should dirty to properly detect when we can submit the form
255+
formMethods.setValue("requiresConfirmationForFreeEmail", e.target.checked, {
256+
shouldDirty: true,
257+
});
258+
}}
259+
/>
244260
</>
245261
)}
246262
</div>

packages/features/eventtypes/lib/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export type FormValues = {
8181
lockTimeZoneToggleOnBookingPage: boolean;
8282
requiresConfirmation: boolean;
8383
requiresConfirmationWillBlockSlot: boolean;
84+
requiresConfirmationForFreeEmail: boolean;
8485
requiresBookerEmailVerification: boolean;
8586
recurringEvent: RecurringEvent | null;
8687
schedulingType: SchedulingType | null;

packages/features/watchlist/operations/check-if-email-in-watchlist.controller.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ function presenter(watchlistedEmail: Watchlist | null) {
2121
* to the specific use cases. Controllers orchestrate Use Cases. They don't implement any
2222
* logic, but define the whole operations using use cases.
2323
*/
24-
export async function checkIfEmailInWatchlistController(
24+
export async function checkIfEmailIsBlockedInWatchlistController(
2525
email: string
2626
): Promise<ReturnType<typeof presenter>> {
2727
return await startSpan({ name: "checkIfEmailInWatchlist Controller" }, async () => {
2828
const lowercasedEmail = email.toLowerCase();
2929
const watchlistRepository = new WatchlistRepository();
30-
const watchlistedEmail = await watchlistRepository.getEmailInWatchlist(lowercasedEmail);
30+
const watchlistedEmail = await watchlistRepository.getBlockedEmailInWatchlist(lowercasedEmail);
3131
return presenter(watchlistedEmail);
3232
});
3333
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Watchlist } from "./watchlist.model";
22

33
export interface IWatchlistRepository {
4-
getEmailInWatchlist(email: string): Promise<Watchlist | null>;
4+
getBlockedEmailInWatchlist(email: string): Promise<Watchlist | null>;
55
}

packages/features/watchlist/watchlist.repository.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { captureException } from "@sentry/nextjs";
22

33
import db from "@calcom/prisma";
4-
import { WatchlistType } from "@calcom/prisma/enums";
4+
import { WatchlistType, WatchlistSeverity } from "@calcom/prisma/enums";
55

66
import type { IWatchlistRepository } from "./watchlist.repository.interface";
77

88
export class WatchlistRepository implements IWatchlistRepository {
9-
async getEmailInWatchlist(email: string) {
9+
async getBlockedEmailInWatchlist(email: string) {
1010
const [, domain] = email.split("@");
1111
try {
1212
const emailInWatchlist = await db.watchlist.findFirst({
1313
where: {
14+
severity: WatchlistSeverity.CRITICAL,
1415
OR: [
1516
{ type: WatchlistType.EMAIL, value: email },
1617
{ type: WatchlistType.DOMAIN, value: domain },
@@ -23,4 +24,19 @@ export class WatchlistRepository implements IWatchlistRepository {
2324
throw err;
2425
}
2526
}
27+
28+
async getFreeEmailDomainInWatchlist(emailDomain: string) {
29+
try {
30+
const domainInWatchWatchlist = await db.watchlist.findFirst({
31+
where: {
32+
type: WatchlistType.DOMAIN,
33+
value: emailDomain,
34+
},
35+
});
36+
return domainInWatchWatchlist;
37+
} catch (err) {
38+
captureException(err);
39+
throw err;
40+
}
41+
}
2642
}

packages/lib/defaultEvents.ts

+1
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const commons = {
9191
team: null,
9292
lockTimeZoneToggleOnBookingPage: false,
9393
requiresConfirmation: false,
94+
requiresConfirmationForFreeEmail: false,
9495
requiresBookerEmailVerification: false,
9596
bookingLimits: null,
9697
durationLimits: null,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, test, vi } from "vitest";
2+
3+
import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository";
4+
5+
import { checkIfFreeEmailDomain } from "./checkIfFreeEmailDomain";
6+
7+
describe("checkIfFreeEmailDomain", () => {
8+
test("If gmail should return true", async () => {
9+
expect(await checkIfFreeEmailDomain("test@gmail.com")).toBe(true);
10+
});
11+
test("If outlook should return true", async () => {
12+
expect(await checkIfFreeEmailDomain("test@outlook.com")).toBe(true);
13+
});
14+
test("If work email, should return false", async () => {
15+
const spy = vi.spyOn(WatchlistRepository.prototype, "getFreeEmailDomainInWatchlist");
16+
spy.mockImplementation(() => {
17+
return null;
18+
});
19+
expect(await checkIfFreeEmailDomain("test@cal.com")).toBe(false);
20+
});
21+
test("If free email domain in watchlist, should return true", async () => {
22+
const spy = vi.spyOn(WatchlistRepository.prototype, "getFreeEmailDomainInWatchlist");
23+
spy.mockImplementation(() => {
24+
return { value: "freedomain.com" };
25+
});
26+
expect(await checkIfFreeEmailDomain("test@freedomain.com")).toBe(true);
27+
});
28+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository";
2+
3+
export const checkIfFreeEmailDomain = async (email: string) => {
4+
const emailDomain = email.split("@")[1].toLowerCase();
5+
// If there's no email domain return as if it was a free email domain
6+
if (!emailDomain) return true;
7+
8+
// Gmail and Outlook are one of the most common email domains so we don't need to check the domains list
9+
if (emailDomain === "gmail.com" || emailDomain === "outlook.com") return true;
10+
11+
// Check if email domain is in the watchlist
12+
const watchlistRepository = new WatchlistRepository();
13+
return !!(await watchlistRepository.getFreeEmailDomainInWatchlist(emailDomain));
14+
};

0 commit comments

Comments
 (0)