Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions packages/features/bookings/lib/getBookingResponsesSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,126 @@ describe("excluded email/domain validation", () => {
email: "blocked.com@allowed.com",
});
});

test("should block full email match when exact email is excluded", async () => {
const excludedEmails = "anik@cal.com";

const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});

// anik@cal.com should be blocked
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "anik@cal.com",
});

expect(parsedResponses.success).toBe(false);

if (parsedResponses.success) {
throw new Error("Should not reach here");
}

expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});

test("should block emails with domain when domain starts with @", async () => {
const excludedEmails = "@gmail.com";

const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});

// anik@gmail.com should be blocked when @gmail.com is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "anik@gmail.com",
});

expect(parsedResponses.success).toBe(false);

if (parsedResponses.success) {
throw new Error("Should not reach here");
}

expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});

test("should block emails with domain when excluded email is just the domain without @", async () => {
const excludedEmails = "gmail.com";

const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});

// test@gmail.com should be blocked when gmail.com is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "test@gmail.com",
});

expect(parsedResponses.success).toBe(false);

if (parsedResponses.success) {
throw new Error("Should not reach here");
}

expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
});

describe("require email/domain validation", () => {
Expand Down
28 changes: 26 additions & 2 deletions packages/features/bookings/lib/getBookingResponsesSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ const ensureValidPhoneNumber = (value: string) => {
// Replace the space(s) in the beginning with + as it is supposed to be provided in the beginning only
return value.replace(/^ +/, "+");
};

/**
* Checks if a booker email matches an email/domain entry.
* Supports three formats:
* - Full email: "user@example.com" - matches exactly
* - Domain with @ prefix: "@example.com" - matches any email ending with "@example.com"
* - Domain without @ prefix: "example.com" - matches any email ending with "@example.com"
*/
const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => {
const bookerEmailLower = bookerEmail.toLowerCase();

if (entry.startsWith("@")) {
const domain = entry.slice(1).toLowerCase();
return bookerEmailLower.endsWith("@" + domain);
}

if (entry.includes("@")) {
return bookerEmailLower === entry.toLowerCase();
}

return bookerEmailLower.endsWith("@" + entry.toLowerCase());
};
export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => {
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);

Expand Down Expand Up @@ -208,7 +230,7 @@ function preprocess<T extends z.ZodType>({
const excludedEmails =
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];

const match = excludedEmails.find((email) => bookerEmail.endsWith("@" + email));
const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry));
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
Expand All @@ -220,7 +242,9 @@ function preprocess<T extends z.ZodType>({
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean) || [];
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.endsWith("@" + email));
const requiredEmailsMatch = requiredEmails.find((requiredEntry) =>
doesEmailMatchEntry(bookerEmail, requiredEntry)
);
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
Expand Down
Loading