* init * DI and other improvements * type fix --------- Co-authored-by: Volnei Munhoz <volnei@cal.com>
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
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> {
|
|
// Map to trim emails first, then filter out empty/invalid ones
|
|
const validUsers = users
|
|
.map((u) => ({ ...u, email: u.email?.trim() ?? "" }))
|
|
.filter((u) => u.email.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: (typeof validUsers)[number][] = [];
|
|
for (const user of validUsers) {
|
|
const normalizedEmail = user.email.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;
|
|
}
|