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:
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 {
|
||||
|
||||
Reference in new issue
Block a user