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
This commit is contained in:
Joe Au-Yeung
2024-12-18 15:02:42 +00:00
committed by GitHub
parent a7f24e777b
commit 8dda418127
27 changed files with 4951 additions and 36 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
import type { NextApiRequest } from "next";
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
export async function isLockedOrBlocked(req: NextApiRequest) {
const user = req.user;
if (!user?.email) return false;
return user.locked || (await checkIfEmailInWatchlistController(user.email));
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
}
@@ -2,6 +2,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import { WatchlistSeverity } from "@calcom/prisma/enums";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
describe("isLockedOrBlocked", () => {
@@ -16,6 +18,7 @@ describe("isLockedOrBlocked", () => {
{
type: "DOMAIN",
value: "blocked.com",
severity: WatchlistSeverity.CRITICAL,
createdById: 1,
},
],
@@ -2851,6 +2851,7 @@
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
"lookup_field_name": "Lookup Field Name",
"salesforce_if_free_email_domain_skip_owner_check": "If attendee has a free email domain, skip the ownership check and round robin as normal",
"filter_operator_is": "Is",
"filter_operator_is_not": "Is not",
"filter_operator_contains": "Contains",
@@ -2866,6 +2867,7 @@
"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.",
"rr_distribution_method_balanced_title": "Load balancing",
"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.",
"require_confirmation_for_free_email": "Only require confirmation for free email providers (Ex. @gmail.com, @outlook.com)",
"exclude_emails_that_contain": "Exclude emails that contain ...",
"require_emails_that_contain": "Require emails that contain ...",
"exclude_emails_match_found_error_message": "Please enter a valid work email address",
@@ -26,6 +26,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const isRoundRobinLeadSkipEnabled = getAppData("roundRobinLeadSkip");
const roundRobinSkipCheckRecordOn =
getAppData("roundRobinSkipCheckRecordOn") ?? SalesforceRecordEnum.CONTACT;
const ifFreeEmailDomainSkipOwnerCheck = getAppData("ifFreeEmailDomainSkipOwnerCheck") ?? false;
const isSkipContactCreationEnabled = getAppData("skipContactCreation");
const createLeadIfAccountNull = getAppData("createLeadIfAccountNull");
const createNewContactUnderAccount = getAppData("createNewContactUnderAccount");
@@ -497,22 +498,34 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
/>
{isRoundRobinLeadSkipEnabled ? (
<div className="my-4 ml-2">
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
{t("salesforce_check_owner_of")}
</label>
<Select
className="mt-2 w-60"
options={checkOwnerOptions}
value={checkOwnerSelectedOption}
onChange={(e) => {
if (e) {
setCheckOwnerSelectedOption(e);
setAppData("roundRobinSkipCheckRecordOn", e.value);
}
}}
/>
</div>
<>
<div className="my-4 ml-2">
<label className="text-emphasis mb-2 align-text-top text-sm font-medium">
{t("salesforce_check_owner_of")}
</label>
<Select
className="mt-2 w-60"
options={checkOwnerOptions}
value={checkOwnerSelectedOption}
onChange={(e) => {
if (e) {
setCheckOwnerSelectedOption(e);
setAppData("roundRobinSkipCheckRecordOn", e.value);
}
}}
/>
</div>
<div className="my-4">
<Switch
label={t("salesforce_if_free_email_domain_skip_owner_check")}
labelOnLeading
checked={ifFreeEmailDomainSkipOwnerCheck}
onCheckedChange={(checked) => {
setAppData("ifFreeEmailDomainSkipOwnerCheck", checked);
}}
/>
</div>
</>
) : null}
<Alert className="mt-2" severity="neutral" title={t("skip_rr_description")} />
</div>
@@ -6,6 +6,7 @@ import { z } from "zod";
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
@@ -431,7 +432,10 @@ export default class SalesforceCRMService implements CRM {
}
// Handle owner information
if (includeOwner || forRoundRobinSkip) {
if (
(includeOwner || forRoundRobinSkip) &&
!(await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]))
) {
const ownerIds: Set<string> = new Set();
if (accountOwnerId) {
@@ -1242,4 +1246,12 @@ export default class SalesforceCRMService implements CRM {
if (companyValue === onBookingWriteToRecordFields[companyFieldName]) return;
return companyValue;
}
private async shouldSkipAttendeeIfFreeEmailDomain(attendeeEmail: string) {
const appOptions = this.getAppOptions();
if (!appOptions.ifFreeEmailDomainSkipOwnerCheck) return false;
const response = await checkIfFreeEmailDomain(attendeeEmail);
return response;
}
}
+1
View File
@@ -22,6 +22,7 @@ export const appDataSchema = eventTypeAppCardZod.extend({
.nativeEnum(SalesforceRecordEnum)
.default(SalesforceRecordEnum.CONTACT)
.optional(),
ifFreeEmailDomainSkipOwnerCheck: z.boolean().optional(),
skipContactCreation: z.boolean().optional(),
createEventOn: z.nativeEnum(SalesforceRecordEnum).default(SalesforceRecordEnum.CONTACT).optional(),
createNewContactUnderAccount: z.boolean().optional(),
@@ -6,7 +6,7 @@ import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
@@ -39,7 +39,7 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
})
.parse(req.body);
const shouldLockByDefault = await checkIfEmailInWatchlistController(_email);
const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(_email);
log.debug("handler", { email: _email });
@@ -759,12 +759,13 @@ async function handler(
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
const allCredentials = await getAllCredentials(organizerUser, eventType);
const { userReschedulingIsOwner, isConfirmedByDefault } = getRequiresConfirmationFlags({
const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({
eventType,
bookingStartTime: reqBody.start,
userId,
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
paymentAppData,
bookerEmail,
});
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.
@@ -56,6 +56,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
periodCountCalendarDays: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresConfirmationForFreeEmail: true,
requiresBookerEmailVerification: true,
maxLeadThreshold: true,
minimumBookingNotice: true,
@@ -1,24 +1,30 @@
import dayjs from "@calcom/dayjs";
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import type { getEventTypeResponse } from "./getEventTypesFromDB";
type EventType = Pick<getEventTypeResponse, "metadata" | "requiresConfirmation">;
type EventType = Pick<
getEventTypeResponse,
"metadata" | "requiresConfirmation" | "requiresConfirmationForFreeEmail"
>;
type PaymentAppData = { price: number };
export function getRequiresConfirmationFlags({
export async function getRequiresConfirmationFlags({
eventType,
bookingStartTime,
userId,
paymentAppData,
originalRescheduledBookingOrganizerId,
bookerEmail,
}: {
eventType: EventType;
bookingStartTime: string;
userId: number | undefined;
paymentAppData: PaymentAppData;
originalRescheduledBookingOrganizerId: number | undefined;
bookerEmail: string;
}) {
const requiresConfirmation = determineRequiresConfirmation(eventType, bookingStartTime);
const requiresConfirmation = await determineRequiresConfirmation(eventType, bookingStartTime, bookerEmail);
const userReschedulingIsOwner = isUserReschedulingOwner(userId, originalRescheduledBookingOrganizerId);
const isConfirmedByDefault = determineIsConfirmedByDefault(
requiresConfirmation,
@@ -38,9 +44,18 @@ export function getRequiresConfirmationFlags({
};
}
function determineRequiresConfirmation(eventType: EventType, bookingStartTime: string): boolean {
async function determineRequiresConfirmation(
eventType: EventType,
bookingStartTime: string,
bookerEmail: string
): Promise<boolean> {
let requiresConfirmation = eventType?.requiresConfirmation;
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
const requiresConfirmationForFreeEmail = eventType?.requiresConfirmationForFreeEmail;
if (requiresConfirmationForFreeEmail) {
requiresConfirmation = await checkIfFreeEmailDomain(bookerEmail);
}
if (rcThreshold) {
const timeDifference = dayjs(dayjs(bookingStartTime).utc().format()).diff(dayjs(), rcThreshold.unit);
@@ -72,6 +72,7 @@ export default function RequiresConfirmationController({
);
const requiresConfirmationWillBlockSlot = formMethods.getValues("requiresConfirmationWillBlockSlot");
const requiresConfirmationForFreeEmail = formMethods.getValues("requiresConfirmationForFreeEmail");
return (
<div className="block items-start sm:flex">
@@ -241,6 +242,21 @@ export default function RequiresConfirmationController({
});
}}
/>
<CheckboxField
checked={requiresConfirmationForFreeEmail}
descriptionAsLabel
description={t("require_confirmation_for_free_email")}
className={customClassNames?.conditionalConfirmationRadio?.checkbox}
descriptionClassName={
customClassNames?.conditionalConfirmationRadio?.checkboxDescription
}
onChange={(e) => {
// We set should dirty to properly detect when we can submit the form
formMethods.setValue("requiresConfirmationForFreeEmail", e.target.checked, {
shouldDirty: true,
});
}}
/>
</>
)}
</div>
@@ -81,6 +81,7 @@ export type FormValues = {
lockTimeZoneToggleOnBookingPage: boolean;
requiresConfirmation: boolean;
requiresConfirmationWillBlockSlot: boolean;
requiresConfirmationForFreeEmail: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent: RecurringEvent | null;
schedulingType: SchedulingType | null;
@@ -21,13 +21,13 @@ function presenter(watchlistedEmail: Watchlist | null) {
* to the specific use cases. Controllers orchestrate Use Cases. They don't implement any
* logic, but define the whole operations using use cases.
*/
export async function checkIfEmailInWatchlistController(
export async function checkIfEmailIsBlockedInWatchlistController(
email: string
): Promise<ReturnType<typeof presenter>> {
return await startSpan({ name: "checkIfEmailInWatchlist Controller" }, async () => {
const lowercasedEmail = email.toLowerCase();
const watchlistRepository = new WatchlistRepository();
const watchlistedEmail = await watchlistRepository.getEmailInWatchlist(lowercasedEmail);
const watchlistedEmail = await watchlistRepository.getBlockedEmailInWatchlist(lowercasedEmail);
return presenter(watchlistedEmail);
});
}
@@ -1,5 +1,5 @@
import type { Watchlist } from "./watchlist.model";
export interface IWatchlistRepository {
getEmailInWatchlist(email: string): Promise<Watchlist | null>;
getBlockedEmailInWatchlist(email: string): Promise<Watchlist | null>;
}
@@ -1,16 +1,17 @@
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import { WatchlistType } from "@calcom/prisma/enums";
import { WatchlistType, WatchlistSeverity } from "@calcom/prisma/enums";
import type { IWatchlistRepository } from "./watchlist.repository.interface";
export class WatchlistRepository implements IWatchlistRepository {
async getEmailInWatchlist(email: string) {
async getBlockedEmailInWatchlist(email: string) {
const [, domain] = email.split("@");
try {
const emailInWatchlist = await db.watchlist.findFirst({
where: {
severity: WatchlistSeverity.CRITICAL,
OR: [
{ type: WatchlistType.EMAIL, value: email },
{ type: WatchlistType.DOMAIN, value: domain },
@@ -23,4 +24,19 @@ export class WatchlistRepository implements IWatchlistRepository {
throw err;
}
}
async getFreeEmailDomainInWatchlist(emailDomain: string) {
try {
const domainInWatchWatchlist = await db.watchlist.findFirst({
where: {
type: WatchlistType.DOMAIN,
value: emailDomain,
},
});
return domainInWatchWatchlist;
} catch (err) {
captureException(err);
throw err;
}
}
}
+1
View File
@@ -91,6 +91,7 @@ const commons = {
team: null,
lockTimeZoneToggleOnBookingPage: false,
requiresConfirmation: false,
requiresConfirmationForFreeEmail: false,
requiresBookerEmailVerification: false,
bookingLimits: null,
durationLimits: null,
@@ -0,0 +1,28 @@
import { describe, expect, test, vi } from "vitest";
import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository";
import { checkIfFreeEmailDomain } from "./checkIfFreeEmailDomain";
describe("checkIfFreeEmailDomain", () => {
test("If gmail should return true", async () => {
expect(await checkIfFreeEmailDomain("test@gmail.com")).toBe(true);
});
test("If outlook should return true", async () => {
expect(await checkIfFreeEmailDomain("test@outlook.com")).toBe(true);
});
test("If work email, should return false", async () => {
const spy = vi.spyOn(WatchlistRepository.prototype, "getFreeEmailDomainInWatchlist");
spy.mockImplementation(() => {
return null;
});
expect(await checkIfFreeEmailDomain("test@cal.com")).toBe(false);
});
test("If free email domain in watchlist, should return true", async () => {
const spy = vi.spyOn(WatchlistRepository.prototype, "getFreeEmailDomainInWatchlist");
spy.mockImplementation(() => {
return { value: "freedomain.com" };
});
expect(await checkIfFreeEmailDomain("test@freedomain.com")).toBe(true);
});
});
@@ -0,0 +1,14 @@
import { WatchlistRepository } from "@calcom/features/watchlist/watchlist.repository";
export const checkIfFreeEmailDomain = async (email: string) => {
const emailDomain = email.split("@")[1].toLowerCase();
// If there's no email domain return as if it was a free email domain
if (!emailDomain) return true;
// Gmail and Outlook are one of the most common email domains so we don't need to check the domains list
if (emailDomain === "gmail.com" || emailDomain === "outlook.com") return true;
// Check if email domain is in the watchlist
const watchlistRepository = new WatchlistRepository();
return !!(await watchlistRepository.getFreeEmailDomainInWatchlist(emailDomain));
};
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
length: true,
title: true,
requiresConfirmation: true,
requiresConfirmationForFreeEmail: true,
requiresConfirmationWillBlockSlot: true,
autoTranslateDescriptionEnabled: true,
position: true,
@@ -444,6 +444,7 @@ export class EventTypeRepository {
periodCountCalendarDays: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresConfirmationForFreeEmail: true,
requiresConfirmationWillBlockSlot: true,
requiresBookerEmailVerification: true,
autoTranslateDescriptionEnabled: true,
+1
View File
@@ -109,6 +109,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
recurringEvent: null,
lockTimeZoneToggleOnBookingPage: false,
requiresConfirmation: false,
requiresConfirmationForFreeEmail: false,
requiresConfirmationWillBlockSlot: false,
disableGuests: false,
hideCalendarNotes: false,
@@ -88,6 +88,7 @@ export const useEventTypeForm = ({
schedulingType: eventType.schedulingType,
requiresConfirmation: eventType.requiresConfirmation,
requiresConfirmationWillBlockSlot: eventType.requiresConfirmationWillBlockSlot,
requiresConfirmationForFreeEmail: eventType.requiresConfirmationForFreeEmail,
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata: eventType.metadata,
@@ -188,6 +189,7 @@ export const useEventTypeForm = ({
};
const getDirtyFields = (values: FormValues): Partial<FormValues> => {
console.log("🚀 ~ getDirtyFields ~ values:", values);
if (!isFormDirty) {
return {};
}
@@ -368,6 +370,7 @@ export const useEventTypeForm = ({
}, {}) as EventTypeUpdateInput;
if (dirtyFieldExists) {
console.log("🚀 ~ handleSubmit ~ filteredPayload:", filteredPayload);
onSubmit({ ...filteredPayload, id: eventType.id });
}
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "requiresConfirmationForFreeEmail" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "WatchlistSeverity" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL');
-- AlterTable
ALTER TABLE "Watchlist" ADD COLUMN "severity" "WatchlistSeverity" NOT NULL DEFAULT 'LOW';
+14 -5
View File
@@ -109,6 +109,7 @@ model EventType {
lockTimeZoneToggleOnBookingPage Boolean @default(false)
requiresConfirmation Boolean @default(false)
requiresConfirmationWillBlockSlot Boolean @default(false)
requiresConfirmationForFreeEmail Boolean @default(false)
requiresBookerEmailVerification Boolean @default(false)
autoTranslateDescriptionEnabled Boolean @default(false)
/// @zod.custom(imports.recurringEventType)
@@ -1738,18 +1739,26 @@ enum WatchlistType {
USERNAME
}
enum WatchlistSeverity {
LOW
MEDIUM
HIGH
CRITICAL
}
model Watchlist {
id String @id @unique @default(cuid())
id String @id @unique @default(cuid())
type WatchlistType
// The identifier of the Watchlisted entity (email or domain)
value String
description String?
createdAt DateTime @default(now())
createdBy User @relation("CreatedWatchlists", onDelete: Cascade, fields: [createdById], references: [id])
createdAt DateTime @default(now())
createdBy User @relation("CreatedWatchlists", onDelete: Cascade, fields: [createdById], references: [id])
createdById Int
updatedAt DateTime @updatedAt
updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id])
updatedAt DateTime @updatedAt
updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id])
updatedById Int?
severity WatchlistSeverity @default(LOW)
@@unique([type, value])
@@index([type, value])
+1
View File
@@ -691,6 +691,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
customInputs: true,
disableGuests: true,
requiresConfirmation: true,
requiresConfirmationForFreeEmail: true,
requiresConfirmationWillBlockSlot: true,
eventName: true,
metadata: true,