fix: resolve signup watchlist review issues and auto-unlock on SIGNUP entry removal (#27923)

* fix: resolve signup watchlist review issues with deleteEntry, email verification ordering, and unlock flow

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* fix: scope sendEmailVerification to non-invite signups only

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* feat: auto-unlock users when SIGNUP-source watchlist entries are removed

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* fix: remove PII from error logging in unlockSignupUser

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-02-13 10:13:33 +00:00
1 parent 1bb4b206c6
commit 50c7210d6b
10 files changed
+542 -26

No files matched your search

@@ -12,8 +12,8 @@ import {
validateAndGetCorrectedUsernameForTeam,
} from "@calcom/features/auth/signup/utils/token";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
@@ -287,15 +287,6 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
await prefillAvatar({ email });
}
// Only send verification email for non-premium usernames
// Premium usernames will get a magic link after payment in paymentCallback
if (!checkoutSessionId) {
sendEmailVerification({
email,
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
username: username || "",
});
}
}
const featureRepository = getFeatureRepository();
@@ -326,6 +317,14 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
);
}
if (!checkoutSessionId && !token) {
sendEmailVerification({
email,
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
username: username || "",
});
}
if (checkoutSessionId) {
console.log("Created user but missing payment", checkoutSessionId);
return NextResponse.json(
@@ -1,5 +1,3 @@
import { createContainer } from "@evyweb/ioctopus";
import { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
@@ -11,8 +9,10 @@ import {
createWatchlistFeature,
type WatchlistFeature,
} from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
import type { IGlobalWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
import type { IOrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
import type {
IGlobalWatchlistRepository,
IOrganizationWatchlistRepository,
} from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
import { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository";
import { AdminWatchlistOperationsService } from "@calcom/features/watchlist/lib/service/AdminWatchlistOperationsService";
import { AdminWatchlistQueryService } from "@calcom/features/watchlist/lib/service/AdminWatchlistQueryService";
@@ -23,9 +23,9 @@ import { OrganizationWatchlistQueryService } from "@calcom/features/watchlist/li
import type { WatchlistAuditService } from "@calcom/features/watchlist/lib/service/WatchlistAuditService";
import type { WatchlistService } from "@calcom/features/watchlist/lib/service/WatchlistService";
import { prisma } from "@calcom/prisma";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
import { createContainer } from "@evyweb/ioctopus";
import { watchlistModule } from "../modules/Watchlist.module";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
export const watchlistContainer = createContainer();
@@ -77,10 +77,12 @@ export async function getWatchlistFeature(): Promise<WatchlistFeature> {
export function getAdminWatchlistOperationsService(): AdminWatchlistOperationsService {
const watchlistRepo = new WatchlistRepository(prisma);
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
const userRepo = new UserRepository(prisma);
return new AdminWatchlistOperationsService({
watchlistRepo,
bookingReportRepo,
userRepo,
});
}
@@ -1436,4 +1436,24 @@ export class UserRepository {
data: { locked: true },
});
}
async unlockByEmail({
email,
}: {
email: string;
}): Promise<{ email: string; username: string | null } | null> {
const user = await this.prismaClient.user.findFirst({
where: { email, locked: true },
select: { id: true, email: true, username: true },
});
if (!user) return null;
await this.prismaClient.user.update({
where: { id: user.id },
data: { locked: false },
});
return { email: user.email, username: user.username };
}
}
@@ -0,0 +1,99 @@
import type { PrismaClient } from "@calcom/prisma/client";
import { WatchlistAction, WatchlistSource, WatchlistType } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type DeepMockProxy, mockDeep, mockReset } from "vitest-mock-extended";
import { GlobalWatchlistRepository } from "./GlobalWatchlistRepository";
describe("GlobalWatchlistRepository", () => {
let prismaMock: DeepMockProxy<PrismaClient>;
let repo: GlobalWatchlistRepository;
beforeEach(() => {
prismaMock = mockDeep<PrismaClient>();
repo = new GlobalWatchlistRepository(prismaMock);
mockReset(prismaMock);
});
describe("deleteEntry", () => {
it("should await the prisma delete call", async () => {
const entryId = "test-entry-id";
prismaMock.watchlist.delete.mockResolvedValue({} as never);
await repo.deleteEntry(entryId);
expect(prismaMock.watchlist.delete).toHaveBeenCalledWith({
where: { id: entryId },
});
});
it("should propagate errors from prisma delete", async () => {
const entryId = "test-entry-id";
prismaMock.watchlist.delete.mockRejectedValue(new Error("Record not found"));
await expect(repo.deleteEntry(entryId)).rejects.toThrow("Record not found");
});
});
describe("createEntry", () => {
it("should use provided source instead of defaulting to MANUAL", async () => {
const mockEntry = {
id: "new-id",
type: WatchlistType.EMAIL,
value: "test@example.com",
description: "Auto-added during signup review",
isGlobal: true,
organizationId: null,
action: WatchlistAction.BLOCK,
source: WatchlistSource.SIGNUP,
lastUpdatedAt: new Date(),
};
prismaMock.watchlist.create.mockResolvedValue(mockEntry as never);
const result = await repo.createEntry({
type: WatchlistType.EMAIL,
value: "test@example.com",
action: WatchlistAction.BLOCK,
source: WatchlistSource.SIGNUP,
description: "Auto-added during signup review",
});
expect(prismaMock.watchlist.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
source: WatchlistSource.SIGNUP,
}),
})
);
expect(result.source).toBe(WatchlistSource.SIGNUP);
});
it("should default to MANUAL when source is not provided", async () => {
const mockEntry = {
id: "new-id",
type: WatchlistType.EMAIL,
value: "test@example.com",
description: null,
isGlobal: true,
organizationId: null,
action: WatchlistAction.BLOCK,
source: WatchlistSource.MANUAL,
lastUpdatedAt: new Date(),
};
prismaMock.watchlist.create.mockResolvedValue(mockEntry as never);
await repo.createEntry({
type: WatchlistType.EMAIL,
value: "test@example.com",
action: WatchlistAction.BLOCK,
});
expect(prismaMock.watchlist.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
source: WatchlistSource.MANUAL,
}),
})
);
});
});
});
@@ -1,6 +1,5 @@
import type { PrismaClient, Watchlist } from "@calcom/prisma/client";
import { WatchlistAction, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
import { WatchlistAction, WatchlistSource, WatchlistType } from "@calcom/prisma/enums";
import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories";
/**
@@ -164,7 +163,7 @@ export class GlobalWatchlistRepository implements IGlobalWatchlistRepository {
}
async deleteEntry(id: string): Promise<void> {
this.prisma.watchlist.delete({
await this.prisma.watchlist.delete({
where: { id },
});
}
@@ -1,13 +1,12 @@
import type { PrismaClient } from "@calcom/prisma";
import { WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
import type {
IWatchlistRepository,
CreateWatchlistInput,
CheckWatchlistInput,
WatchlistEntry,
CreateWatchlistInput,
FindAllEntriesInput,
IWatchlistRepository,
WatchlistAuditEntry,
WatchlistEntry,
} from "./IWatchlistRepository";
export class WatchlistRepository implements IWatchlistRepository {
@@ -238,6 +237,9 @@ export class WatchlistRepository implements IWatchlistRepository {
id: string;
isGlobal: boolean;
organizationId: number | null;
source: WatchlistSource;
type: WatchlistEntry["type"];
value: string;
}>
> {
return this.prismaClient.watchlist.findMany({
@@ -246,6 +248,9 @@ export class WatchlistRepository implements IWatchlistRepository {
id: true,
isGlobal: true,
organizationId: true,
source: true,
type: true,
value: true,
},
});
}
@@ -0,0 +1,221 @@
import { WatchlistAction, WatchlistSource, WatchlistType } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AdminWatchlistOperationsService } from "./AdminWatchlistOperationsService";
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
sendEmailVerification: vi.fn().mockResolvedValue(undefined),
}));
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
function createMockWatchlistRepo() {
return {
findEntryWithAuditAndReports: vi.fn(),
deleteEntry: vi.fn().mockResolvedValue(undefined),
findEntriesByIds: vi.fn().mockResolvedValue([]),
bulkDeleteEntries: vi.fn().mockResolvedValue({ deleted: 0 }),
createEntry: vi.fn(),
createEntryIfNotExists: vi.fn(),
checkExists: vi.fn(),
findAllEntriesWithLatestAudit: vi.fn(),
createEntryFromReport: vi.fn(),
findOrgAndGlobalEntries: vi.fn(),
};
}
function createMockBookingReportRepo() {
return {
findPendingSystemReportsByEmail: vi.fn(),
findPendingSystemReportsByDomain: vi.fn(),
bulkLinkGlobalWatchlistWithSystemStatus: vi.fn(),
dismissSystemReportsByEmail: vi.fn(),
};
}
function createMockUserRepo() {
return {
unlockByEmail: vi.fn().mockResolvedValue(null),
};
}
function makeEntry(overrides: Record<string, unknown> = {}) {
return {
id: "entry-1",
type: WatchlistType.EMAIL,
value: "test@example.com",
action: WatchlistAction.BLOCK,
description: null,
organizationId: null,
isGlobal: true,
source: WatchlistSource.SIGNUP,
lastUpdatedAt: new Date(),
...overrides,
};
}
describe("AdminWatchlistOperationsService", () => {
let service: AdminWatchlistOperationsService;
let mockWatchlistRepo: ReturnType<typeof createMockWatchlistRepo>;
let mockBookingReportRepo: ReturnType<typeof createMockBookingReportRepo>;
let mockUserRepo: ReturnType<typeof createMockUserRepo>;
beforeEach(() => {
vi.clearAllMocks();
mockWatchlistRepo = createMockWatchlistRepo();
mockBookingReportRepo = createMockBookingReportRepo();
mockUserRepo = createMockUserRepo();
service = new AdminWatchlistOperationsService({
watchlistRepo: mockWatchlistRepo as never,
bookingReportRepo: mockBookingReportRepo as never,
userRepo: mockUserRepo as never,
});
});
describe("deleteWatchlistEntry - auto-unlock", () => {
test("should unlock user and send verification email when deleting SIGNUP email entry", async () => {
const entry = makeEntry({ source: WatchlistSource.SIGNUP, type: WatchlistType.EMAIL });
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] });
mockUserRepo.unlockByEmail.mockResolvedValue({ email: "test@example.com", username: "testuser" });
await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 });
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-1", 1);
expect(mockUserRepo.unlockByEmail).toHaveBeenCalledWith({ email: "test@example.com" });
expect(sendEmailVerification).toHaveBeenCalledWith({
email: "test@example.com",
username: "testuser",
});
});
test("should not unlock user when deleting MANUAL source entry", async () => {
const entry = makeEntry({ source: WatchlistSource.MANUAL, type: WatchlistType.EMAIL });
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] });
await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 });
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-1", 1);
expect(mockUserRepo.unlockByEmail).not.toHaveBeenCalled();
expect(sendEmailVerification).not.toHaveBeenCalled();
});
test("should not unlock user when deleting SIGNUP domain entry", async () => {
const entry = makeEntry({
source: WatchlistSource.SIGNUP,
type: WatchlistType.DOMAIN,
value: "example.com",
});
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] });
await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 });
expect(mockUserRepo.unlockByEmail).not.toHaveBeenCalled();
});
test("should not send verification email when user is not locked", async () => {
const entry = makeEntry({ source: WatchlistSource.SIGNUP, type: WatchlistType.EMAIL });
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] });
mockUserRepo.unlockByEmail.mockResolvedValue(null);
await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 });
expect(mockUserRepo.unlockByEmail).toHaveBeenCalledWith({ email: "test@example.com" });
expect(sendEmailVerification).not.toHaveBeenCalled();
});
test("should not throw if unlock fails", async () => {
const entry = makeEntry({ source: WatchlistSource.SIGNUP, type: WatchlistType.EMAIL });
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry, auditHistory: [] });
mockUserRepo.unlockByEmail.mockRejectedValue(new Error("db error"));
const result = await service.deleteWatchlistEntry({ entryId: "entry-1", userId: 1 });
expect(result.success).toBe(true);
});
});
describe("bulkDeleteWatchlistEntries - auto-unlock", () => {
test("should unlock users for SIGNUP email entries in bulk delete", async () => {
const entries = [
{
id: "e1",
isGlobal: true,
organizationId: null,
source: WatchlistSource.SIGNUP,
type: WatchlistType.EMAIL,
value: "user1@example.com",
},
{
id: "e2",
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
type: WatchlistType.EMAIL,
value: "user2@example.com",
},
];
mockWatchlistRepo.findEntriesByIds.mockResolvedValue(entries);
mockWatchlistRepo.bulkDeleteEntries.mockResolvedValue({ deleted: 2 });
mockUserRepo.unlockByEmail.mockResolvedValue({ email: "user1@example.com", username: "user1" });
await service.bulkDeleteWatchlistEntries({ entryIds: ["e1", "e2"], userId: 1 });
expect(mockUserRepo.unlockByEmail).toHaveBeenCalledTimes(1);
expect(mockUserRepo.unlockByEmail).toHaveBeenCalledWith({ email: "user1@example.com" });
expect(sendEmailVerification).toHaveBeenCalledWith({
email: "user1@example.com",
username: "user1",
});
});
test("should not unlock users for non-SIGNUP entries in bulk delete", async () => {
const entries = [
{
id: "e1",
isGlobal: true,
organizationId: null,
source: WatchlistSource.MANUAL,
type: WatchlistType.EMAIL,
value: "user@example.com",
},
];
mockWatchlistRepo.findEntriesByIds.mockResolvedValue(entries);
mockWatchlistRepo.bulkDeleteEntries.mockResolvedValue({ deleted: 1 });
await service.bulkDeleteWatchlistEntries({ entryIds: ["e1"], userId: 1 });
expect(mockUserRepo.unlockByEmail).not.toHaveBeenCalled();
});
test("should handle multiple SIGNUP entries in bulk delete", async () => {
const entries = [
{
id: "e1",
isGlobal: true,
organizationId: null,
source: WatchlistSource.SIGNUP,
type: WatchlistType.EMAIL,
value: "user1@example.com",
},
{
id: "e2",
isGlobal: true,
organizationId: null,
source: WatchlistSource.SIGNUP,
type: WatchlistType.EMAIL,
value: "user2@example.com",
},
];
mockWatchlistRepo.findEntriesByIds.mockResolvedValue(entries);
mockWatchlistRepo.bulkDeleteEntries.mockResolvedValue({ deleted: 2 });
mockUserRepo.unlockByEmail
.mockResolvedValueOnce({ email: "user1@example.com", username: "user1" })
.mockResolvedValueOnce({ email: "user2@example.com", username: "user2" });
await service.bulkDeleteWatchlistEntries({ entryIds: ["e1", "e2"], userId: 1 });
expect(mockUserRepo.unlockByEmail).toHaveBeenCalledTimes(2);
expect(sendEmailVerification).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,8 +1,9 @@
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import type { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository";
import logger from "@calcom/lib/logger";
import { SystemReportStatus, WatchlistType } from "@calcom/prisma/enums";
import { SystemReportStatus, WatchlistSource, WatchlistType } from "@calcom/prisma/enums";
import { WatchlistErrors } from "../errors/WatchlistErrors";
import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization";
import type {
@@ -53,13 +54,16 @@ export interface BulkDismissReportsByEmailInput {
type Deps = {
watchlistRepo: WatchlistRepository;
bookingReportRepo: PrismaBookingReportRepository;
userRepo: UserRepository;
};
export class AdminWatchlistOperationsService extends WatchlistOperationsService {
private adminLog = logger.getSubLogger({ prefix: ["AdminWatchlistOperationsService"] });
private userRepo: UserRepository;
constructor(deps: Deps) {
super(deps);
this.userRepo = deps.userRepo;
}
protected getScope(): WatchlistOperationsScope {
@@ -94,6 +98,13 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
validIds.push(id);
}
const signupEmailEntries = validIds
.map((id) => entryMap.get(id))
.filter(
(e): e is NonNullable<typeof e> =>
!!e && e.source === WatchlistSource.SIGNUP && e.type === WatchlistType.EMAIL
);
let successCount = 0;
if (validIds.length > 0) {
const result = await this.deps.watchlistRepo.bulkDeleteEntries({
@@ -103,6 +114,8 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
successCount = result.deleted;
}
await Promise.all(signupEmailEntries.map((entry) => this.unlockSignupUser(entry.value)));
if (successCount === 0 && failed.length > 0) {
this.adminLog.error("Bulk delete watchlist entries failures", { failed });
throw WatchlistErrors.bulkDeletePartialFailure(`Failed to delete all entries: ${failed[0].reason}`);
@@ -131,6 +144,10 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
await this.deps.watchlistRepo.deleteEntry(input.entryId, input.userId);
if (entry.source === WatchlistSource.SIGNUP && entry.type === WatchlistType.EMAIL) {
await this.unlockSignupUser(entry.value);
}
return {
success: true,
message: "Entry deleted successfully",
@@ -194,6 +211,20 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
return { success: true, count: result.count };
}
private async unlockSignupUser(email: string): Promise<void> {
try {
const user = await this.userRepo.unlockByEmail({ email });
if (user) {
await sendEmailVerification({
email: user.email,
username: user.username || "",
});
}
} catch (error) {
this.adminLog.error("Failed to auto-unlock user after SIGNUP watchlist removal", { error });
}
}
async bulkDismissReportsByEmail(input: BulkDismissReportsByEmailInput): Promise<BulkDismissReportsResult> {
const results = await Promise.all(
input.emails.map(async (email) => {
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockSendEmailVerification = vi.fn().mockResolvedValue({ ok: true, skipped: false });
const mockFindBlockedEmail = vi.fn();
const mockDeleteEntry = vi.fn();
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
sendEmailVerification: (...args: unknown[]) => mockSendEmailVerification(...args),
}));
vi.mock("@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository", () => ({
GlobalWatchlistRepository: class {
findBlockedEmail = mockFindBlockedEmail;
deleteEntry = mockDeleteEntry;
},
}));
vi.mock("@calcom/features/watchlist/lib/utils/normalization", () => ({
normalizeEmail: vi.fn((e: string) => e.toLowerCase()),
}));
vi.mock("@calcom/prisma", () => {
const mockPrisma = {
user: {
update: vi.fn(),
},
};
return { default: mockPrisma, prisma: mockPrisma };
});
import { prisma } from "@calcom/prisma";
import lockUserAccountHandler from "./lockUserAccount.handler";
describe("lockUserAccountHandler", () => {
const mockUser = {
id: 1,
email: "admin@example.com",
organizationId: null,
profile: null,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.user.update).mockResolvedValue({
id: 42,
email: "test@example.com",
username: "testuser",
} as never);
mockFindBlockedEmail.mockResolvedValue(null);
mockDeleteEntry.mockResolvedValue(undefined);
mockSendEmailVerification.mockResolvedValue({ ok: true, skipped: false });
});
describe("unlocking a user", () => {
it("should remove watchlist entry when unlocking a user", async () => {
const watchlistEntry = { id: "watchlist-entry-123" };
mockFindBlockedEmail.mockResolvedValue(watchlistEntry);
await lockUserAccountHandler({
ctx: { user: mockUser as never },
input: { userId: 42, locked: false },
});
expect(mockDeleteEntry).toHaveBeenCalledWith("watchlist-entry-123");
});
it("should send verification email after unlocking and removing from watchlist", async () => {
const watchlistEntry = { id: "watchlist-entry-123" };
mockFindBlockedEmail.mockResolvedValue(watchlistEntry);
await lockUserAccountHandler({
ctx: { user: mockUser as never },
input: { userId: 42, locked: false },
});
expect(mockSendEmailVerification).toHaveBeenCalledWith({
email: "test@example.com",
username: "testuser",
});
});
it("should send verification email even when no watchlist entry exists", async () => {
mockFindBlockedEmail.mockResolvedValue(null);
await lockUserAccountHandler({
ctx: { user: mockUser as never },
input: { userId: 42, locked: false },
});
expect(mockDeleteEntry).not.toHaveBeenCalled();
expect(mockSendEmailVerification).toHaveBeenCalledWith({
email: "test@example.com",
username: "testuser",
});
});
it("should call deleteEntry before sendEmailVerification", async () => {
const callOrder: string[] = [];
mockFindBlockedEmail.mockResolvedValue({ id: "entry-1" });
mockDeleteEntry.mockImplementation(async () => {
callOrder.push("deleteEntry");
});
mockSendEmailVerification.mockImplementation(async () => {
callOrder.push("sendEmailVerification");
return { ok: true, skipped: false };
});
await lockUserAccountHandler({
ctx: { user: mockUser as never },
input: { userId: 42, locked: false },
});
expect(callOrder).toEqual(["deleteEntry", "sendEmailVerification"]);
});
});
describe("locking a user", () => {
it("should not remove watchlist entry when locking a user", async () => {
await lockUserAccountHandler({
ctx: { user: mockUser as never },
input: { userId: 42, locked: true },
});
expect(mockFindBlockedEmail).not.toHaveBeenCalled();
expect(mockDeleteEntry).not.toHaveBeenCalled();
});
it("should not send verification email when locking a user", async () => {
await lockUserAccountHandler({
ctx: { user: mockUser as never },
input: { userId: 42, locked: true },
});
expect(mockSendEmailVerification).not.toHaveBeenCalled();
});
});
});
@@ -1,7 +1,7 @@
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
import { normalizeEmail } from "@calcom/features/watchlist/lib/utils/normalization";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TAdminLockUserAccountSchema } from "./lockUserAccount.schema";
@@ -25,6 +25,7 @@ const lockUserAccountHandler = async ({ input }: GetOptions) => {
select: {
id: true,
email: true,
username: true,
},
});
@@ -39,6 +40,11 @@ const lockUserAccountHandler = async ({ input }: GetOptions) => {
if (watchlistEntry) {
await globalWatchlistRepo.deleteEntry(watchlistEntry.id);
}
await sendEmailVerification({
email: user.email,
username: user.username || "",
});
}
return {