fix: use graceful filtering for previously hard failing blocked users in team events (#26446)

* init

* typefix

* --

* fix test

* update index

* cleanup

* rm unnecessary comments

* improvements

* add tests

* test fixes

* fixes

* fixes

* fixes

* add try catch to make booking fail-open

* Update packages/features/watchlist/operations/check-user-blocking.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix type

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Syed Ali Shahbaz
2026-01-08 08:30:54 -03:00
committed by GitHub
co-authored by cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 27623926fc
commit 59fca85d92
20 changed files with 1573 additions and 37 deletions
@@ -76,6 +76,13 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/features/watchlist/operations/filter-blocked-users.controller", () => ({
filterBlockedUsers: vi.fn().mockImplementation(async (users) => ({
eligibleUsers: users,
blockedCount: 0,
})),
}));
vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({
getQualifiedHostsService: vi.fn().mockReturnValue({
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
@@ -1,22 +1,21 @@
import type { Logger } from "tslog";
import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential";
import type { RoutingFormResponse } from "@calcom/features/bookings/lib/getLuckyUser";
import { getQualifiedHostsService } from "@calcom/features/di/containers/QualifiedHosts";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
import { filterBlockedUsers } from "@calcom/features/watchlist/operations/filter-blocked-users.controller";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { userSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import prisma, { userSelect } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { Logger } from "tslog";
import type { NewBookingEventType } from "./getEventTypesFromDB";
import { loadUsers } from "./loadUsers";
@@ -46,6 +45,7 @@ type EventType = Pick<
| "schedulingType"
| "maxLeadThreshold"
| "team"
| "parent"
| "assignAllTeamMembers"
| "assignRRMembersUsingSegment"
| "rrSegmentQueryValue"
@@ -133,14 +133,28 @@ const _loadAndValidateUsers = async ({
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
// Determine if users are locked
const containsBlockedUser = await checkIfUsersAreBlocked({
users,
organizationId: null,
span: sentrySpan,
});
// Get organizationId from eventType (handles org teams and managed events)
let organizationId: number | null = eventType.parent?.team?.parentId ?? eventType.team?.parentId ?? null;
if (containsBlockedUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
// Fallback: For personal events, use the user's first org membership for org-specific blocking
// TODO: When we support multiple orgs, revisit the logic
if (!organizationId && eventType.userId) {
organizationId = await ProfileRepository.findFirstOrganizationIdForUser({ userId: eventType.userId });
}
const { eligibleUsers, blockedCount } = await filterBlockedUsers(users, organizationId, sentrySpan);
if (blockedCount > 0) {
logger.info(`Filtered out ${blockedCount} blocked user(s) from booking`);
}
// If all users are blocked, throw 404
// For team events with some eligible users, continue with graceful degradation
if (eligibleUsers.length === 0) {
throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
}
users = eligibleUsers;
// map fixed users
users = users.map((user) => ({
@@ -1325,6 +1325,7 @@ export class EventTypeRepository {
team: {
select: {
id: true,
parentId: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
@@ -1362,6 +1363,7 @@ export class EventTypeRepository {
groupId: true,
user: {
select: {
locked: true,
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
},
@@ -1384,6 +1386,7 @@ export class EventTypeRepository {
},
users: {
select: {
locked: true,
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
},
@@ -911,6 +911,22 @@ export class ProfileRepository {
});
}
/**
* Returns the first organization ID the user belongs to, or null if none.
* Used for org-specific blocking on personal events.
*
* TODO: When we support checking against multiple orgs, update this to return
* all org IDs and check if user is blocked in ANY of them.
*/
static async findFirstOrganizationIdForUser({ userId }: { userId: number }): Promise<number | null> {
const profile = await prisma.profile.findFirst({
where: { userId },
select: { organizationId: true },
});
return profile?.organizationId ?? null;
}
static async findManyForOrg({ organizationId }: { organizationId: number }) {
return await prisma.profile.findMany({
where: {
@@ -1178,6 +1178,7 @@ export class UserRepository {
return this.prismaClient.user.findMany({
where,
select: {
locked: true,
allowDynamicBooking: true,
...availabilityUserSelect,
credentials: {
@@ -6,6 +6,21 @@ export interface BlockingResult {
watchlistEntry?: Record<string, unknown> | null;
}
/**
* Result of bulk blocking check.
* Maps email (lowercase) -> BlockingResult
*/
export type BulkBlockingResult = Map<string, BlockingResult>;
export interface IBlockingService {
/**
* Check if a single email is blocked.
*/
isBlocked(email: string, organizationId?: number | null): Promise<BlockingResult>;
/**
* Bulk check multiple emails in a single query.
* Returns Map<email, BlockingResult> for efficient lookup.
*/
areBlocked(emails: string[], organizationId?: number | null): Promise<BulkBlockingResult>;
}
@@ -13,6 +13,15 @@ export interface IGlobalWatchlistRepository {
findById(id: string): Promise<Watchlist | null>;
listBlockedEntries(): Promise<Watchlist[]>;
/**
* Bulk find blocking entries for multiple emails and domains.
* Single query for N emails and domains - eliminates N+1.
*/
findBlockingEntriesForEmailsAndDomains(params: {
emails: string[];
domains: string[];
}): Promise<Watchlist[]>;
// Write operations
createEntry(data: {
type: WatchlistType;
@@ -52,6 +61,16 @@ export interface IOrganizationWatchlistRepository {
listBlockedEntries(organizationId: number): Promise<Watchlist[]>;
listAllOrganizationEntries(): Promise<Watchlist[]>;
/**
* Bulk find blocking entries for multiple emails and domains within an organization.
* Single query for N emails and domains - eliminates N+1.
*/
findBlockingEntriesForEmailsAndDomains(params: {
emails: string[];
domains: string[];
organizationId: number;
}): Promise<Watchlist[]>;
// Write operations
createEntry(
organizationId: number,
@@ -85,6 +85,41 @@ export class GlobalWatchlistRepository implements IGlobalWatchlistRepository {
});
}
/**
* Bulk find blocking entries for multiple emails and domains.
* Single query for N emails and domains - eliminates N+1.
*/
async findBlockingEntriesForEmailsAndDomains(params: {
emails: string[];
domains: string[];
}): Promise<Watchlist[]> {
const { emails, domains } = params;
if (emails.length === 0 && domains.length === 0) {
return [];
}
const conditions: Array<{ type: WatchlistType; value: { in: string[] } }> = [];
if (emails.length > 0) {
conditions.push({ type: WatchlistType.EMAIL, value: { in: emails } });
}
if (domains.length > 0) {
conditions.push({ type: WatchlistType.DOMAIN, value: { in: domains } });
}
return this.prisma.watchlist.findMany({
select: this.selectFields,
where: {
organizationId: null,
isGlobal: true,
action: WatchlistAction.BLOCK,
OR: conditions,
},
});
}
// Write operations for global entries
async createEntry(data: {
type: WatchlistType;
@@ -76,6 +76,41 @@ export class OrganizationWatchlistRepository implements IOrganizationWatchlistRe
});
}
/**
* Batch find blocking entries for multiple emails and domains within an organization.
* Single query for N users - eliminates N+1.
*/
async findBlockingEntriesForEmailsAndDomains(params: {
emails: string[];
domains: string[];
organizationId: number;
}): Promise<Watchlist[]> {
const { emails, domains, organizationId } = params;
if (emails.length === 0 && domains.length === 0) {
return [];
}
const conditions: Array<{ type: WatchlistType; value: { in: string[] } }> = [];
if (emails.length > 0) {
conditions.push({ type: WatchlistType.EMAIL, value: { in: emails } });
}
if (domains.length > 0) {
conditions.push({ type: WatchlistType.DOMAIN, value: { in: domains } });
}
return this.prisma.watchlist.findMany({
select: this.selectFields,
where: {
organizationId,
action: WatchlistAction.BLOCK,
OR: conditions,
},
});
}
async findById(id: string, organizationId: number): Promise<Watchlist | null> {
return this.prisma.watchlist.findFirst({
select: this.selectFields,
@@ -1,7 +1,7 @@
import type { IBlockingService, BlockingResult } from "../interface/IBlockingService";
import type { BulkBlockingResult, BlockingResult, IBlockingService } from "../interface/IBlockingService";
import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories";
import { WatchlistType } from "../types";
import { normalizeEmail, extractDomainFromEmail, normalizeDomain } from "../utils/normalization";
import { extractDomainFromEmail, normalizeDomain, normalizeEmail } from "../utils/normalization";
type Deps = {
globalRepo: IGlobalWatchlistRepository;
@@ -42,6 +42,65 @@ export class GlobalBlockingService implements IBlockingService {
return { isBlocked: false };
}
/**
* Bulk check multiple emails in a single query.
* Returns Map<email (lowercase), BlockingResult> for efficient lookup.
*/
async areBlocked(emails: string[]): Promise<BulkBlockingResult> {
const result: BulkBlockingResult = new Map();
if (emails.length === 0) {
return result;
}
// Normalize and extract domains
const normalizedEmails = emails.map((e) => normalizeEmail(e));
const uniqueDomains = Array.from(new Set(normalizedEmails.map((e) => extractDomainFromEmail(e))));
// Single DB query for all emails and domains
const blockingEntries = await this.deps.globalRepo.findBlockingEntriesForEmailsAndDomains({
emails: normalizedEmails,
domains: uniqueDomains,
});
// Build lookup maps for O(1) checks (Maps serve as both lookup and entry storage)
const emailEntries = new Map<string, (typeof blockingEntries)[0]>();
const domainEntries = new Map<string, (typeof blockingEntries)[0]>();
for (const entry of blockingEntries) {
if (entry.type === WatchlistType.EMAIL) {
emailEntries.set(entry.value.toLowerCase(), entry);
} else if (entry.type === WatchlistType.DOMAIN) {
domainEntries.set(entry.value.toLowerCase(), entry);
}
}
// Map results for each email
for (const email of normalizedEmails) {
const domain = extractDomainFromEmail(email);
const emailEntry = emailEntries.get(email);
const domainEntry = domainEntries.get(domain);
if (emailEntry) {
result.set(email, {
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: emailEntry,
});
} else if (domainEntry) {
result.set(email, {
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: domainEntry,
});
} else {
result.set(email, { isBlocked: false });
}
}
return result;
}
async isFreeEmailDomain(domain: string): Promise<boolean> {
const normalizedDomain = normalizeDomain(domain);
return this.deps.globalRepo.findFreeEmailDomain(normalizedDomain).then((entry) => !!entry);
@@ -1,7 +1,7 @@
import type { IBlockingService, BlockingResult } from "../interface/IBlockingService";
import type { BulkBlockingResult, BlockingResult, IBlockingService } from "../interface/IBlockingService";
import type { IOrganizationWatchlistRepository } from "../interface/IWatchlistRepositories";
import { WatchlistType } from "../types";
import { normalizeEmail, extractDomainFromEmail, normalizeDomain } from "../utils/normalization";
import { extractDomainFromEmail, normalizeDomain, normalizeEmail } from "../utils/normalization";
type Deps = {
orgRepo: IOrganizationWatchlistRepository;
@@ -42,6 +42,66 @@ export class OrganizationBlockingService implements IBlockingService {
return { isBlocked: false };
}
/**
* Bulk check multiple emails in a single query for an organization.
* Returns Map<email (lowercase), BlockingResult> for efficient lookup.
*/
async areBlocked(emails: string[], organizationId: number): Promise<BulkBlockingResult> {
const result: BulkBlockingResult = new Map();
if (emails.length === 0 || !organizationId) {
return result;
}
// Normalize and extract domains
const normalizedEmails = emails.map((e) => normalizeEmail(e));
const uniqueDomains = Array.from(new Set(normalizedEmails.map((e) => extractDomainFromEmail(e))));
// Single DB query for all emails and domains
const blockingEntries = await this.deps.orgRepo.findBlockingEntriesForEmailsAndDomains({
emails: normalizedEmails,
domains: uniqueDomains,
organizationId,
});
// Build lookup maps for O(1) checks (Maps serve as both lookup and entry storage)
const emailEntries = new Map<string, (typeof blockingEntries)[0]>();
const domainEntries = new Map<string, (typeof blockingEntries)[0]>();
for (const entry of blockingEntries) {
if (entry.type === WatchlistType.EMAIL) {
emailEntries.set(entry.value.toLowerCase(), entry);
} else if (entry.type === WatchlistType.DOMAIN) {
domainEntries.set(entry.value.toLowerCase(), entry);
}
}
// Map results for each email
for (const email of normalizedEmails) {
const domain = extractDomainFromEmail(email);
const emailEntry = emailEntries.get(email);
const domainEntry = domainEntries.get(domain);
if (emailEntry) {
result.set(email, {
isBlocked: true,
reason: WatchlistType.EMAIL,
watchlistEntry: emailEntry,
});
} else if (domainEntry) {
result.set(email, {
isBlocked: true,
reason: WatchlistType.DOMAIN,
watchlistEntry: domainEntry,
});
} else {
result.set(email, { isBlocked: false });
}
}
return result;
}
async isDomainBlocked(domain: string, organizationId: number): Promise<BlockingResult> {
const normalizedDomain = normalizeDomain(domain);
@@ -0,0 +1,394 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import {
getBlockedUsersMap,
isUserBlocked,
checkWatchlistBlocking,
type BlockableUser,
type BlockingInfo,
} from "./check-user-blocking";
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
const mockGlobalBlocking: { areBlocked: ReturnType<typeof vi.fn> } = {
areBlocked: vi.fn(),
};
const mockOrgBlocking: { areBlocked: ReturnType<typeof vi.fn> } = {
areBlocked: vi.fn(),
};
const mockWatchlistFeature: {
globalBlocking: typeof mockGlobalBlocking;
orgBlocking: typeof mockOrgBlocking;
watchlist: Record<string, unknown>;
audit: Record<string, unknown>;
} = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
watchlist: {},
audit: {},
};
describe("check-user-blocking", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature as never);
});
describe("getBlockedUsersMap", () => {
describe("Empty input handling", () => {
test("should return empty result for empty users array", async () => {
const result = await getBlockedUsersMap([]);
expect(result).toEqual({
blockingMap: new Map(),
blockedCount: 0,
lockedCount: 0,
watchlistBlockedCount: 0,
});
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
test("should skip users with empty emails", async () => {
mockGlobalBlocking.areBlocked.mockResolvedValue(new Map([["valid@example.com", { isBlocked: false }]]));
const users: BlockableUser[] = [
{ email: "", locked: false },
{ email: " ", locked: false },
{ email: "valid@example.com", locked: false },
];
const result = await getBlockedUsersMap(users);
expect(result.blockedCount).toBe(0);
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledWith(["valid@example.com"]);
});
test("should return empty result if all users have empty emails", async () => {
const users: BlockableUser[] = [
{ email: "", locked: false },
{ email: " ", locked: false },
];
const result = await getBlockedUsersMap(users);
expect(result).toEqual({
blockingMap: new Map(),
blockedCount: 0,
lockedCount: 0,
watchlistBlockedCount: 0,
});
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Locked user blocking", () => {
test("should block locked users without calling watchlist", async () => {
const users: BlockableUser[] = [
{ email: "locked@example.com", locked: true },
{ email: "unlocked@example.com", locked: false },
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["unlocked@example.com", { isBlocked: false }]])
);
const result = await getBlockedUsersMap(users);
expect(result.blockedCount).toBe(1);
expect(result.lockedCount).toBe(1);
expect(result.watchlistBlockedCount).toBe(0);
const lockedInfo = result.blockingMap.get("locked@example.com");
expect(lockedInfo).toEqual({ isBlocked: true, reason: "locked" });
// Should only check unlocked user in watchlist
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledWith(["unlocked@example.com"]);
});
test("should not call watchlist if all users are locked", async () => {
const users: BlockableUser[] = [
{ email: "locked1@example.com", locked: true },
{ email: "locked2@example.com", locked: true },
];
const result = await getBlockedUsersMap(users);
expect(result.blockedCount).toBe(2);
expect(result.lockedCount).toBe(2);
expect(result.watchlistBlockedCount).toBe(0);
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
test("should correctly count multiple locked users", async () => {
const users: BlockableUser[] = [
{ email: "locked1@example.com", locked: true },
{ email: "locked2@example.com", locked: true },
{ email: "locked3@example.com", locked: true },
];
const result = await getBlockedUsersMap(users);
expect(result.lockedCount).toBe(3);
expect(result.blockedCount).toBe(3);
});
});
describe("Watchlist blocking", () => {
test("should block users on global watchlist", async () => {
const users: BlockableUser[] = [
{ email: "blocked@spam.com", locked: false },
{ email: "clean@example.com", locked: false },
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["blocked@spam.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await getBlockedUsersMap(users);
expect(result.blockedCount).toBe(1);
expect(result.lockedCount).toBe(0);
expect(result.watchlistBlockedCount).toBe(1);
const blockedInfo = result.blockingMap.get("blocked@spam.com");
expect(blockedInfo).toEqual({ isBlocked: true, reason: "watchlist" });
const cleanInfo = result.blockingMap.get("clean@example.com");
expect(cleanInfo).toEqual({ isBlocked: false });
});
test("should check org-level blocking when organizationId is provided", async () => {
const users: BlockableUser[] = [{ email: "user@example.com", locked: false }];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
mockOrgBlocking.areBlocked.mockResolvedValue(new Map([["user@example.com", { isBlocked: true }]]));
const result = await getBlockedUsersMap(users, 123);
expect(result.blockedCount).toBe(1);
expect(result.watchlistBlockedCount).toBe(1);
expect(mockOrgBlocking.areBlocked).toHaveBeenCalledWith(["user@example.com"], 123);
});
test("should not check org-level blocking when organizationId is not provided", async () => {
const users: BlockableUser[] = [{ email: "user@example.com", locked: false }];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
await getBlockedUsersMap(users);
expect(mockOrgBlocking.areBlocked).not.toHaveBeenCalled();
});
test("should not check org-level blocking when organizationId is null", async () => {
const users: BlockableUser[] = [{ email: "user@example.com", locked: false }];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
await getBlockedUsersMap(users, null);
expect(mockOrgBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Combined blocking (locked + watchlist)", () => {
test("should correctly count both locked and watchlist-blocked users", async () => {
const users: BlockableUser[] = [
{ email: "locked@example.com", locked: true },
{ email: "watchlist-blocked@spam.com", locked: false },
{ email: "clean@example.com", locked: false },
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["watchlist-blocked@spam.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await getBlockedUsersMap(users);
expect(result.blockedCount).toBe(2);
expect(result.lockedCount).toBe(1);
expect(result.watchlistBlockedCount).toBe(1);
});
test("should prioritize locked status over watchlist (locked users not checked in watchlist)", async () => {
const users: BlockableUser[] = [
{ email: "locked-and-would-be-blocked@spam.com", locked: true },
{ email: "clean@example.com", locked: false },
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["clean@example.com", { isBlocked: false }]])
);
const result = await getBlockedUsersMap(users);
// The locked user's email should NOT be sent to watchlist check
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledWith(["clean@example.com"]);
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalledWith(
expect.arrayContaining(["locked-and-would-be-blocked@spam.com"])
);
// But the locked user should still be marked as blocked
const lockedInfo = result.blockingMap.get("locked-and-would-be-blocked@spam.com");
expect(lockedInfo).toEqual({ isBlocked: true, reason: "locked" });
});
});
describe("Email normalization", () => {
test("should normalize emails to lowercase", async () => {
const users: BlockableUser[] = [{ email: "USER@EXAMPLE.COM", locked: false }];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
const result = await getBlockedUsersMap(users);
expect(result.blockingMap.has("user@example.com")).toBe(true);
expect(result.blockingMap.has("USER@EXAMPLE.COM")).toBe(false);
});
test("should trim whitespace from emails", async () => {
const users: BlockableUser[] = [{ email: " user@example.com ", locked: false }];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
const result = await getBlockedUsersMap(users);
expect(result.blockingMap.has("user@example.com")).toBe(true);
});
});
});
describe("isUserBlocked", () => {
test("should return true for blocked user", () => {
const blockingMap = new Map<string, BlockingInfo>([
["blocked@example.com", { isBlocked: true, reason: "locked" }],
]);
expect(isUserBlocked("blocked@example.com", blockingMap)).toBe(true);
});
test("should return false for non-blocked user", () => {
const blockingMap = new Map<string, BlockingInfo>([
["clean@example.com", { isBlocked: false }],
]);
expect(isUserBlocked("clean@example.com", blockingMap)).toBe(false);
});
test("should return false for user not in map", () => {
const blockingMap = new Map<string, BlockingInfo>();
expect(isUserBlocked("unknown@example.com", blockingMap)).toBe(false);
});
test("should return false for empty email", () => {
const blockingMap = new Map<string, BlockingInfo>([
["blocked@example.com", { isBlocked: true, reason: "locked" }],
]);
expect(isUserBlocked("", blockingMap)).toBe(false);
expect(isUserBlocked(" ", blockingMap)).toBe(false);
});
test("should normalize email before lookup", () => {
const blockingMap = new Map<string, BlockingInfo>([
["blocked@example.com", { isBlocked: true, reason: "locked" }],
]);
expect(isUserBlocked("BLOCKED@EXAMPLE.COM", blockingMap)).toBe(true);
expect(isUserBlocked(" blocked@example.com ", blockingMap)).toBe(true);
});
});
describe("checkWatchlistBlocking", () => {
test("should batch check all emails against global watchlist", async () => {
const emails = ["user1@example.com", "user2@example.com"];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["user1@example.com", { isBlocked: false }],
["user2@example.com", { isBlocked: true }],
])
);
const result = await checkWatchlistBlocking(emails);
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledWith(emails);
expect(result.get("user1@example.com")).toBe(false);
expect(result.get("user2@example.com")).toBe(true);
});
test("should combine global and org blocking results", async () => {
const emails = ["user@example.com"];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
mockOrgBlocking.areBlocked.mockResolvedValue(new Map([["user@example.com", { isBlocked: true }]]));
const result = await checkWatchlistBlocking(emails, 123);
expect(result.get("user@example.com")).toBe(true);
});
test("should block if either global or org blocks", async () => {
const emails = ["global-blocked@example.com", "org-blocked@example.com", "clean@example.com"];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["global-blocked@example.com", { isBlocked: true }],
["org-blocked@example.com", { isBlocked: false }],
["clean@example.com", { isBlocked: false }],
])
);
mockOrgBlocking.areBlocked.mockResolvedValue(
new Map([
["global-blocked@example.com", { isBlocked: false }],
["org-blocked@example.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await checkWatchlistBlocking(emails, 123);
expect(result.get("global-blocked@example.com")).toBe(true);
expect(result.get("org-blocked@example.com")).toBe(true);
expect(result.get("clean@example.com")).toBe(false);
});
test("should normalize emails in result map", async () => {
const emails = ["USER@EXAMPLE.COM"];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: true }]])
);
const result = await checkWatchlistBlocking(emails);
expect(result.get("user@example.com")).toBe(true);
});
});
});
@@ -0,0 +1,148 @@
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
import logger from "@calcom/lib/logger";
const log: ReturnType<typeof logger.getSubLogger> = logger.getSubLogger({
prefix: ["watchlist", "check-user-blocking"],
});
/**
* Minimal user shape required for blocking checks.
* Users must have email and locked status.
*/
export interface BlockableUser {
email: string;
locked: boolean;
}
export interface BlockingInfo {
isBlocked: boolean;
reason?: "locked" | "watchlist";
}
export interface CheckUserBlockingResult {
/** Map of normalized email -> blocking info */
blockingMap: Map<string, BlockingInfo>;
/** Count of blocked users */
blockedCount: number;
/** Count of locked users */
lockedCount: number;
/** Count of watchlist-blocked users */
watchlistBlockedCount: number;
}
export async function checkWatchlistBlocking(
emails: string[],
organizationId?: number | null
): Promise<Map<string, boolean>> {
try {
const watchlist = await getWatchlistFeature();
// Batch check - single DB query for all emails
const globalBlockedMap = await watchlist.globalBlocking.areBlocked(emails);
let orgBlockedMap: Map<string, { isBlocked: boolean }> | null = null;
if (organizationId) {
orgBlockedMap = await watchlist.orgBlocking.areBlocked(emails, organizationId);
}
const result = new Map<string, boolean>();
for (const email of emails) {
const normalizedEmail = email.trim().toLowerCase();
const globalResult = globalBlockedMap.get(normalizedEmail);
const orgResult = orgBlockedMap?.get(normalizedEmail);
result.set(normalizedEmail, !!(globalResult?.isBlocked || orgResult?.isBlocked));
}
return result;
} catch (error) {
// Fail-open: If watchlist check fails, allow booking to proceed
// This ensures availability even if watchlist service is down
let errorMessage = "Unknown error";
if (error instanceof Error) {
errorMessage = error.message;
}
log.error("Watchlist check failed, allowing users through (fail-open)", {
error: errorMessage,
emailCount: emails.length,
organizationId,
});
return new Map(emails.map((e) => [e.trim().toLowerCase(), false]));
}
}
/**
* Core utility to check which users are blocked.
* Checks both:
* 1. Locked users (from user.locked field)
* 2. Watchlist-blocked emails (global and org-level)
*
* Single DB query for N emails - eliminates N+1.
*
* @param users - List of users to check (must have email and locked fields)
* @param organizationId - Optional organization ID for org-specific blocking
* @returns Map of email -> blocking info and counts
*/
export async function getBlockedUsersMap<T extends BlockableUser>(
users: T[],
organizationId?: number | null
): Promise<CheckUserBlockingResult> {
// Filter out users with empty/invalid emails
const validUsers = users.filter((u) => u.email && u.email.trim().length > 0);
if (validUsers.length === 0) {
return { blockingMap: new Map(), blockedCount: 0, lockedCount: 0, watchlistBlockedCount: 0 };
}
const blockingMap = new Map<string, BlockingInfo>();
let lockedCount = 0;
// First pass: check locked users (immediate block, no DB needed)
const nonLockedUsers: T[] = [];
for (const user of validUsers) {
const normalizedEmail = user.email.trim().toLowerCase();
if (user.locked) {
blockingMap.set(normalizedEmail, { isBlocked: true, reason: "locked" });
lockedCount++;
} else {
nonLockedUsers.push(user);
}
}
// Second pass: check watchlist for non-locked users
let watchlistBlockedCount = 0;
if (nonLockedUsers.length > 0) {
const emails = nonLockedUsers.map((u) => u.email);
const watchlistResults = await checkWatchlistBlocking(emails, organizationId);
for (const user of nonLockedUsers) {
const normalizedEmail = user.email.trim().toLowerCase();
const isWatchlistBlocked = watchlistResults.get(normalizedEmail) ?? false;
if (isWatchlistBlocked) {
blockingMap.set(normalizedEmail, { isBlocked: true, reason: "watchlist" });
watchlistBlockedCount++;
} else {
blockingMap.set(normalizedEmail, { isBlocked: false });
}
}
}
return {
blockingMap,
blockedCount: lockedCount + watchlistBlockedCount,
lockedCount,
watchlistBlockedCount,
};
}
/**
* Check if a single user is blocked (convenience wrapper).
*/
export function isUserBlocked(email: string, blockingMap: Map<string, BlockingInfo>): boolean {
if (!email || email.trim().length === 0) {
return false; // Empty emails can't be blocked
}
return blockingMap.get(email.trim().toLowerCase())?.isBlocked ?? false;
}
@@ -0,0 +1,229 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { filterBlockedHosts, type HostWithEmail } from "./filter-blocked-hosts.controller";
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
const mockGlobalBlocking: { areBlocked: ReturnType<typeof vi.fn> } = {
areBlocked: vi.fn(),
};
const mockOrgBlocking: { areBlocked: ReturnType<typeof vi.fn> } = {
areBlocked: vi.fn(),
};
const mockWatchlistFeature: {
globalBlocking: typeof mockGlobalBlocking;
orgBlocking: typeof mockOrgBlocking;
watchlist: Record<string, unknown>;
audit: Record<string, unknown>;
} = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
watchlist: {},
audit: {},
};
// Helper to create host objects
function createHost(id: number, email: string, locked: boolean): HostWithEmail {
return {
user: { id, email, locked },
};
}
describe("filterBlockedHosts", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature as never);
});
describe("Empty input handling", () => {
test("should return empty array for empty hosts array", async () => {
const result = await filterBlockedHosts([]);
expect(result).toEqual({ eligibleHosts: [], blockedCount: 0 });
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Locked host filtering", () => {
test("should filter out locked hosts", async () => {
const hosts = [
createHost(1, "locked@example.com", true),
createHost(2, "unlocked@example.com", false),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["unlocked@example.com", { isBlocked: false }]])
);
const result = await filterBlockedHosts(hosts);
expect(result.eligibleHosts).toHaveLength(1);
expect(result.eligibleHosts[0].user.id).toBe(2);
expect(result.blockedCount).toBe(1);
});
test("should filter out all locked hosts", async () => {
const hosts = [
createHost(1, "locked1@example.com", true),
createHost(2, "locked2@example.com", true),
];
const result = await filterBlockedHosts(hosts);
expect(result.eligibleHosts).toHaveLength(0);
expect(result.blockedCount).toBe(2);
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Watchlist blocking", () => {
test("should filter out watchlist-blocked hosts", async () => {
const hosts = [
createHost(1, "blocked@spam.com", false),
createHost(2, "clean@example.com", false),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["blocked@spam.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await filterBlockedHosts(hosts);
expect(result.eligibleHosts).toHaveLength(1);
expect(result.eligibleHosts[0].user.id).toBe(2);
expect(result.blockedCount).toBe(1);
});
test("should check org-level blocking when organizationId provided", async () => {
const hosts = [createHost(1, "user@example.com", false)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
mockOrgBlocking.areBlocked.mockResolvedValue(new Map([["user@example.com", { isBlocked: true }]]));
const result = await filterBlockedHosts(hosts, 123);
expect(result.eligibleHosts).toHaveLength(0);
expect(result.blockedCount).toBe(1);
expect(mockOrgBlocking.areBlocked).toHaveBeenCalledWith(["user@example.com"], 123);
});
});
describe("Combined blocking (locked + watchlist)", () => {
test("should filter out both locked and watchlist-blocked hosts", async () => {
const hosts = [
createHost(1, "locked@example.com", true),
createHost(2, "watchlist-blocked@spam.com", false),
createHost(3, "clean@example.com", false),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["watchlist-blocked@spam.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await filterBlockedHosts(hosts);
expect(result.eligibleHosts).toHaveLength(1);
expect(result.eligibleHosts[0].user.id).toBe(3);
expect(result.blockedCount).toBe(2);
});
});
describe("Preserves host data", () => {
test("should preserve all host properties in returned eligible hosts", async () => {
interface ExtendedHost extends HostWithEmail {
isFixed: boolean;
priority: number;
}
const hosts: ExtendedHost[] = [
{
user: { id: 1, email: "host@example.com", locked: false },
isFixed: true,
priority: 10,
},
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["host@example.com", { isBlocked: false }]])
);
const result = await filterBlockedHosts(hosts);
expect(result.eligibleHosts).toHaveLength(1);
expect(result.eligibleHosts[0]).toEqual({
user: { id: 1, email: "host@example.com", locked: false },
isFixed: true,
priority: 10,
});
});
});
describe("Email normalization", () => {
test("should handle emails with different cases", async () => {
const hosts = [createHost(1, "USER@EXAMPLE.COM", false)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: true }]])
);
const result = await filterBlockedHosts(hosts);
expect(result.eligibleHosts).toHaveLength(0);
expect(result.blockedCount).toBe(1);
});
});
describe("Performance: batched checks", () => {
test("should make single batch call for multiple hosts", async () => {
const hosts = [
createHost(1, "user1@example.com", false),
createHost(2, "user2@example.com", false),
createHost(3, "user3@example.com", false),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["user1@example.com", { isBlocked: false }],
["user2@example.com", { isBlocked: false }],
["user3@example.com", { isBlocked: false }],
])
);
await filterBlockedHosts(hosts);
// Should be called once with all emails, not 3 times
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledTimes(1);
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledWith([
"user1@example.com",
"user2@example.com",
"user3@example.com",
]);
});
test("should not call watchlist for locked-only hosts", async () => {
const hosts = [
createHost(1, "locked1@example.com", true),
createHost(2, "locked2@example.com", true),
];
await filterBlockedHosts(hosts);
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,49 @@
import type { BlockableUser } from "./check-user-blocking";
import { getBlockedUsersMap, isUserBlocked } from "./check-user-blocking";
/**
* Host type expected from slots service.
* Minimal shape needed for filtering.
*/
export interface HostWithEmail {
user: BlockableUser & {
id: number;
};
}
export interface FilterBlockedHostsResult<T extends HostWithEmail> {
eligibleHosts: T[];
blockedCount: number;
}
/**
* Filter out blocked hosts from a list using batched blocking check.
* Checks both locked users and watchlist-blocked emails.
* Single DB query for N hosts - eliminates N+1.
*
* @param hosts - List of hosts to filter
* @param organizationId - Optional organization ID for org-specific blocking
* @returns Object with eligibleHosts and blockedCount
*/
export async function filterBlockedHosts<T extends HostWithEmail>(
hosts: T[],
organizationId?: number | null
): Promise<FilterBlockedHostsResult<T>> {
if (hosts.length === 0) {
return { eligibleHosts: [], blockedCount: 0 };
}
// Map hosts to blockable users for the core utility
const usersToCheck = hosts.map((h) => h.user);
// Get blocking map (handles locked users and watchlist in one call)
const { blockingMap, blockedCount } = await getBlockedUsersMap(usersToCheck, organizationId);
// Filter out blocked hosts
const eligibleHosts = hosts.filter((host) => !isUserBlocked(host.user.email, blockingMap));
return {
eligibleHosts,
blockedCount,
};
}
@@ -0,0 +1,370 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import type { SpanFn } from "@calcom/features/watchlist/lib/telemetry";
import { filterBlockedUsers, type UserWithEmail } from "./filter-blocked-users.controller";
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
getWatchlistFeature: vi.fn(),
}));
const mockGlobalBlocking: { areBlocked: ReturnType<typeof vi.fn> } = {
areBlocked: vi.fn(),
};
const mockOrgBlocking: { areBlocked: ReturnType<typeof vi.fn> } = {
areBlocked: vi.fn(),
};
const mockWatchlistFeature: {
globalBlocking: typeof mockGlobalBlocking;
orgBlocking: typeof mockOrgBlocking;
watchlist: Record<string, unknown>;
audit: Record<string, unknown>;
} = {
globalBlocking: mockGlobalBlocking,
orgBlocking: mockOrgBlocking,
watchlist: {},
audit: {},
};
// Helper to create user objects
function createUser(email: string, locked: boolean, id?: number, username?: string | null): UserWithEmail {
return {
id,
email,
locked,
username: username ?? email.split("@")[0],
};
}
describe("filterBlockedUsers", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { getWatchlistFeature } = await import("@calcom/features/di/watchlist/containers/watchlist");
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature as never);
});
describe("Empty input handling", () => {
test("should return empty array for empty users array", async () => {
const result = await filterBlockedUsers([]);
expect(result).toEqual({ eligibleUsers: [], blockedCount: 0 });
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Locked user filtering", () => {
test("should filter out locked users", async () => {
const users = [
createUser("locked@example.com", true, 1),
createUser("unlocked@example.com", false, 2),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["unlocked@example.com", { isBlocked: false }]])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
expect(result.eligibleUsers[0].email).toBe("unlocked@example.com");
expect(result.blockedCount).toBe(1);
});
test("should filter out all locked users", async () => {
const users = [
createUser("locked1@example.com", true, 1),
createUser("locked2@example.com", true, 2),
];
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(0);
expect(result.blockedCount).toBe(2);
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
test("should correctly identify locked users regardless of position", async () => {
const users = [
createUser("first@example.com", false, 1),
createUser("locked@example.com", true, 2),
createUser("last@example.com", false, 3),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["first@example.com", { isBlocked: false }],
["last@example.com", { isBlocked: false }],
])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(2);
expect(result.blockedCount).toBe(1);
});
});
describe("Watchlist blocking", () => {
test("should filter out watchlist-blocked users", async () => {
const users = [
createUser("blocked@spam.com", false, 1),
createUser("clean@example.com", false, 2),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["blocked@spam.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
expect(result.eligibleUsers[0].email).toBe("clean@example.com");
expect(result.blockedCount).toBe(1);
});
test("should check org-level blocking when organizationId provided", async () => {
const users = [createUser("user@example.com", false, 1)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
mockOrgBlocking.areBlocked.mockResolvedValue(new Map([["user@example.com", { isBlocked: true }]]));
const result = await filterBlockedUsers(users, 123);
expect(result.eligibleUsers).toHaveLength(0);
expect(result.blockedCount).toBe(1);
expect(mockOrgBlocking.areBlocked).toHaveBeenCalledWith(["user@example.com"], 123);
});
test("should not check org-level blocking when organizationId is null", async () => {
const users = [createUser("user@example.com", false, 1)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
await filterBlockedUsers(users, null);
expect(mockOrgBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Combined blocking (locked + watchlist)", () => {
test("should filter out both locked and watchlist-blocked users", async () => {
const users = [
createUser("locked@example.com", true, 1),
createUser("watchlist-blocked@spam.com", false, 2),
createUser("clean@example.com", false, 3),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["watchlist-blocked@spam.com", { isBlocked: true }],
["clean@example.com", { isBlocked: false }],
])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
expect(result.eligibleUsers[0].email).toBe("clean@example.com");
expect(result.blockedCount).toBe(2);
});
test("should handle complex scenario with mixed blocking reasons", async () => {
const users = [
createUser("locked1@example.com", true, 1),
createUser("global-blocked@spam.com", false, 2),
createUser("org-blocked@competitor.com", false, 3),
createUser("clean1@example.com", false, 4),
createUser("locked2@example.com", true, 5),
createUser("clean2@example.com", false, 6),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["global-blocked@spam.com", { isBlocked: true }],
["org-blocked@competitor.com", { isBlocked: false }],
["clean1@example.com", { isBlocked: false }],
["clean2@example.com", { isBlocked: false }],
])
);
mockOrgBlocking.areBlocked.mockResolvedValue(
new Map([
["global-blocked@spam.com", { isBlocked: false }],
["org-blocked@competitor.com", { isBlocked: true }],
["clean1@example.com", { isBlocked: false }],
["clean2@example.com", { isBlocked: false }],
])
);
const result = await filterBlockedUsers(users, 123);
expect(result.eligibleUsers).toHaveLength(2);
expect(result.eligibleUsers.map((u) => u.email)).toEqual(["clean1@example.com", "clean2@example.com"]);
expect(result.blockedCount).toBe(4); // 2 locked + 1 global + 1 org
});
});
describe("Preserves user data", () => {
test("should preserve all user properties in returned eligible users", async () => {
interface ExtendedUser extends UserWithEmail {
metadata: Record<string, unknown>;
credentials: unknown[];
}
const users: ExtendedUser[] = [
{
id: 1,
email: "user@example.com",
locked: false,
username: "testuser",
metadata: { key: "value" },
credentials: [{ type: "google" }],
},
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
expect(result.eligibleUsers[0]).toEqual({
id: 1,
email: "user@example.com",
locked: false,
username: "testuser",
metadata: { key: "value" },
credentials: [{ type: "google" }],
});
});
test("should handle users with null username", async () => {
const users: UserWithEmail[] = [
{
id: 1,
email: "user@example.com",
locked: false,
username: null,
},
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
expect(result.eligibleUsers[0].username).toBeNull();
});
});
describe("Email edge cases", () => {
test("should handle emails with different cases", async () => {
const users = [createUser("USER@EXAMPLE.COM", false, 1)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: true }]])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(0);
expect(result.blockedCount).toBe(1);
});
test("should handle emails with whitespace", async () => {
const users = [createUser(" user@example.com ", false, 1)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
});
});
describe("Performance: batched checks", () => {
test("should make single batch call for multiple users", async () => {
const users = [
createUser("user1@example.com", false, 1),
createUser("user2@example.com", false, 2),
createUser("user3@example.com", false, 3),
];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([
["user1@example.com", { isBlocked: false }],
["user2@example.com", { isBlocked: false }],
["user3@example.com", { isBlocked: false }],
])
);
await filterBlockedUsers(users);
// Should be called once with all emails, not 3 times
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledTimes(1);
expect(mockGlobalBlocking.areBlocked).toHaveBeenCalledWith([
"user1@example.com",
"user2@example.com",
"user3@example.com",
]);
});
test("should not call watchlist for locked-only users", async () => {
const users = [
createUser("locked1@example.com", true, 1),
createUser("locked2@example.com", true, 2),
];
await filterBlockedUsers(users);
expect(mockGlobalBlocking.areBlocked).not.toHaveBeenCalled();
});
});
describe("Telemetry (span)", () => {
test("should call span when provided", async () => {
const users = [createUser("user@example.com", false, 1)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
const mockSpan: SpanFn = vi.fn((_options, callback) => {
return Promise.resolve(callback());
});
const result = await filterBlockedUsers(users, null, mockSpan);
expect(result.eligibleUsers).toHaveLength(1);
expect(mockSpan).toHaveBeenCalledWith({ name: "filterBlockedUsers Controller" }, expect.any(Function));
});
test("should work without span", async () => {
const users = [createUser("user@example.com", false, 1)];
mockGlobalBlocking.areBlocked.mockResolvedValue(
new Map([["user@example.com", { isBlocked: false }]])
);
// Should not throw when span is not provided
const result = await filterBlockedUsers(users);
expect(result.eligibleUsers).toHaveLength(1);
});
});
});
@@ -0,0 +1,56 @@
import type { SpanFn } from "@calcom/features/watchlist/lib/telemetry";
import type { BlockableUser } from "./check-user-blocking";
import { getBlockedUsersMap, isUserBlocked } from "./check-user-blocking";
/**
* User type expected from loadAndValidateUsers.
*/
export interface UserWithEmail extends BlockableUser {
id?: number;
username: string | null;
}
export interface FilterBlockedUsersResult<T extends UserWithEmail> {
eligibleUsers: T[];
blockedCount: number;
}
/**
* Filter out blocked users from a list using batched blocking check.
* Checks both locked users and watchlist-blocked emails.
* Single DB query for N users - eliminates N+1.
*
* @param users - List of users to filter
* @param organizationId - Optional organization ID for org-specific blocking
* @param span - Optional tracing span
* @returns Object with eligibleUsers and blockedCount
*/
export async function filterBlockedUsers<T extends UserWithEmail>(
users: T[],
organizationId?: number | null,
span?: SpanFn
): Promise<FilterBlockedUsersResult<T>> {
const execute = async (): Promise<FilterBlockedUsersResult<T>> => {
if (users.length === 0) {
return { eligibleUsers: [], blockedCount: 0 };
}
// Get blocking map (handles locked users and watchlist in one call)
const { blockingMap, blockedCount } = await getBlockedUsersMap(users, organizationId);
// Filter out blocked users
const eligibleUsers = users.filter((user) => !isUserBlocked(user.email, blockingMap));
return {
eligibleUsers,
blockedCount,
};
};
if (!span) {
return execute();
}
return span({ name: "filterBlockedUsers Controller" }, execute);
}
@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "public"."Watchlist_type_value_organizationId_action_idx";
-- CreateIndex
CREATE INDEX "Watchlist_isGlobal_action_organizationId_type_value_idx" ON "public"."Watchlist"("isGlobal", "action", "organizationId", "type", "value");
+1 -1
View File
@@ -2412,7 +2412,7 @@ model Watchlist {
audits WatchlistAudit[]
@@unique([type, value, organizationId])
@@index([type, value, organizationId, action])
@@index([isGlobal, action, organizationId, type, value])
}
model WatchlistAudit {
@@ -1,6 +1,4 @@
import type { Logger } from "tslog";
import { v4 as uuid } from "uuid";
import process from "node:process";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
@@ -9,9 +7,9 @@ import type {
CurrentSeats,
EventType,
GetAvailabilityUser,
UserAvailabilityService,
IFromUser,
IToUser,
UserAvailabilityService,
} from "@calcom/features/availability/lib/getUserAvailability";
import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
@@ -33,6 +31,7 @@ import type { ISelectedSlotRepository } from "@calcom/features/selectedSlots/rep
import type { NoSlotsNotificationService } from "@calcom/features/slots/handleNotificationWhenNoSlots";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { withSelectedCalendars } from "@calcom/features/users/repositories/UserRepository";
import { filterBlockedHosts } from "@calcom/features/watchlist/operations/filter-blocked-hosts.controller";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
@@ -43,21 +42,21 @@ import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"
import LimitManager from "@calcom/lib/intervalLimits/limitManager";
import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils";
import {
BookingDateInPastError,
calculatePeriodLimits,
isTimeOutOfBounds,
isTimeViolatingFutureLimit,
BookingDateInPastError,
} from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
import { SchedulingType, PeriodType } from "@calcom/prisma/enums";
import { PeriodType, SchedulingType } from "@calcom/prisma/enums";
import type { EventBusyDate, EventBusyDetails } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { TRPCError } from "@trpc/server";
import type { Logger } from "tslog";
import { v4 as uuid } from "uuid";
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
import type { GetScheduleOptions } from "./types";
@@ -200,8 +199,8 @@ export class AvailableSlotsService {
usernameList: Array.isArray(input.usernameList)
? input.usernameList
: input.usernameList
? [input.usernameList]
: [],
? [input.usernameList]
: [],
});
const usersWithOldSelectedCalendars = usersForDynamicEventType.map((user) => withSelectedCalendars(user));
@@ -280,7 +279,7 @@ export class AvailableSlotsService {
* cause slots from adjacent days to leak into the response.
*/
private _filterSlotsByRequestedDateRange<
T extends Record<string, { time: string; attendees?: number; bookingUid?: string }[]>
T extends Record<string, { time: string; attendees?: number; bookingUid?: string }[]>,
>({
slotsMappedToDate,
startTime,
@@ -871,7 +870,7 @@ export class AvailableSlotsService {
});
}
let busyTimesFromLimitsMap: Map<number, EventBusyDetails[]> | undefined = undefined;
let busyTimesFromLimitsMap: Map<number, EventBusyDetails[]> | undefined;
if (eventType && (bookingLimits || durationLimits)) {
const usersForLimits = usersWithCredentials.map((user) => ({ id: user.id, email: user.email }));
const eventTimeZone = eventType.schedule?.timeZone ?? usersWithCredentials[0]?.timeZone ?? "UTC";
@@ -894,7 +893,7 @@ export class AvailableSlotsService {
const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits);
let teamBookingLimitsMap: Map<number, EventBusyDetails[]> | undefined = undefined;
let teamBookingLimitsMap: Map<number, EventBusyDetails[]> | undefined;
if (teamForBookingLimits && teamBookingLimits) {
const usersForTeamLimits = usersWithCredentials.map((user) => ({ id: user.id, email: user.email }));
const eventTimeZone = eventType.schedule?.timeZone ?? usersWithCredentials[0]?.timeZone ?? "UTC";
@@ -1097,11 +1096,32 @@ export class AvailableSlotsService {
rrHostSubsetIds: input.rrHostSubsetIds ?? undefined,
});
const allHosts = [...qualifiedRRHosts, ...fixedHosts];
// Filter out blocked hosts BEFORE calculating availability (batched - single DB query)
const organizationId = eventType.parent?.team?.parentId ?? eventType.team?.parentId ?? null;
const { eligibleHosts: eligibleQualifiedRRHosts } = await filterBlockedHosts(
qualifiedRRHosts,
organizationId
);
const { eligibleHosts: eligibleFixedHosts } = await filterBlockedHosts(fixedHosts, organizationId);
const { eligibleHosts: eligibleFallbackRRHosts } = allFallbackRRHosts
? await filterBlockedHosts(allFallbackRRHosts, organizationId)
: { eligibleHosts: [] };
const allHosts = [...eligibleQualifiedRRHosts, ...eligibleFixedHosts];
// If all hosts are blocked, return empty slots
if (allHosts.length === 0) {
loggerWithEventDetails.info("All hosts are blocked by watchlist, returning empty slots");
return {
slots: {},
};
}
const twoWeeksFromNow = dayjs().add(2, "week");
const hasFallbackRRHosts = allFallbackRRHosts && allFallbackRRHosts.length > qualifiedRRHosts.length;
const hasFallbackRRHosts =
eligibleFallbackRRHosts.length > 0 && eligibleFallbackRRHosts.length > eligibleQualifiedRRHosts.length;
let { allUsersAvailability, usersWithCredentials, currentSeats } =
await this.calculateHostsAndAvailabilities({
@@ -1142,7 +1162,7 @@ export class AvailableSlotsService {
const firstTwoWeeksAvailabilities = await this.calculateHostsAndAvailabilities({
input,
eventType,
hosts: [...qualifiedRRHosts, ...fixedHosts],
hosts: [...eligibleQualifiedRRHosts, ...eligibleFixedHosts],
loggerWithEventDetails,
startTime: dayjs(),
endTime: twoWeeksFromNow,
@@ -1165,8 +1185,9 @@ export class AvailableSlotsService {
loggerWithEventDetails.info({
email: input.email,
contactOwnerEmail,
qualifiedRRHosts: qualifiedRRHosts.map((host) => host.user.id),
fallbackRRHosts: allFallbackRRHosts.map((host) => host.user.id),
eligibleQualifiedRRHosts: eligibleQualifiedRRHosts.map((host) => host.user.id),
eligibleFallbackRRHosts: eligibleFallbackRRHosts.map((host) => host.user.id),
blockedHostsCount: qualifiedRRHosts.length - eligibleQualifiedRRHosts.length,
fallBackActive: diff > 0,
});
}
@@ -1177,7 +1198,7 @@ export class AvailableSlotsService {
await this.calculateHostsAndAvailabilities({
input,
eventType,
hosts: [...allFallbackRRHosts, ...fixedHosts],
hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts],
loggerWithEventDetails,
startTime,
endTime,
@@ -1413,7 +1434,7 @@ export class AvailableSlotsService {
eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone;
const eventUtcOffset = getUTCOffsetByTimezone(eventTimeZone) ?? 0;
const bookerUtcOffset = input.timeZone ? getUTCOffsetByTimezone(input.timeZone) ?? 0 : 0;
const bookerUtcOffset = input.timeZone ? (getUTCOffsetByTimezone(input.timeZone) ?? 0) : 0;
const periodLimits = calculatePeriodLimits({
periodType: eventType.periodType,
periodDays: eventType.periodDays,