diff --git a/apps/web/modules/settings/organizations/privacy/components/blocked-entries-columns.tsx b/apps/web/modules/settings/organizations/privacy/components/blocked-entries-columns.tsx
index cd81c18ad0..1f876cbbab 100644
--- a/apps/web/modules/settings/organizations/privacy/components/blocked-entries-columns.tsx
+++ b/apps/web/modules/settings/organizations/privacy/components/blocked-entries-columns.tsx
@@ -50,13 +50,13 @@ export function useBlockedEntriesColumns({
header: t("blocked_by"),
size: 180,
cell: ({ row }) => {
- const audit = row.original.audits?.[0] as
+ const audit = row.original.latestAudit as
| { changedByUserId: number | null }
| {
changedByUser?: { id: number; email: string; name: string | null } | undefined;
changedByUserId: number | null;
}
- | undefined;
+ | null;
const email =
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
return {email ?? "—"};
diff --git a/packages/features/di/watchlist/Watchlist.tokens.ts b/packages/features/di/watchlist/Watchlist.tokens.ts
index 9321e9c408..50573628a6 100644
--- a/packages/features/di/watchlist/Watchlist.tokens.ts
+++ b/packages/features/di/watchlist/Watchlist.tokens.ts
@@ -5,6 +5,14 @@ export const WATCHLIST_DI_TOKENS = {
ORGANIZATION_BLOCKING_SERVICE: Symbol("OrganizationBlockingService"),
AUDIT_SERVICE: Symbol("WatchlistAuditService"),
+ // Operations services
+ ADMIN_WATCHLIST_OPERATIONS_SERVICE: Symbol("AdminWatchlistOperationsService"),
+ ORGANIZATION_WATCHLIST_OPERATIONS_SERVICE: Symbol("OrganizationWatchlistOperationsService"),
+
+ // Query services
+ ADMIN_WATCHLIST_QUERY_SERVICE: Symbol("AdminWatchlistQueryService"),
+ ORGANIZATION_WATCHLIST_QUERY_SERVICE: Symbol("OrganizationWatchlistQueryService"),
+
// Repositories
GLOBAL_WATCHLIST_REPOSITORY: Symbol("GlobalWatchlistRepository"),
ORGANIZATION_WATCHLIST_REPOSITORY: Symbol("OrganizationWatchlistRepository"),
diff --git a/packages/features/di/watchlist/containers/watchlist.ts b/packages/features/di/watchlist/containers/watchlist.ts
index df59a4378b..69746e2283 100644
--- a/packages/features/di/watchlist/containers/watchlist.ts
+++ b/packages/features/di/watchlist/containers/watchlist.ts
@@ -1,18 +1,27 @@
import { createContainer } from "@evyweb/ioctopus";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
import { taskerServiceModule } from "@calcom/features/di/shared/services/tasker.service";
import { SHARED_TOKENS } from "@calcom/features/di/shared/shared.tokens";
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
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 { AdminWatchlistOperationsService } from "@calcom/features/watchlist/lib/service/AdminWatchlistOperationsService";
+import { AdminWatchlistQueryService } from "@calcom/features/watchlist/lib/service/AdminWatchlistQueryService";
import type { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
import type { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
+import { OrganizationWatchlistOperationsService } from "@calcom/features/watchlist/lib/service/OrganizationWatchlistOperationsService";
+import { OrganizationWatchlistQueryService } from "@calcom/features/watchlist/lib/service/OrganizationWatchlistQueryService";
import type { WatchlistAuditService } from "@calcom/features/watchlist/lib/service/WatchlistAuditService";
import type { WatchlistService } from "@calcom/features/watchlist/lib/service/WatchlistService";
+import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
+import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
+import { prisma } from "@calcom/prisma";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
@@ -64,3 +73,53 @@ export function getOrganizationWatchlistRepository() {
export async function getWatchlistFeature(): Promise {
return createWatchlistFeature(watchlistContainer);
}
+
+export function getAdminWatchlistOperationsService(): AdminWatchlistOperationsService {
+ const watchlistRepo = new WatchlistRepository(prisma);
+ const bookingReportRepo = new PrismaBookingReportRepository(prisma);
+
+ return new AdminWatchlistOperationsService({
+ watchlistRepo,
+ bookingReportRepo,
+ });
+}
+
+export function getOrganizationWatchlistOperationsService(
+ organizationId: number
+): OrganizationWatchlistOperationsService {
+ const watchlistRepo = new WatchlistRepository(prisma);
+ const bookingReportRepo = new PrismaBookingReportRepository(prisma);
+ const permissionCheckService = new PermissionCheckService();
+
+ return new OrganizationWatchlistOperationsService({
+ watchlistRepo,
+ bookingReportRepo,
+ permissionCheckService,
+ organizationId,
+ });
+}
+
+export function getAdminWatchlistQueryService(): AdminWatchlistQueryService {
+ const watchlistRepo = new WatchlistRepository(prisma);
+ const bookingReportRepo = new PrismaBookingReportRepository(prisma);
+ const userRepo = new UserRepository(prisma);
+
+ return new AdminWatchlistQueryService({
+ watchlistRepo,
+ bookingReportRepo,
+ userRepo,
+ prisma,
+ });
+}
+
+export function getOrganizationWatchlistQueryService(): OrganizationWatchlistQueryService {
+ const watchlistRepo = new WatchlistRepository(prisma);
+ const userRepo = new UserRepository(prisma);
+ const permissionCheckService = new PermissionCheckService();
+
+ return new OrganizationWatchlistQueryService({
+ watchlistRepo,
+ userRepo,
+ permissionCheckService,
+ });
+}
diff --git a/packages/features/watchlist/lib/errors/WatchlistErrors.ts b/packages/features/watchlist/lib/errors/WatchlistErrors.ts
new file mode 100644
index 0000000000..7c1220e484
--- /dev/null
+++ b/packages/features/watchlist/lib/errors/WatchlistErrors.ts
@@ -0,0 +1,49 @@
+export enum WatchlistErrorCode {
+ NOT_FOUND = "NOT_FOUND",
+ UNAUTHORIZED = "UNAUTHORIZED",
+ ALREADY_IN_WATCHLIST = "ALREADY_IN_WATCHLIST",
+ INVALID_EMAIL = "INVALID_EMAIL",
+ INVALID_DOMAIN = "INVALID_DOMAIN",
+ INVALID_IP = "INVALID_IP",
+ VALIDATION_ERROR = "VALIDATION_ERROR",
+ PERMISSION_DENIED = "PERMISSION_DENIED",
+ DUPLICATE_ENTRY = "DUPLICATE_ENTRY",
+ BULK_DELETE_PARTIAL_FAILURE = "BULK_DELETE_PARTIAL_FAILURE",
+}
+
+export class WatchlistError extends Error {
+ constructor(
+ public readonly code: WatchlistErrorCode,
+ message: string
+ ) {
+ super(message);
+ this.name = "WatchlistError";
+ }
+}
+
+export const WatchlistErrors = {
+ notFound: (message: string) => new WatchlistError(WatchlistErrorCode.NOT_FOUND, message),
+
+ unauthorized: (message: string) => new WatchlistError(WatchlistErrorCode.UNAUTHORIZED, message),
+
+ alreadyInWatchlist: (message: string) =>
+ new WatchlistError(WatchlistErrorCode.ALREADY_IN_WATCHLIST, message),
+
+ invalidEmail: (message: string) => new WatchlistError(WatchlistErrorCode.INVALID_EMAIL, message),
+
+ invalidDomain: (message: string) => new WatchlistError(WatchlistErrorCode.INVALID_DOMAIN, message),
+
+ invalidIp: (message: string) => new WatchlistError(WatchlistErrorCode.INVALID_IP, message),
+
+ validationError: (message: string) =>
+ new WatchlistError(WatchlistErrorCode.VALIDATION_ERROR, message),
+
+ permissionDenied: (message: string) =>
+ new WatchlistError(WatchlistErrorCode.PERMISSION_DENIED, message),
+
+ duplicateEntry: (message: string) =>
+ new WatchlistError(WatchlistErrorCode.DUPLICATE_ENTRY, message),
+
+ bulkDeletePartialFailure: (message: string) =>
+ new WatchlistError(WatchlistErrorCode.BULK_DELETE_PARTIAL_FAILURE, message),
+};
diff --git a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts
new file mode 100644
index 0000000000..4182ac2395
--- /dev/null
+++ b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts
@@ -0,0 +1,214 @@
+import logger from "@calcom/lib/logger";
+import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
+import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
+import { BookingReportStatus } from "@calcom/prisma/enums";
+
+import { WatchlistErrors } from "../errors/WatchlistErrors";
+import type {
+ DeleteWatchlistEntryInput,
+ DeleteWatchlistEntryResult,
+ WatchlistOperationsScope,
+} from "./WatchlistOperationsService";
+import { WatchlistOperationsService } from "./WatchlistOperationsService";
+
+export interface BulkDeleteWatchlistEntriesInput {
+ entryIds: string[];
+ userId: number;
+}
+
+export interface BulkDeleteWatchlistEntriesResult {
+ success: number;
+ failed: number;
+ message: string;
+}
+
+export interface DismissReportInput {
+ reportId: string;
+}
+
+export interface DismissReportResult {
+ success: boolean;
+}
+
+export interface BulkDismissReportsInput {
+ reportIds: string[];
+}
+
+export interface BulkDismissReportsResult {
+ success: number;
+ failed: number;
+ message: string;
+}
+
+type Deps = {
+ watchlistRepo: WatchlistRepository;
+ bookingReportRepo: PrismaBookingReportRepository;
+};
+
+export class AdminWatchlistOperationsService extends WatchlistOperationsService {
+ private adminLog = logger.getSubLogger({ prefix: ["AdminWatchlistOperationsService"] });
+
+ constructor(deps: Deps) {
+ super(deps);
+ }
+
+ protected getScope(): WatchlistOperationsScope {
+ return {
+ organizationId: null,
+ isGlobal: true,
+ };
+ }
+
+ protected async findReports(
+ reportIds: string[]
+ ): Promise> {
+ const reports = await this.deps.bookingReportRepo.findReportsByIds({
+ reportIds,
+ });
+
+ return reports;
+ }
+
+ async bulkDeleteWatchlistEntries(
+ input: BulkDeleteWatchlistEntriesInput
+ ): Promise {
+ const entries = await this.deps.watchlistRepo.findEntriesByIds(input.entryIds);
+
+ const entryMap = new Map(entries.map((e) => [e.id, e]));
+ const validIds: string[] = [];
+ const failed: Array<{ id: string; reason: string }> = [];
+
+ for (const id of input.entryIds) {
+ const entry = entryMap.get(id);
+
+ if (!entry) {
+ failed.push({ id, reason: "Entry not found" });
+ continue;
+ }
+
+ if (!entry.isGlobal || entry.organizationId !== null) {
+ failed.push({ id, reason: "Can only delete system blocklist entries" });
+ continue;
+ }
+
+ validIds.push(id);
+ }
+
+ let successCount = 0;
+ if (validIds.length > 0) {
+ const result = await this.deps.watchlistRepo.bulkDeleteEntries({
+ ids: validIds,
+ userId: input.userId,
+ });
+ successCount = result.deleted;
+ }
+
+ 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}`);
+ }
+
+ return {
+ success: successCount,
+ failed: failed.length,
+ // TODO: use translate keys in follow up frontend PR
+ message:
+ failed.length === 0
+ ? "All entries deleted successfully"
+ : `Deleted ${successCount} entries, ${failed.length} failed`,
+ };
+ }
+
+ async deleteWatchlistEntry(input: DeleteWatchlistEntryInput): Promise {
+ const { entry } = await this.deps.watchlistRepo.findEntryWithAuditAndReports(input.entryId);
+
+ if (!entry) {
+ throw WatchlistErrors.notFound("Blocklist entry not found");
+ }
+
+ if (!entry.isGlobal || entry.organizationId !== null) {
+ throw WatchlistErrors.permissionDenied("You can only delete system blocklist entries");
+ }
+
+ await this.deps.watchlistRepo.deleteEntry(input.entryId, input.userId);
+
+ return {
+ success: true,
+ message: "Entry deleted successfully",
+ };
+ }
+
+ async dismissReport(input: DismissReportInput): Promise {
+ const reports = await this.deps.bookingReportRepo.findReportsByIds({
+ reportIds: [input.reportId],
+ });
+
+ if (reports.length === 0) {
+ throw WatchlistErrors.notFound("Report not found");
+ }
+
+ const report = reports[0];
+
+ if (report.watchlistId) {
+ throw WatchlistErrors.validationError(
+ "Cannot dismiss a report that has already been added to the blocklist"
+ );
+ }
+
+ await this.deps.bookingReportRepo.updateReportStatus({
+ reportId: input.reportId,
+ status: BookingReportStatus.DISMISSED,
+ });
+
+ return { success: true };
+ }
+
+ async bulkDismissReports(input: BulkDismissReportsInput): Promise {
+ const reports = await this.deps.bookingReportRepo.findReportsByIds({
+ reportIds: input.reportIds,
+ });
+
+ const reportMap = new Map(reports.map((r) => [r.id, r]));
+ const validReportIds: string[] = [];
+ const failed: Array<{ id: string; reason: string }> = [];
+
+ for (const reportId of input.reportIds) {
+ const report = reportMap.get(reportId);
+
+ if (!report) {
+ failed.push({ id: reportId, reason: "Report not found" });
+ continue;
+ }
+
+ if (report.watchlistId) {
+ failed.push({ id: reportId, reason: "Already added to blocklist" });
+ continue;
+ }
+
+ validReportIds.push(reportId);
+ }
+
+ let successCount = 0;
+ if (validReportIds.length > 0) {
+ const result = await this.deps.bookingReportRepo.bulkUpdateReportStatus({
+ reportIds: validReportIds,
+ status: BookingReportStatus.DISMISSED,
+ });
+ successCount = result.updated;
+ }
+
+ if (successCount === 0 && failed.length > 0) {
+ this.adminLog.error("Bulk dismiss reports failures", { failed });
+ throw WatchlistErrors.bulkDeletePartialFailure(`Failed to dismiss all reports: ${failed[0].reason}`);
+ }
+
+ return {
+ success: successCount,
+ failed: failed.length,
+ message:
+ failed.length === 0
+ ? "All reports dismissed successfully"
+ : `Dismissed ${successCount} reports, ${failed.length} failed`,
+ };
+ }
+}
diff --git a/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts b/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts
new file mode 100644
index 0000000000..d99eecbe0e
--- /dev/null
+++ b/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts
@@ -0,0 +1,132 @@
+import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import type { ListBookingReportsFilters } from "@calcom/lib/server/repository/bookingReport.interface";
+import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
+import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
+import type { PrismaClient } from "@calcom/prisma";
+import type { WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
+
+import { WatchlistErrors } from "../errors/WatchlistErrors";
+
+export interface ListWatchlistEntriesInput {
+ limit: number;
+ offset: number;
+ searchTerm?: string;
+ filters?: {
+ type?: WatchlistType;
+ source?: WatchlistSource;
+ };
+}
+
+export interface GetWatchlistEntryDetailsInput {
+ entryId: string;
+}
+
+export interface ListBookingReportsInput {
+ limit: number;
+ offset: number;
+ searchTerm?: string;
+ filters?: ListBookingReportsFilters;
+}
+
+type Deps = {
+ watchlistRepo: WatchlistRepository;
+ bookingReportRepo: PrismaBookingReportRepository;
+ userRepo: UserRepository;
+ prisma: PrismaClient;
+};
+
+export class AdminWatchlistQueryService {
+ constructor(private readonly deps: Deps) {}
+
+ async listWatchlistEntries(input: ListWatchlistEntriesInput) {
+ const { rows, meta } = await this.deps.watchlistRepo.findAllEntriesWithLatestAudit({
+ organizationId: null,
+ isGlobal: true,
+ limit: input.limit,
+ offset: input.offset,
+ searchTerm: input.searchTerm,
+ filters: input.filters,
+ });
+
+ const userIds = rows
+ .map((entry) => entry.latestAudit?.changedByUserId)
+ .filter((id): id is number => id !== null && id !== undefined);
+
+ const uniqueUserIds = Array.from(new Set(userIds));
+
+ const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
+
+ const userMap = new Map(users.map((u) => [u.id, u]));
+
+ const rowsWithCreators = rows.map((entry) => {
+ if (entry.latestAudit?.changedByUserId) {
+ const changedByUser = userMap.get(entry.latestAudit.changedByUserId);
+ return {
+ ...entry,
+ latestAudit: {
+ ...entry.latestAudit,
+ changedByUser,
+ },
+ };
+ }
+ return entry;
+ });
+
+ return {
+ rows: rowsWithCreators,
+ meta,
+ };
+ }
+
+ async getWatchlistEntryDetails(input: GetWatchlistEntryDetailsInput) {
+ const result = await this.deps.watchlistRepo.findEntryWithAuditAndReports(input.entryId);
+
+ if (!result.entry) {
+ throw WatchlistErrors.notFound("Blocklist entry not found");
+ }
+
+ if (!result.entry.isGlobal || result.entry.organizationId !== null) {
+ throw WatchlistErrors.permissionDenied("You can only view system blocklist entries");
+ }
+
+ const userIds = result.auditHistory
+ .map((audit) => audit.changedByUserId)
+ .filter((id): id is number => id !== null && id !== undefined);
+
+ const uniqueUserIds = Array.from(new Set(userIds));
+
+ const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
+
+ const userMap = new Map(users.map((u) => [u.id, u]));
+
+ const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
+ ...audit,
+ changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
+ }));
+
+ return {
+ entry: result.entry,
+ auditHistory: auditHistoryWithUsers,
+ };
+ }
+
+ async listBookingReports(input: ListBookingReportsInput) {
+ const result = await this.deps.bookingReportRepo.findAllReportedBookings({
+ skip: input.offset,
+ take: input.limit,
+ searchTerm: input.searchTerm,
+ filters: input.filters,
+ });
+
+ return result;
+ }
+
+ async getPendingReportsCount(): Promise {
+ return this.deps.prisma.bookingReport.count({
+ where: {
+ status: "PENDING",
+ watchlistId: null,
+ },
+ });
+ }
+}
diff --git a/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts b/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts
new file mode 100644
index 0000000000..f70adb8e90
--- /dev/null
+++ b/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts
@@ -0,0 +1,81 @@
+import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
+import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
+import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { WatchlistErrors } from "../errors/WatchlistErrors";
+import type {
+ AddReportsToWatchlistInput,
+ AddReportsToWatchlistResult,
+ CreateWatchlistEntryInput,
+ CreateWatchlistEntryResult,
+ DeleteWatchlistEntryInput,
+ DeleteWatchlistEntryResult,
+ WatchlistOperationsScope,
+} from "./WatchlistOperationsService";
+import { WatchlistOperationsService } from "./WatchlistOperationsService";
+
+type Deps = {
+ watchlistRepo: WatchlistRepository;
+ bookingReportRepo: PrismaBookingReportRepository;
+ permissionCheckService: PermissionCheckService;
+ organizationId: number;
+};
+
+export class OrganizationWatchlistOperationsService extends WatchlistOperationsService {
+ private readonly permissionCheckService: PermissionCheckService;
+ private readonly organizationId: number;
+
+ constructor(deps: Deps) {
+ super(deps);
+ this.permissionCheckService = deps.permissionCheckService;
+ this.organizationId = deps.organizationId;
+ }
+
+ protected getScope(): WatchlistOperationsScope {
+ return {
+ organizationId: this.organizationId,
+ isGlobal: false,
+ };
+ }
+
+ protected async findReports(
+ reportIds: string[]
+ ): Promise> {
+ const reports = await this.deps.bookingReportRepo.findReportsByIds({
+ reportIds,
+ organizationId: this.organizationId,
+ });
+
+ return reports;
+ }
+
+ private async checkPermission(userId: number, permission: PermissionString): Promise {
+ const hasPermission = await this.permissionCheckService.checkPermission({
+ userId,
+ teamId: this.organizationId,
+ permission,
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+
+ if (!hasPermission) {
+ throw WatchlistErrors.permissionDenied(`You are not authorized to ${permission.split(".")[1]} watchlist entries`);
+ }
+ }
+
+ async addReportsToWatchlist(input: AddReportsToWatchlistInput): Promise {
+ await this.checkPermission(input.userId, "watchlist.create");
+ return super.addReportsToWatchlist(input);
+ }
+
+ async createWatchlistEntry(input: CreateWatchlistEntryInput): Promise {
+ await this.checkPermission(input.userId, "watchlist.create");
+ return super.createWatchlistEntry(input);
+ }
+
+ async deleteWatchlistEntry(input: DeleteWatchlistEntryInput): Promise {
+ await this.checkPermission(input.userId, "watchlist.delete");
+ return super.deleteWatchlistEntry(input);
+ }
+}
diff --git a/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts b/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts
new file mode 100644
index 0000000000..f64a7c5ee7
--- /dev/null
+++ b/packages/features/watchlist/lib/service/OrganizationWatchlistQueryService.ts
@@ -0,0 +1,122 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
+import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
+import { MembershipRole, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
+
+import { WatchlistErrors } from "../errors/WatchlistErrors";
+
+export interface ListWatchlistEntriesInput {
+ organizationId: number;
+ userId: number;
+ limit: number;
+ offset: number;
+ searchTerm?: string;
+ filters?: {
+ type?: WatchlistType;
+ source?: WatchlistSource;
+ };
+}
+
+export interface GetWatchlistEntryDetailsInput {
+ organizationId: number;
+ userId: number;
+ entryId: string;
+}
+
+type Deps = {
+ watchlistRepo: WatchlistRepository;
+ userRepo: UserRepository;
+ permissionCheckService: PermissionCheckService;
+};
+
+export class OrganizationWatchlistQueryService {
+ constructor(private readonly deps: Deps) {}
+
+ private async checkReadPermission(userId: number, organizationId: number): Promise {
+ const hasPermission = await this.deps.permissionCheckService.checkPermission({
+ userId,
+ teamId: organizationId,
+ permission: "watchlist.read",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+
+ if (!hasPermission) {
+ throw WatchlistErrors.permissionDenied("You are not authorized to view watchlist entries");
+ }
+ }
+
+ async listWatchlistEntries(input: ListWatchlistEntriesInput) {
+ await this.checkReadPermission(input.userId, input.organizationId);
+
+ const result = await this.deps.watchlistRepo.findAllEntriesWithLatestAudit({
+ organizationId: input.organizationId,
+ limit: input.limit,
+ offset: input.offset,
+ searchTerm: input.searchTerm,
+ filters: input.filters,
+ });
+
+ const userIds = result.rows
+ .map((entry) => entry.latestAudit?.changedByUserId)
+ .filter((id): id is number => id !== null && id !== undefined);
+
+ const uniqueUserIds = Array.from(new Set(userIds));
+
+ const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
+
+ const userMap = new Map(users.map((u) => [u.id, u]));
+
+ const rowsWithCreators = result.rows.map((entry) => {
+ if (entry.latestAudit?.changedByUserId) {
+ const changedByUser = userMap.get(entry.latestAudit.changedByUserId);
+ return {
+ ...entry,
+ latestAudit: {
+ ...entry.latestAudit,
+ changedByUser,
+ },
+ };
+ }
+ return entry;
+ });
+
+ return {
+ rows: rowsWithCreators,
+ meta: result.meta,
+ };
+ }
+
+ async getWatchlistEntryDetails(input: GetWatchlistEntryDetailsInput) {
+ await this.checkReadPermission(input.userId, input.organizationId);
+
+ const result = await this.deps.watchlistRepo.findEntryWithAuditAndReports(input.entryId);
+
+ if (!result.entry) {
+ throw WatchlistErrors.notFound("Blocklist entry not found");
+ }
+
+ if (result.entry.organizationId !== input.organizationId) {
+ throw WatchlistErrors.permissionDenied("You can only view blocklist entries from your organization");
+ }
+
+ const userIds = result.auditHistory
+ .map((audit) => audit.changedByUserId)
+ .filter((id): id is number => id !== null && id !== undefined);
+
+ const uniqueUserIds = Array.from(new Set(userIds));
+
+ const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
+
+ const userMap = new Map(users.map((u) => [u.id, u]));
+
+ const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
+ ...audit,
+ changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
+ }));
+
+ return {
+ entry: result.entry,
+ auditHistory: auditHistoryWithUsers,
+ };
+ }
+}
diff --git a/packages/features/watchlist/lib/service/WatchlistOperationsService.ts b/packages/features/watchlist/lib/service/WatchlistOperationsService.ts
new file mode 100644
index 0000000000..7fbb047667
--- /dev/null
+++ b/packages/features/watchlist/lib/service/WatchlistOperationsService.ts
@@ -0,0 +1,179 @@
+import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
+import logger from "@calcom/lib/logger";
+import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
+import type { WatchlistEntry } from "@calcom/lib/server/repository/watchlist.interface";
+import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
+import { WatchlistAction, WatchlistType } from "@calcom/prisma/enums";
+
+import { WatchlistErrors } from "../errors/WatchlistErrors";
+import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization";
+
+export interface AddReportsToWatchlistInput {
+ reportIds: string[];
+ type: WatchlistType;
+ description?: string;
+ userId: number;
+}
+
+export interface AddReportsToWatchlistResult {
+ success: boolean;
+ message: string;
+ addedCount: number;
+ skippedCount: number;
+ results: Array<{ reportId: string; watchlistId: string }>;
+}
+
+export interface CreateWatchlistEntryInput {
+ type: WatchlistType;
+ value: string;
+ description?: string;
+ userId: number;
+}
+
+export interface CreateWatchlistEntryResult {
+ success: boolean;
+ entry: WatchlistEntry;
+}
+
+export interface DeleteWatchlistEntryInput {
+ entryId: string;
+ userId: number;
+}
+
+export interface DeleteWatchlistEntryResult {
+ success: boolean;
+ message: string;
+}
+
+export interface WatchlistOperationsScope {
+ organizationId: number | null;
+ isGlobal: boolean;
+}
+
+type Deps = {
+ watchlistRepo: WatchlistRepository;
+ bookingReportRepo: PrismaBookingReportRepository;
+};
+
+export abstract class WatchlistOperationsService {
+ private log = logger.getSubLogger({ prefix: ["WatchlistOperationsService"] });
+
+ constructor(protected readonly deps: Deps) {}
+
+ protected abstract getScope(): WatchlistOperationsScope;
+
+ protected abstract findReports(
+ reportIds: string[]
+ ): Promise>;
+
+ validateEmailOrDomain(type: WatchlistType, value: string): void {
+ if (type === WatchlistType.EMAIL && !emailRegex.test(value)) {
+ throw WatchlistErrors.invalidEmail("Invalid email address format");
+ }
+
+ if (type === WatchlistType.DOMAIN && !domainRegex.test(value)) {
+ throw WatchlistErrors.invalidDomain("Invalid domain format (e.g., example.com)");
+ }
+ }
+
+ async addReportsToWatchlist(input: AddReportsToWatchlistInput): Promise {
+ const scope = this.getScope();
+
+ const validReports = await this.findReports(input.reportIds);
+
+ if (validReports.length !== input.reportIds.length) {
+ const foundIds = new Set(validReports.map((r) => r.id));
+ const missingIds = input.reportIds.filter((id) => !foundIds.has(id));
+ throw WatchlistErrors.notFound(`Report(s) not found: ${missingIds.join(", ")}`);
+ }
+
+ const reportsToAdd = validReports.filter((report) => !report.watchlistId);
+
+ if (reportsToAdd.length === 0) {
+ throw WatchlistErrors.alreadyInWatchlist("All selected reports are already in the watchlist");
+ }
+
+ const normalizedValues = new Map();
+ try {
+ for (const report of reportsToAdd) {
+ const value =
+ input.type === WatchlistType.EMAIL
+ ? normalizeEmail(report.bookerEmail)
+ : extractDomainFromEmail(report.bookerEmail);
+ normalizedValues.set(report.id, value);
+ }
+ } catch (error) {
+ this.log.error("Email normalization failed", {
+ error: error instanceof Error ? error.message : String(error),
+ reportIds: reportsToAdd.map((r) => r.id),
+ });
+ throw WatchlistErrors.invalidEmail(error instanceof Error ? error.message : "Invalid email format");
+ }
+
+ const results = await Promise.all(
+ reportsToAdd.map(async (report) => {
+ const normalizedValue = normalizedValues.get(report.id);
+ if (!normalizedValue) {
+ throw new Error("Unable to process the selected report. Please try again.");
+ }
+
+ const { watchlistEntry } = await this.deps.watchlistRepo.createEntryFromReport({
+ type: input.type,
+ value: normalizedValue,
+ organizationId: scope.organizationId,
+ isGlobal: scope.isGlobal,
+ userId: input.userId,
+ description: input.description,
+ });
+
+ return {
+ reportId: report.id,
+ watchlistId: watchlistEntry.id,
+ };
+ })
+ );
+
+ await this.deps.bookingReportRepo.bulkLinkWatchlistWithStatus({
+ links: results.map((r) => ({ reportId: r.reportId, watchlistId: r.watchlistId })),
+ status: "BLOCKED",
+ });
+
+ return {
+ success: true,
+ message: `Successfully added ${results.length} report(s) to watchlist`,
+ addedCount: results.length,
+ skippedCount: validReports.length - reportsToAdd.length,
+ results,
+ };
+ }
+
+ async createWatchlistEntry(input: CreateWatchlistEntryInput): Promise {
+ const scope = this.getScope();
+
+ this.validateEmailOrDomain(input.type, input.value);
+
+ const entry = await this.deps.watchlistRepo.createEntryIfNotExists({
+ type: input.type,
+ value: input.value.toLowerCase(),
+ organizationId: scope.organizationId,
+ action: WatchlistAction.BLOCK,
+ description: input.description,
+ userId: input.userId,
+ isGlobal: scope.isGlobal,
+ });
+
+ return {
+ success: true,
+ entry,
+ };
+ }
+
+ async deleteWatchlistEntry(input: DeleteWatchlistEntryInput): Promise {
+ await this.deps.watchlistRepo.deleteEntry(input.entryId, input.userId);
+
+ return {
+ success: true,
+ message: "Entry deleted successfully",
+ };
+ }
+}
diff --git a/packages/lib/server/repository/bookingReport.interface.ts b/packages/lib/server/repository/bookingReport.interface.ts
index 0bf2d473e3..9ec81b037e 100644
--- a/packages/lib/server/repository/bookingReport.interface.ts
+++ b/packages/lib/server/repository/bookingReport.interface.ts
@@ -55,6 +55,11 @@ export interface BookingReportWithDetails {
action: string;
description: string | null;
} | null;
+ organization: {
+ id: number;
+ name: string;
+ slug: string | null;
+ } | null;
}
export interface IBookingReportRepository {
@@ -84,5 +89,16 @@ export interface IBookingReportRepository {
organizationId?: number;
}): Promise;
+ bulkUpdateReportStatus(params: {
+ reportIds: string[];
+ status: BookingReportStatus;
+ organizationId?: number;
+ }): Promise<{ updated: number }>;
+
+ bulkLinkWatchlistWithStatus(params: {
+ links: Array<{ reportId: string; watchlistId: string }>;
+ status: BookingReportStatus;
+ }): Promise;
+
countPendingReports(params: { organizationId: number }): Promise;
}
diff --git a/packages/lib/server/repository/bookingReport.ts b/packages/lib/server/repository/bookingReport.ts
index acec86866f..e42d375b8c 100644
--- a/packages/lib/server/repository/bookingReport.ts
+++ b/packages/lib/server/repository/bookingReport.ts
@@ -1,4 +1,5 @@
import type { PrismaClient } from "@calcom/prisma";
+import type { BookingReportStatus } from "@calcom/prisma/enums";
import type { Prisma } from "@calcom/prisma/generated/prisma/client";
import type {
@@ -86,6 +87,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
createdAt: true,
status: true,
watchlistId: true,
+ organizationId: true,
reportedBy: {
select: {
id: true,
@@ -111,6 +113,13 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
description: true,
},
},
+ organization: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
},
orderBy: { createdAt: "desc" },
}),
@@ -153,6 +162,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
createdAt: true,
status: true,
watchlistId: true,
+ organizationId: true,
reportedBy: {
select: {
id: true,
@@ -178,6 +188,13 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
description: true,
},
},
+ organization: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ },
+ },
},
});
@@ -196,7 +213,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
async updateReportStatus(params: {
reportId: string;
- status: "PENDING" | "DISMISSED" | "BLOCKED";
+ status: BookingReportStatus;
organizationId?: number;
}): Promise {
const where: Prisma.BookingReportWhereInput = {
@@ -213,6 +230,50 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
});
}
+ async bulkUpdateReportStatus(params: {
+ reportIds: string[];
+ status: BookingReportStatus;
+ organizationId?: number;
+ }): Promise<{ updated: number }> {
+ const where: Prisma.BookingReportWhereInput = {
+ id: { in: params.reportIds },
+ };
+
+ if (params.organizationId !== undefined) {
+ where.organizationId = params.organizationId;
+ }
+
+ const result = await this.prismaClient.bookingReport.updateMany({
+ where,
+ data: { status: params.status },
+ });
+
+ return { updated: result.count };
+ }
+
+ async bulkLinkWatchlistWithStatus(params: {
+ links: Array<{ reportId: string; watchlistId: string }>;
+ status: BookingReportStatus;
+ }): Promise {
+ if (params.links.length === 0) return;
+
+ const groupedByWatchlist = new Map();
+ for (const link of params.links) {
+ const reportIds = groupedByWatchlist.get(link.watchlistId) || [];
+ reportIds.push(link.reportId);
+ groupedByWatchlist.set(link.watchlistId, reportIds);
+ }
+
+ await Promise.all(
+ Array.from(groupedByWatchlist.entries()).map(([watchlistId, reportIds]) =>
+ this.prismaClient.bookingReport.updateMany({
+ where: { id: { in: reportIds } },
+ data: { watchlistId, status: params.status },
+ })
+ )
+ );
+ }
+
async countPendingReports(params: { organizationId: number }): Promise {
return this.prismaClient.bookingReport.count({
where: {
diff --git a/packages/lib/server/repository/watchlist.interface.ts b/packages/lib/server/repository/watchlist.interface.ts
index 1dc79dc196..100dc8f61a 100644
--- a/packages/lib/server/repository/watchlist.interface.ts
+++ b/packages/lib/server/repository/watchlist.interface.ts
@@ -43,21 +43,24 @@ export interface CheckWatchlistInput {
}
export interface FindAllEntriesInput {
- organizationId: number;
+ organizationId?: number | null;
+ isGlobal?: boolean;
limit: number;
offset: number;
searchTerm?: string;
filters?: {
type?: WatchlistType;
+ source?: WatchlistSource;
};
}
export interface IWatchlistRepository {
createEntry(params: CreateWatchlistInput): Promise;
+ createEntryIfNotExists(params: CreateWatchlistInput): Promise;
checkExists(params: CheckWatchlistInput): Promise;
- findAllEntries(params: FindAllEntriesInput): Promise<{
+ findAllEntriesWithLatestAudit(params: FindAllEntriesInput): Promise<{
rows: (WatchlistEntry & {
- audits?: { changedByUserId: number | null }[];
+ latestAudit: { changedByUserId: number | null } | null;
})[];
meta: { totalRowCount: number };
}>;
@@ -74,5 +77,13 @@ export interface IWatchlistRepository {
| null;
auditHistory: WatchlistAuditEntry[];
}>;
+ findEntriesByIds(ids: string[]): Promise<
+ Array<{
+ id: string;
+ isGlobal: boolean;
+ organizationId: number | null;
+ }>
+ >;
deleteEntry(id: string, userId: number): Promise;
+ bulkDeleteEntries(params: { ids: string[]; userId: number }): Promise<{ deleted: number }>;
}
diff --git a/packages/lib/server/repository/watchlist.repository.ts b/packages/lib/server/repository/watchlist.repository.ts
index f1bab3be4a..145aa901ef 100644
--- a/packages/lib/server/repository/watchlist.repository.ts
+++ b/packages/lib/server/repository/watchlist.repository.ts
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@calcom/prisma";
-import { WatchlistSource } from "@calcom/prisma/enums";
+import { WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
import type {
IWatchlistRepository,
@@ -14,17 +14,6 @@ export class WatchlistRepository implements IWatchlistRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async createEntry(params: CreateWatchlistInput): Promise {
- const existing = await this.checkExists({
- type: params.type,
- value: params.value,
- organizationId: params.organizationId,
- isGlobal: params.isGlobal,
- });
-
- if (existing) {
- throw new Error("Watchlist entry already exists for this organization");
- }
-
const watchlist = await this.prismaClient.$transaction(async (tx) => {
const created = await tx.watchlist.create({
data: {
@@ -55,6 +44,21 @@ export class WatchlistRepository implements IWatchlistRepository {
return watchlist;
}
+ async createEntryIfNotExists(params: CreateWatchlistInput): Promise {
+ const existing = await this.checkExists({
+ type: params.type,
+ value: params.value,
+ organizationId: params.organizationId,
+ isGlobal: params.isGlobal,
+ });
+
+ if (existing) {
+ return existing;
+ }
+
+ return this.createEntry(params);
+ }
+
async checkExists(params: CheckWatchlistInput): Promise {
if (!params.isGlobal && !params.organizationId) {
throw new Error("Both isGlobal and organizationId are missing");
@@ -87,12 +91,13 @@ export class WatchlistRepository implements IWatchlistRepository {
return null;
}
- async findAllEntries(params: FindAllEntriesInput): Promise<{
- rows: (WatchlistEntry & { audits?: { changedByUserId: number | null }[] })[];
+ async findAllEntriesWithLatestAudit(params: FindAllEntriesInput): Promise<{
+ rows: (WatchlistEntry & { latestAudit: { changedByUserId: number | null } | null })[];
meta: { totalRowCount: number };
}> {
const where = {
organizationId: params.organizationId,
+ isGlobal: params.isGlobal,
...(params.searchTerm && {
value: {
contains: params.searchTerm,
@@ -102,9 +107,12 @@ export class WatchlistRepository implements IWatchlistRepository {
...(params.filters?.type && {
type: params.filters.type,
}),
+ ...(params.filters?.source && {
+ source: params.filters.source,
+ }),
};
- const [rows, totalRowCount] = await Promise.all([
+ const [entries, totalRowCount] = await Promise.all([
this.prismaClient.watchlist.findMany({
where,
take: params.limit,
@@ -136,6 +144,12 @@ export class WatchlistRepository implements IWatchlistRepository {
this.prismaClient.watchlist.count({ where }),
]);
+ const rows = entries.map((entry) => ({
+ ...entry,
+ latestAudit: entry.audits[0] ?? null,
+ audits: undefined,
+ }));
+
return {
rows,
meta: { totalRowCount },
@@ -214,6 +228,23 @@ export class WatchlistRepository implements IWatchlistRepository {
};
}
+ async findEntriesByIds(ids: string[]): Promise<
+ Array<{
+ id: string;
+ isGlobal: boolean;
+ organizationId: number | null;
+ }>
+ > {
+ return this.prismaClient.watchlist.findMany({
+ where: { id: { in: ids } },
+ select: {
+ id: true,
+ isGlobal: true,
+ organizationId: true,
+ },
+ });
+ }
+
async deleteEntry(id: string, userId: number): Promise {
const existing = await this.prismaClient.watchlist.findUnique({
where: { id },
@@ -247,4 +278,61 @@ export class WatchlistRepository implements IWatchlistRepository {
});
});
}
+
+ async bulkDeleteEntries(params: { ids: string[]; userId: number }): Promise<{ deleted: number }> {
+ const entries = await this.prismaClient.watchlist.findMany({
+ where: { id: { in: params.ids } },
+ select: {
+ id: true,
+ type: true,
+ value: true,
+ description: true,
+ action: true,
+ },
+ });
+
+ if (entries.length === 0) {
+ return { deleted: 0 };
+ }
+
+ await this.prismaClient.$transaction(async (tx) => {
+ await tx.watchlistAudit.createMany({
+ data: entries.map((entry) => ({
+ watchlistId: entry.id,
+ type: entry.type,
+ value: entry.value,
+ description: entry.description,
+ action: entry.action,
+ changedByUserId: params.userId,
+ })),
+ });
+
+ await tx.watchlist.deleteMany({
+ where: { id: { in: entries.map((e) => e.id) } },
+ });
+ });
+
+ return { deleted: entries.length };
+ }
+
+ async createEntryFromReport(params: {
+ type: CreateWatchlistInput["type"];
+ value: string;
+ organizationId: number | null;
+ isGlobal: boolean;
+ userId: number;
+ description?: string;
+ }): Promise<{ watchlistEntry: WatchlistEntry; value: string }> {
+ const watchlistEntry = await this.createEntryIfNotExists({
+ type: params.type,
+ value: params.value,
+ organizationId: params.organizationId,
+ isGlobal: params.isGlobal,
+ action: WatchlistAction.BLOCK,
+ description: params.description,
+ userId: params.userId,
+ });
+
+ return { watchlistEntry, value: params.value };
+ }
}
diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts
index b2c145727b..c7024dc144 100644
--- a/packages/trpc/server/routers/viewer/admin/_router.ts
+++ b/packages/trpc/server/routers/viewer/admin/_router.ts
@@ -18,6 +18,7 @@ import {
workspacePlatformUpdateServiceAccountSchema,
workspacePlatformToggleEnabledSchema,
} from "./workspacePlatform/schema";
+import { watchlistRouter } from "./watchlist/_router";
const NAMESPACE = "admin";
@@ -105,4 +106,5 @@ export const adminRouter = router({
return handler(opts);
}),
}),
+ watchlist: watchlistRouter,
});
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/_router.ts b/packages/trpc/server/routers/viewer/admin/watchlist/_router.ts
new file mode 100644
index 0000000000..2d50dac4c4
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/_router.ts
@@ -0,0 +1,57 @@
+import { authedAdminProcedure } from "../../../../procedures/authedProcedure";
+import { router } from "../../../../trpc";
+import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema";
+import { ZBulkDeleteWatchlistEntriesInputSchema } from "./bulkDelete.schema";
+import { ZBulkDismissReportsInputSchema } from "./bulkDismiss.schema";
+import { ZCreateWatchlistEntryInputSchema } from "./create.schema";
+import { ZDeleteWatchlistEntryInputSchema } from "./delete.schema";
+import { ZDismissReportInputSchema } from "./dismissReport.schema";
+import { ZGetWatchlistEntryDetailsInputSchema } from "./getDetails.schema";
+import { ZListWatchlistEntriesInputSchema } from "./list.schema";
+import { ZListReportsInputSchema } from "./listReports.schema";
+
+export const watchlistRouter = router({
+ list: authedAdminProcedure.input(ZListWatchlistEntriesInputSchema).query(async (opts) => {
+ const { listWatchlistEntriesHandler: handler } = await import("./list.handler");
+ return handler(opts);
+ }),
+ create: authedAdminProcedure.input(ZCreateWatchlistEntryInputSchema).mutation(async (opts) => {
+ const { createWatchlistEntryHandler: handler } = await import("./create.handler");
+ return handler(opts);
+ }),
+ delete: authedAdminProcedure.input(ZDeleteWatchlistEntryInputSchema).mutation(async (opts) => {
+ const { deleteWatchlistEntryHandler: handler } = await import("./delete.handler");
+ return handler(opts);
+ }),
+ bulkDelete: authedAdminProcedure
+ .input(ZBulkDeleteWatchlistEntriesInputSchema)
+ .mutation(async (opts) => {
+ const { bulkDeleteWatchlistEntriesHandler: handler } = await import("./bulkDelete.handler");
+ return handler(opts);
+ }),
+ getDetails: authedAdminProcedure.input(ZGetWatchlistEntryDetailsInputSchema).query(async (opts) => {
+ const { getWatchlistEntryDetailsHandler: handler } = await import("./getDetails.handler");
+ return handler(opts);
+ }),
+ // Report management endpoints
+ listReports: authedAdminProcedure.input(ZListReportsInputSchema).query(async (opts) => {
+ const { listReportsHandler: handler } = await import("./listReports.handler");
+ return handler(opts);
+ }),
+ dismissReport: authedAdminProcedure.input(ZDismissReportInputSchema).mutation(async (opts) => {
+ const { dismissReportHandler: handler } = await import("./dismissReport.handler");
+ return handler(opts);
+ }),
+ bulkDismiss: authedAdminProcedure.input(ZBulkDismissReportsInputSchema).mutation(async (opts) => {
+ const { bulkDismissReportsHandler: handler } = await import("./bulkDismiss.handler");
+ return handler(opts);
+ }),
+ addToWatchlist: authedAdminProcedure.input(ZAddToWatchlistInputSchema).mutation(async (opts) => {
+ const { addToWatchlistHandler: handler } = await import("./addToWatchlist.handler");
+ return handler(opts);
+ }),
+ pendingReportsCount: authedAdminProcedure.query(async () => {
+ const { pendingReportsCountHandler: handler } = await import("./pendingReportsCount.handler");
+ return handler();
+ }),
+});
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts
new file mode 100644
index 0000000000..e76c77f4d0
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts
@@ -0,0 +1,61 @@
+import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TAddToWatchlistInputSchema } from "./addToWatchlist.schema";
+
+type AddToWatchlistOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TAddToWatchlistInputSchema;
+};
+
+export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOptions) => {
+ const { user } = ctx;
+ const service = getAdminWatchlistOperationsService();
+
+ try {
+ return await service.addReportsToWatchlist({
+ reportIds: input.reportIds,
+ type: input.type,
+ description: input.description,
+ userId: user.id,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.NOT_FOUND:
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: error.message,
+ });
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: error.message,
+ });
+ case WatchlistErrorCode.ALREADY_IN_WATCHLIST:
+ case WatchlistErrorCode.INVALID_EMAIL:
+ case WatchlistErrorCode.INVALID_DOMAIN:
+ case WatchlistErrorCode.INVALID_IP:
+ case WatchlistErrorCode.VALIDATION_ERROR:
+ case WatchlistErrorCode.DUPLICATE_ENTRY:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw error;
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts
new file mode 100644
index 0000000000..b081ef675b
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts
@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+import { WatchlistType } from "@calcom/prisma/enums";
+
+export const ZAddToWatchlistInputSchema = z.object({
+ reportIds: z.array(z.string().uuid()).min(1),
+ type: z.nativeEnum(WatchlistType),
+ description: z.string().optional(),
+});
+
+export type TAddToWatchlistInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDelete.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDelete.handler.ts
new file mode 100644
index 0000000000..1b525a41c8
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDelete.handler.ts
@@ -0,0 +1,49 @@
+import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TBulkDeleteWatchlistEntriesInputSchema } from "./bulkDelete.schema";
+
+type BulkDeleteWatchlistEntriesOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TBulkDeleteWatchlistEntriesInputSchema;
+};
+
+export const bulkDeleteWatchlistEntriesHandler = async ({
+ ctx,
+ input,
+}: BulkDeleteWatchlistEntriesOptions) => {
+ const { user } = ctx;
+ const service = getAdminWatchlistOperationsService();
+
+ try {
+ return await service.bulkDeleteWatchlistEntries({
+ entryIds: input.ids,
+ userId: user.id,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.BULK_DELETE_PARTIAL_FAILURE:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to delete system blocklist entries",
+ });
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDelete.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDelete.schema.ts
new file mode 100644
index 0000000000..8dec91f6da
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDelete.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZBulkDeleteWatchlistEntriesInputSchema = z.object({
+ ids: z.array(z.string().uuid()).min(1, "At least one entry must be selected"),
+});
+
+export type TBulkDeleteWatchlistEntriesInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts
new file mode 100644
index 0000000000..544cb65082
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts
@@ -0,0 +1,44 @@
+import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TBulkDismissReportsInputSchema } from "./bulkDismiss.schema";
+
+type BulkDismissReportsOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TBulkDismissReportsInputSchema;
+};
+
+export const bulkDismissReportsHandler = async ({ input }: BulkDismissReportsOptions) => {
+ const service = getAdminWatchlistOperationsService();
+
+ try {
+ return await service.bulkDismissReports({
+ reportIds: input.reportIds,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.BULK_DELETE_PARTIAL_FAILURE:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to dismiss reports",
+ });
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts
new file mode 100644
index 0000000000..b1fa896a36
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZBulkDismissReportsInputSchema = z.object({
+ reportIds: z.array(z.string().uuid()).min(1, "At least one report must be selected"),
+});
+
+export type TBulkDismissReportsInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/create.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/create.handler.ts
new file mode 100644
index 0000000000..0d79bb4b69
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/create.handler.ts
@@ -0,0 +1,47 @@
+import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TCreateWatchlistEntryInputSchema } from "./create.schema";
+
+type CreateWatchlistEntryOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TCreateWatchlistEntryInputSchema;
+};
+
+export const createWatchlistEntryHandler = async ({ ctx, input }: CreateWatchlistEntryOptions) => {
+ const { user } = ctx;
+ const service = getAdminWatchlistOperationsService();
+
+ try {
+ return await service.createWatchlistEntry({
+ type: input.type,
+ value: input.value,
+ description: input.description,
+ userId: user.id,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.INVALID_EMAIL:
+ case WatchlistErrorCode.INVALID_DOMAIN:
+ case WatchlistErrorCode.DUPLICATE_ENTRY:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw error;
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/create.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/create.schema.ts
new file mode 100644
index 0000000000..763f803e00
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/create.schema.ts
@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+import { WatchlistType } from "@calcom/prisma/enums";
+
+export const ZCreateWatchlistEntryInputSchema = z.object({
+ type: z.nativeEnum(WatchlistType),
+ value: z.string().min(1).max(255),
+ description: z.string().optional(),
+});
+
+export type TCreateWatchlistEntryInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/delete.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/delete.handler.ts
new file mode 100644
index 0000000000..e972cf3b19
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/delete.handler.ts
@@ -0,0 +1,54 @@
+import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+import logger from "@calcom/lib/logger";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TDeleteWatchlistEntryInputSchema } from "./delete.schema";
+
+type DeleteWatchlistEntryOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TDeleteWatchlistEntryInputSchema;
+};
+
+export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlistEntryOptions) => {
+ const { user } = ctx;
+ const service = getAdminWatchlistOperationsService();
+
+ try {
+ return await service.deleteWatchlistEntry({
+ entryId: input.id,
+ userId: user.id,
+ });
+ } catch (error) {
+ logger.error("Failed to delete system blocklist entry", { error });
+
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.NOT_FOUND:
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: error.message,
+ });
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to delete system blocklist entry",
+ });
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/delete.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/delete.schema.ts
new file mode 100644
index 0000000000..65377da9f0
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/delete.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZDeleteWatchlistEntryInputSchema = z.object({
+ id: z.string().uuid(),
+});
+
+export type TDeleteWatchlistEntryInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.handler.ts
new file mode 100644
index 0000000000..c082a13cce
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.handler.ts
@@ -0,0 +1,49 @@
+import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TDismissReportInputSchema } from "./dismissReport.schema";
+
+type DismissReportOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TDismissReportInputSchema;
+};
+
+export const dismissReportHandler = async ({ input }: DismissReportOptions) => {
+ const service = getAdminWatchlistOperationsService();
+
+ try {
+ return await service.dismissReport({
+ reportId: input.reportId,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.NOT_FOUND:
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: error.message,
+ });
+ case WatchlistErrorCode.VALIDATION_ERROR:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to dismiss report",
+ });
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts
new file mode 100644
index 0000000000..e4e1b32d6a
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZDismissReportInputSchema = z.object({
+ reportId: z.string().uuid(),
+});
+
+export type TDismissReportInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/getDetails.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/getDetails.handler.ts
new file mode 100644
index 0000000000..a212d886a4
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/getDetails.handler.ts
@@ -0,0 +1,50 @@
+import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TGetWatchlistEntryDetailsInputSchema } from "./getDetails.schema";
+
+type GetWatchlistEntryDetailsOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TGetWatchlistEntryDetailsInputSchema;
+};
+
+export const getWatchlistEntryDetailsHandler = async ({ input }: GetWatchlistEntryDetailsOptions) => {
+ const service = getAdminWatchlistQueryService();
+
+ try {
+ return await service.getWatchlistEntryDetails({
+ entryId: input.id,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.NOT_FOUND:
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: error.message,
+ });
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to get blocklist entry details",
+ });
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/getDetails.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/getDetails.schema.ts
new file mode 100644
index 0000000000..be3f4643c5
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/getDetails.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZGetWatchlistEntryDetailsInputSchema = z.object({
+ id: z.string().uuid(),
+});
+
+export type TGetWatchlistEntryDetailsInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/list.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/list.handler.ts
new file mode 100644
index 0000000000..db09787746
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/list.handler.ts
@@ -0,0 +1,22 @@
+import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TListWatchlistEntriesInputSchema } from "./list.schema";
+
+type ListWatchlistEntriesOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TListWatchlistEntriesInputSchema;
+};
+
+export const listWatchlistEntriesHandler = async ({ input }: ListWatchlistEntriesOptions) => {
+ const service = getAdminWatchlistQueryService();
+
+ return await service.listWatchlistEntries({
+ limit: input.limit,
+ offset: input.offset,
+ searchTerm: input.searchTerm,
+ filters: input.filters,
+ });
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/list.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/list.schema.ts
new file mode 100644
index 0000000000..da0b1907b0
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/list.schema.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+import { WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
+
+export const ZListWatchlistEntriesInputSchema = z.object({
+ limit: z.number().int().min(1).max(100).default(25),
+ offset: z.number().int().min(0).default(0),
+ searchTerm: z.string().optional(),
+ filters: z
+ .object({
+ type: z.nativeEnum(WatchlistType).optional(),
+ source: z.nativeEnum(WatchlistSource).optional(),
+ })
+ .optional(),
+});
+
+export type TListWatchlistEntriesInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts
new file mode 100644
index 0000000000..a0b70ddde9
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts
@@ -0,0 +1,22 @@
+import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
+
+import type { TrpcSessionUser } from "../../../../types";
+import type { TListReportsInputSchema } from "./listReports.schema";
+
+type ListReportsOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TListReportsInputSchema;
+};
+
+export const listReportsHandler = async ({ input }: ListReportsOptions) => {
+ const service = getAdminWatchlistQueryService();
+
+ return await service.listBookingReports({
+ limit: input.limit,
+ offset: input.offset,
+ searchTerm: input.searchTerm,
+ filters: input.filters,
+ });
+};
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts
new file mode 100644
index 0000000000..9af677042e
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
+
+export const ZListReportsInputSchema = z.object({
+ limit: z.number().min(1).max(100).default(25),
+ offset: z.number().min(0).default(0),
+ searchTerm: z.string().optional(),
+ filters: z
+ .object({
+ status: z.array(z.nativeEnum(BookingReportStatus)).optional(),
+ reason: z.array(z.nativeEnum(BookingReportReason)).optional(),
+ })
+ .optional(),
+});
+
+export type TListReportsInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/pendingReportsCount.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/pendingReportsCount.handler.ts
new file mode 100644
index 0000000000..46e520e308
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/watchlist/pendingReportsCount.handler.ts
@@ -0,0 +1,6 @@
+import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
+
+export const pendingReportsCountHandler = async () => {
+ const service = getAdminWatchlistQueryService();
+ return await service.getPendingReportsCount();
+};
diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx
index dc5652c19f..7bb2d96976 100644
--- a/packages/trpc/server/routers/viewer/organizations/_router.tsx
+++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx
@@ -174,25 +174,25 @@ export const viewerOrganizationsRouter = router({
listWatchlistEntries: authedOrgAdminProcedure
.input(ZListWatchlistEntriesInputSchema)
.query(async (opts) => {
- const { default: handler } = await import("./listWatchlistEntries.handler");
+ const { listWatchlistEntriesHandler: handler } = await import("./listWatchlistEntries.handler");
return handler(opts);
}),
createWatchlistEntry: authedOrgAdminProcedure
.input(ZCreateWatchlistEntryInputSchema)
.mutation(async (opts) => {
- const { default: handler } = await import("./createWatchlistEntry.handler");
+ const { createWatchlistEntryHandler: handler } = await import("./createWatchlistEntry.handler");
return handler(opts);
}),
deleteWatchlistEntry: authedOrgAdminProcedure
.input(ZDeleteWatchlistEntryInputSchema)
.mutation(async (opts) => {
- const { default: handler } = await import("./deleteWatchlistEntry.handler");
+ const { deleteWatchlistEntryHandler: handler } = await import("./deleteWatchlistEntry.handler");
return handler(opts);
}),
getWatchlistEntryDetails: authedOrgAdminProcedure
.input(ZGetWatchlistEntryDetailsInputSchema)
.query(async (opts) => {
- const { default: handler } = await import("./getWatchlistEntryDetails.handler");
+ const { getWatchlistEntryDetailsHandler: handler } = await import("./getWatchlistEntryDetails.handler");
return handler(opts);
}),
listBookingReports: authedOrgAdminProcedure
@@ -204,7 +204,7 @@ export const viewerOrganizationsRouter = router({
addToWatchlist: authedOrgAdminProcedure
.input(ZAddToWatchlistInputSchema)
.mutation(async (opts) => {
- const { default: handler } = await import("./addToWatchlist.handler");
+ const { addToWatchlistHandler: handler } = await import("./addToWatchlist.handler");
return handler(opts);
}),
dismissBookingReport: authedOrgAdminProcedure
diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts
index c3e1b2d3c5..f9f0eaee42 100644
--- a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts
+++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts
@@ -1,22 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import type { WatchlistEntry } from "@calcom/lib/server/repository/watchlist.interface";
-import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import {
- MembershipRole,
- WatchlistType,
- WatchlistAction,
- WatchlistSource,
- BookingReportReason,
-} from "@calcom/prisma/enums";
+import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+import { WatchlistType } from "@calcom/prisma/enums";
import { addToWatchlistHandler } from "./addToWatchlist.handler";
-vi.mock("@calcom/features/pbac/services/permission-check.service");
-vi.mock("@calcom/lib/server/repository/bookingReport");
-vi.mock("@calcom/lib/server/repository/watchlist.repository");
+vi.mock("@calcom/features/di/watchlist/containers/watchlist");
describe("addToWatchlistHandler (Organization)", () => {
const mockUser = {
@@ -26,75 +15,16 @@ describe("addToWatchlistHandler (Organization)", () => {
profile: null,
};
- const mockReports = [
- {
- id: "report-1",
- bookingUid: "booking-uid-1",
- bookerEmail: "Spammer@Example.com",
- reportedById: 1,
- reason: BookingReportReason.SPAM,
- description: null,
- cancelled: false,
- createdAt: new Date(),
- watchlistId: null,
- reporter: { id: 1, name: "Admin", email: "admin@example.com" },
- booking: {
- id: 1,
- uid: "booking-uid-1",
- title: "Test Booking",
- startTime: new Date(),
- endTime: new Date(),
- },
- watchlist: null,
- },
- {
- id: "report-2",
- bookingUid: "booking-uid-2",
- bookerEmail: " User@Spammer.com ",
- reportedById: 1,
- reason: BookingReportReason.SPAM,
- description: null,
- cancelled: false,
- createdAt: new Date(),
- watchlistId: null,
- reporter: { id: 1, name: "Admin", email: "admin@example.com" },
- booking: {
- id: 2,
- uid: "booking-uid-2",
- title: "Test Booking 2",
- startTime: new Date(),
- endTime: new Date(),
- },
- watchlist: null,
- },
- ];
-
- const mockPermissionCheckService = {
- checkPermission: vi.fn(),
+ const mockService = {
+ addReportsToWatchlist: vi.fn(),
};
- const mockReportRepo = {
- findReportsByIds: vi.fn(),
- linkWatchlistToReport: vi.fn(),
- updateReportStatus: vi.fn(),
- };
-
- const mockWatchlistRepo = {
- checkExists: vi.fn(),
- createEntry: vi.fn(),
- };
-
- beforeEach(() => {
+ beforeEach(async () => {
vi.clearAllMocks();
- vi.mocked(PermissionCheckService).mockImplementation(
- () => mockPermissionCheckService as InstanceType
- );
- vi.mocked(PrismaBookingReportRepository).mockImplementation(
- () => mockReportRepo as InstanceType
- );
- vi.mocked(WatchlistRepository).mockImplementation(
- () => mockWatchlistRepo as InstanceType
+ const { getOrganizationWatchlistOperationsService } = await import(
+ "@calcom/features/di/watchlist/containers/watchlist"
);
+ vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never);
});
describe("access control", () => {
@@ -112,11 +42,34 @@ describe("addToWatchlistHandler (Organization)", () => {
message: "You must be part of an organization to add to watchlist",
});
- expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
+ expect(mockService.addReportsToWatchlist).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("error mapping", () => {
+ it("should map NOT_FOUND error to NOT_FOUND", async () => {
+ mockService.addReportsToWatchlist.mockRejectedValue(
+ WatchlistErrors.notFound("Booking report(s) not found: report-1")
+ );
+
+ await expect(
+ addToWatchlistHandler({
+ ctx: { user: mockUser },
+ input: {
+ reportIds: ["report-1"],
+ type: WatchlistType.EMAIL,
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ message: "Booking report(s) not found: report-1",
+ });
});
- it("should throw UNAUTHORIZED when user lacks permission", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(false);
+ it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => {
+ mockService.addReportsToWatchlist.mockRejectedValue(
+ WatchlistErrors.permissionDenied("You are not authorized to add entries to the watchlist")
+ );
await expect(
addToWatchlistHandler({
@@ -130,89 +83,18 @@ describe("addToWatchlistHandler (Organization)", () => {
code: "UNAUTHORIZED",
message: "You are not authorized to add entries to the watchlist",
});
-
- expect(mockReportRepo.findReportsByIds).not.toHaveBeenCalled();
});
- it("should check permission with correct parameters", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
- mockWatchlistRepo.checkExists.mockResolvedValue(null);
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
- mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
- mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
-
- await addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1"],
- type: WatchlistType.EMAIL,
- },
- });
-
- expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
- userId: 1,
- teamId: 100,
- permission: "watchlist.create",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
- });
- });
-
- describe("validation", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
- it("should throw NOT_FOUND when report IDs don't exist", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([]);
+ it("should map ALREADY_IN_WATCHLIST error to BAD_REQUEST", async () => {
+ mockService.addReportsToWatchlist.mockRejectedValue(
+ WatchlistErrors.alreadyInWatchlist("All selected bookers are already in the watchlist")
+ );
await expect(
addToWatchlistHandler({
ctx: { user: mockUser },
input: {
- reportIds: ["report-1", "report-2"],
- type: WatchlistType.EMAIL,
- },
- })
- ).rejects.toMatchObject({
- code: "NOT_FOUND",
- message: expect.stringContaining("Booking report(s) not found"),
- });
- });
-
- it("should throw NOT_FOUND with specific missing IDs", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
-
- await expect(
- addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1", "report-2", "report-3"],
- type: WatchlistType.EMAIL,
- },
- })
- ).rejects.toMatchObject({
- code: "NOT_FOUND",
- message: expect.stringContaining("report-2, report-3"),
- });
- });
- });
-
- describe("duplicate prevention", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
- it("should throw BAD_REQUEST when all reports are already in watchlist", async () => {
- const reportsInWatchlist = mockReports.map((r) => ({ ...r, watchlistId: "watchlist-123" }));
- mockReportRepo.findReportsByIds.mockResolvedValue(reportsInWatchlist);
-
- await expect(
- addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1", "report-2"],
+ reportIds: ["report-1"],
type: WatchlistType.EMAIL,
},
})
@@ -221,160 +103,48 @@ describe("addToWatchlistHandler (Organization)", () => {
message: "All selected bookers are already in the watchlist",
});
});
- });
- describe("successful addition", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
+ it("should re-throw unknown service errors", async () => {
+ const unknownError = new Error("Database connection failed");
+ mockService.addReportsToWatchlist.mockRejectedValue(unknownError);
- it("should create new organization watchlist entry for EMAIL type and link report", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
- mockWatchlistRepo.checkExists.mockResolvedValue(null);
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
- mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
- mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
-
- const result = await addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1"],
- type: WatchlistType.EMAIL,
- description: "Spam user",
- },
- });
-
- expect(result.success).toBe(true);
- expect(result.addedCount).toBe(1);
- expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({
- type: WatchlistType.EMAIL,
- value: "spammer@example.com",
- organizationId: 100,
- });
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
- type: WatchlistType.EMAIL,
- value: "spammer@example.com",
- organizationId: 100,
- action: WatchlistAction.BLOCK,
- description: "Spam user",
- userId: 1,
- });
- expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({
- reportId: "report-1",
- watchlistId: "watchlist-new",
- });
- expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
- reportId: "report-1",
- status: "BLOCKED",
- organizationId: 100,
- });
- });
-
- it("should create new organization watchlist entry for DOMAIN type and link report", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
- mockWatchlistRepo.checkExists.mockResolvedValue(null);
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-domain" });
- mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
- mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
-
- const result = await addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1"],
- type: WatchlistType.DOMAIN,
- },
- });
-
- expect(result.success).toBe(true);
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
- type: WatchlistType.DOMAIN,
- value: "example.com",
- organizationId: 100,
- action: WatchlistAction.BLOCK,
- description: undefined,
- userId: 1,
- });
- expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
- reportId: "report-1",
- status: "BLOCKED",
- organizationId: 100,
- });
- });
-
- it("should reuse existing organization watchlist entry when value already exists", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
- mockWatchlistRepo.checkExists.mockResolvedValue({ id: "existing-watchlist" } as WatchlistEntry);
- mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
- mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
-
- const result = await addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1"],
- type: WatchlistType.EMAIL,
- },
- });
-
- expect(result.success).toBe(true);
- expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
- expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({
- reportId: "report-1",
- watchlistId: "existing-watchlist",
- });
- expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
- reportId: "report-1",
- status: "BLOCKED",
- organizationId: 100,
- });
+ await expect(
+ addToWatchlistHandler({
+ ctx: { user: mockUser },
+ input: {
+ reportIds: ["report-1"],
+ type: WatchlistType.EMAIL,
+ },
+ })
+ ).rejects.toThrow("Database connection failed");
});
});
- describe("organization scoping", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
+ describe("successful delegation", () => {
+ it("should delegate to service with correct parameters", async () => {
+ mockService.addReportsToWatchlist.mockResolvedValue({
+ success: true,
+ addedCount: 2,
+ message: "Successfully added 2 entries to watchlist",
+ });
- it("should pass organizationId to repository when finding reports", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
- mockWatchlistRepo.checkExists.mockResolvedValue(null);
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
- mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
- mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
-
- await addToWatchlistHandler({
+ const result = await addToWatchlistHandler({
ctx: { user: mockUser },
input: {
- reportIds: ["report-1"],
+ reportIds: ["report-1", "report-2"],
type: WatchlistType.EMAIL,
+ description: "Spam users",
},
});
- expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
- reportIds: ["report-1"],
- organizationId: 100,
- });
- });
-
- it("should pass organizationId when checking for existing watchlist entry", async () => {
- mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
- mockWatchlistRepo.checkExists.mockResolvedValue(null);
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
- mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
- mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
-
- await addToWatchlistHandler({
- ctx: { user: mockUser },
- input: {
- reportIds: ["report-1"],
- type: WatchlistType.EMAIL,
- },
- });
-
- expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({
+ expect(mockService.addReportsToWatchlist).toHaveBeenCalledWith({
+ reportIds: ["report-1", "report-2"],
type: WatchlistType.EMAIL,
- value: "spammer@example.com",
- organizationId: 100,
+ description: "Spam users",
+ userId: 1,
});
+ expect(result.success).toBe(true);
+ expect(result.addedCount).toBe(2);
});
});
});
diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts
index 3133268185..734a770dd3 100644
--- a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts
@@ -1,12 +1,5 @@
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import {
- normalizeEmail,
- extractDomainFromEmail,
-} from "@calcom/features/watchlist/lib/utils/normalization";
-import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { prisma } from "@calcom/prisma";
-import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums";
+import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
import { TRPCError } from "@trpc/server";
@@ -31,108 +24,45 @@ export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOption
});
}
- const permissionCheckService = new PermissionCheckService();
- const hasPermission = await permissionCheckService.checkPermission({
- userId: user.id,
- teamId: organizationId,
- permission: "watchlist.create",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
-
- if (!hasPermission) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "You are not authorized to add entries to the watchlist",
- });
- }
-
- const bookingReportRepo = new PrismaBookingReportRepository(prisma);
- const watchlistRepo = new WatchlistRepository(prisma);
-
- const validReports = await bookingReportRepo.findReportsByIds({
- reportIds: input.reportIds,
- organizationId,
- });
-
- if (validReports.length !== input.reportIds.length) {
- const foundIds = validReports.map((r) => r.id);
- const missingIds = input.reportIds.filter((id) => !foundIds.includes(id));
- throw new TRPCError({
- code: "NOT_FOUND",
- message: `Booking report(s) not found: ${missingIds.join(", ")}`,
- });
- }
-
- const reportsToAdd = validReports.filter((report) => !report.watchlistId);
-
- if (reportsToAdd.length === 0) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "All selected bookers are already in the watchlist",
- });
- }
+ const service = getOrganizationWatchlistOperationsService(organizationId);
try {
- const results = await Promise.all(
- reportsToAdd.map(async (report) => {
- const value =
- input.type === "EMAIL"
- ? normalizeEmail(report.bookerEmail)
- : extractDomainFromEmail(report.bookerEmail);
-
- const existingWatchlist = await watchlistRepo.checkExists({
- type: input.type,
- value,
- organizationId,
- });
-
- let watchlistId: string;
-
- if (existingWatchlist) {
- watchlistId = existingWatchlist.id;
- } else {
- const newWatchlist = await watchlistRepo.createEntry({
- type: input.type,
- value,
- organizationId,
- action: WatchlistAction.BLOCK,
- description: input.description,
- userId: user.id,
- });
- watchlistId = newWatchlist.id;
- }
-
- await bookingReportRepo.linkWatchlistToReport({
- reportId: report.id,
- watchlistId,
- });
-
- await bookingReportRepo.updateReportStatus({
- reportId: report.id,
- status: "BLOCKED",
- organizationId,
- });
-
- return { reportId: report.id, watchlistId, value };
- })
- );
-
- return {
- success: true,
- message: `Successfully added ${reportsToAdd.length} report(s) to watchlist`,
- addedCount: reportsToAdd.length,
- skippedCount: validReports.length - reportsToAdd.length,
- results: results.map((r) => ({ reportId: r.reportId, watchlistId: r.watchlistId })),
- };
+ return await service.addReportsToWatchlist({
+ reportIds: input.reportIds,
+ type: input.type,
+ description: input.description,
+ userId: user.id,
+ });
} catch (error) {
- if (error instanceof Error && error.message.includes("already exists")) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "This entry already exists in the watchlist for your organization",
- });
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: error.message,
+ });
+ case WatchlistErrorCode.NOT_FOUND:
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: error.message,
+ });
+ case WatchlistErrorCode.ALREADY_IN_WATCHLIST:
+ case WatchlistErrorCode.INVALID_EMAIL:
+ case WatchlistErrorCode.INVALID_DOMAIN:
+ case WatchlistErrorCode.DUPLICATE_ENTRY:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
}
+
throw error;
}
};
-
-export default addToWatchlistHandler;
diff --git a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts
index 90ab355223..d456b3fd0a 100644
--- a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts
+++ b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts
@@ -1,16 +1,11 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { WatchlistType, WatchlistAction, MembershipRole } from "@calcom/prisma/enums";
-
-import { TRPCError } from "@trpc/server";
+import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+import { WatchlistType } from "@calcom/prisma/enums";
import { createWatchlistEntryHandler } from "./createWatchlistEntry.handler";
-vi.mock("@calcom/features/pbac/services/permission-check.service");
-vi.mock("@calcom/lib/server/repository/watchlist.repository");
+vi.mock("@calcom/features/di/watchlist/containers/watchlist");
describe("createWatchlistEntryHandler", () => {
const mockUser = {
@@ -20,25 +15,23 @@ describe("createWatchlistEntryHandler", () => {
profile: null,
};
- const mockPermissionCheckService = {
- checkPermission: vi.fn(),
+ const mockService = {
+ createWatchlistEntry: vi.fn(),
};
- const mockWatchlistRepo = {
- createEntry: vi.fn(),
- };
-
- beforeEach(() => {
+ beforeEach(async () => {
vi.clearAllMocks();
- vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
- vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
+ const { getOrganizationWatchlistOperationsService } = await import(
+ "@calcom/features/di/watchlist/containers/watchlist"
+ );
+ vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never);
});
describe("access control", () => {
it("should throw FORBIDDEN when user is not part of an organization", async () => {
await expect(
createWatchlistEntryHandler({
- ctx: { user: { ...(mockUser as any), organizationId: undefined, profile: null } },
+ ctx: { user: { ...mockUser, organizationId: undefined, profile: null } as never },
input: {
type: WatchlistType.EMAIL,
value: "spam@example.com",
@@ -49,58 +42,19 @@ describe("createWatchlistEntryHandler", () => {
message: "You must be part of an organization to manage blocklist",
});
- expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
- });
-
- it("should throw UNAUTHORIZED when user lacks permission", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(false);
-
- await expect(
- createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.EMAIL,
- value: "spam@example.com",
- },
- })
- ).rejects.toMatchObject({
- code: "UNAUTHORIZED",
- message: "You are not authorized to create blocklist entries",
- });
-
- expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
- });
-
- it("should check permission with correct parameters", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
-
- await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.EMAIL,
- value: "test@example.com",
- },
- });
-
- expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
- userId: 1,
- teamId: 100,
- permission: "watchlist.create",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
+ expect(mockService.createWatchlistEntry).not.toHaveBeenCalled();
});
});
- describe("validation", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
+ describe("error mapping", () => {
+ it("should map INVALID_EMAIL error to BAD_REQUEST", async () => {
+ mockService.createWatchlistEntry.mockRejectedValue(
+ WatchlistErrors.invalidEmail("Invalid email address format")
+ );
- it("should throw BAD_REQUEST for invalid email format", async () => {
await expect(
createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
+ ctx: { user: mockUser as never },
input: {
type: WatchlistType.EMAIL,
value: "invalid-email",
@@ -110,194 +64,124 @@ describe("createWatchlistEntryHandler", () => {
code: "BAD_REQUEST",
message: "Invalid email address format",
});
-
- expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
});
- it("should throw BAD_REQUEST for email missing @ symbol", async () => {
+ it("should map INVALID_DOMAIN error to BAD_REQUEST", async () => {
+ mockService.createWatchlistEntry.mockRejectedValue(
+ WatchlistErrors.invalidDomain("Invalid domain format (e.g., example.com)")
+ );
+
await expect(
createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.EMAIL,
- value: "notanemail.com",
- },
- })
- ).rejects.toMatchObject({
- code: "BAD_REQUEST",
- message: "Invalid email address format",
- });
- });
-
- it("should accept valid domain format without @", async () => {
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
-
- const result = await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.DOMAIN,
- value: "example.com",
- },
- });
-
- expect(result.success).toBe(true);
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalled();
- });
-
- it("should throw BAD_REQUEST for domain with invalid format", async () => {
- await expect(
- createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
+ ctx: { user: mockUser as never },
input: {
type: WatchlistType.DOMAIN,
value: "invalid..domain",
},
})
- ).rejects.toStrictEqual(
- new TRPCError({
- code: "BAD_REQUEST",
- message: "Invalid domain format (e.g., example.com)",
- })
- );
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: "Invalid domain format (e.g., example.com)",
+ });
});
- it("should accept valid email format", async () => {
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
+ it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => {
+ mockService.createWatchlistEntry.mockRejectedValue(
+ WatchlistErrors.permissionDenied("You are not authorized to create blocklist entries")
+ );
- const result = await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.EMAIL,
- value: "valid@example.com",
- },
+ await expect(
+ createWatchlistEntryHandler({
+ ctx: { user: mockUser as never },
+ input: {
+ type: WatchlistType.EMAIL,
+ value: "spam@example.com",
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to create blocklist entries",
});
-
- expect(result.success).toBe(true);
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalled();
});
- it("should accept valid domain format and normalize it", async () => {
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
+ it("should re-throw unknown service errors", async () => {
+ const unknownError = new Error("Database connection failed");
+ mockService.createWatchlistEntry.mockRejectedValue(unknownError);
- const result = await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.DOMAIN,
- value: "example.com",
- },
- });
-
- expect(result.success).toBe(true);
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith(
- expect.objectContaining({
- value: "example.com",
+ await expect(
+ createWatchlistEntryHandler({
+ ctx: { user: mockUser as never },
+ input: {
+ type: WatchlistType.EMAIL,
+ value: "spam@example.com",
+ },
})
- );
+ ).rejects.toThrow("Database connection failed");
});
});
- describe("successful creation", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
- it("should create watchlist entry for EMAIL type", async () => {
+ describe("successful delegation", () => {
+ it("should delegate to service with correct parameters for EMAIL", async () => {
const mockEntry = {
id: "watchlist-1",
type: WatchlistType.EMAIL,
value: "spam@example.com",
organizationId: 100,
- action: WatchlistAction.BLOCK,
};
- mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry);
+
+ mockService.createWatchlistEntry.mockResolvedValue({
+ success: true,
+ entry: mockEntry,
+ });
const result = await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
+ ctx: { user: mockUser as never },
input: {
type: WatchlistType.EMAIL,
value: "spam@example.com",
+ description: "Known spammer",
},
});
- expect(result.success).toBe(true);
- expect(result.entry).toEqual(mockEntry);
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
+ expect(mockService.createWatchlistEntry).toHaveBeenCalledWith({
type: WatchlistType.EMAIL,
value: "spam@example.com",
- organizationId: 100,
- action: WatchlistAction.BLOCK,
- description: undefined,
+ description: "Known spammer",
userId: 1,
});
+ expect(result.success).toBe(true);
+ expect(result.entry).toEqual(mockEntry);
});
- it("should create watchlist entry for DOMAIN type and normalize it", async () => {
+ it("should delegate to service with correct parameters for DOMAIN", async () => {
const mockEntry = {
id: "watchlist-2",
type: WatchlistType.DOMAIN,
value: "spammer.com",
organizationId: 100,
- action: WatchlistAction.BLOCK,
};
- mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry);
+
+ mockService.createWatchlistEntry.mockResolvedValue({
+ success: true,
+ entry: mockEntry,
+ });
const result = await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
+ ctx: { user: mockUser as never },
input: {
type: WatchlistType.DOMAIN,
value: "spammer.com",
},
});
- expect(result.success).toBe(true);
- expect(result.entry).toEqual(mockEntry);
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
+ expect(mockService.createWatchlistEntry).toHaveBeenCalledWith({
type: WatchlistType.DOMAIN,
value: "spammer.com",
- organizationId: 100,
- action: WatchlistAction.BLOCK,
description: undefined,
userId: 1,
});
- });
-
- it("should convert value to lowercase", async () => {
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-4" });
-
- await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.EMAIL,
- value: "SPAM@EXAMPLE.COM",
- },
- });
-
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
- type: WatchlistType.EMAIL,
- value: "spam@example.com",
- organizationId: 100,
- action: WatchlistAction.BLOCK,
- description: undefined,
- userId: 1,
- });
- });
-
- it("should always set action to BLOCK", async () => {
- mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-5" });
-
- await createWatchlistEntryHandler({
- ctx: { user: mockUser as any },
- input: {
- type: WatchlistType.EMAIL,
- value: "test@example.com",
- },
- });
-
- expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith(
- expect.objectContaining({
- action: WatchlistAction.BLOCK,
- })
- );
+ expect(result.success).toBe(true);
+ expect(result.entry).toEqual(mockEntry);
});
});
});
diff --git a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts
index 8b9acabc72..4862a01640 100644
--- a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts
@@ -1,8 +1,6 @@
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { prisma } from "@calcom/prisma";
-import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums";
+import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+import logger from "@calcom/lib/logger";
import { TRPCError } from "@trpc/server";
@@ -27,60 +25,40 @@ export const createWatchlistEntryHandler = async ({ ctx, input }: CreateWatchlis
});
}
- const permissionCheckService = new PermissionCheckService();
- const hasPermission = await permissionCheckService.checkPermission({
- userId: user.id,
- teamId: organizationId,
- permission: "watchlist.create",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
-
- if (!hasPermission) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "You are not authorized to create blocklist entries",
- });
- }
-
- const watchlistRepo = new WatchlistRepository(prisma);
-
- if (input.type === "EMAIL" && !emailRegex.test(input.value)) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Invalid email address format",
- });
- }
-
- if (input.type === "DOMAIN" && !domainRegex.test(input.value)) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Invalid domain format (e.g., example.com)",
- });
- }
+ const service = getOrganizationWatchlistOperationsService(organizationId);
try {
- const entry = await watchlistRepo.createEntry({
+ return await service.createWatchlistEntry({
type: input.type,
- value: input.value.toLowerCase(),
- organizationId,
- action: WatchlistAction.BLOCK,
+ value: input.value,
description: input.description,
userId: user.id,
});
-
- return {
- success: true,
- entry,
- };
} catch (error) {
- if (error instanceof Error && error.message.includes("already exists")) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "This entry already exists in the blocklist for your organization",
- });
+ logger.error("Failed to create blocklist entry", { error });
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: error.message,
+ });
+ case WatchlistErrorCode.INVALID_EMAIL:
+ case WatchlistErrorCode.INVALID_DOMAIN:
+ case WatchlistErrorCode.DUPLICATE_ENTRY:
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
}
+
throw error;
}
};
-
-export default createWatchlistEntryHandler;
diff --git a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts
index d361423ab6..776e62badf 100644
--- a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts
+++ b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts
@@ -1,12 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { MembershipRole, WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
+import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+import { WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
import { deleteWatchlistEntryHandler } from "./deleteWatchlistEntry.handler";
-vi.mock("@calcom/features/pbac/services/permission-check.service");
+vi.mock("@calcom/features/di/watchlist/containers/watchlist");
vi.mock("@calcom/lib/server/repository/watchlist.repository");
describe("deleteWatchlistEntryHandler", () => {
@@ -28,19 +27,23 @@ describe("deleteWatchlistEntryHandler", () => {
updatedAt: new Date(),
};
- const mockPermissionCheckService = {
- checkPermission: vi.fn(),
+ const mockService = {
+ deleteWatchlistEntry: vi.fn(),
};
const mockWatchlistRepo = {
findEntryWithAuditAndReports: vi.fn(),
- deleteEntry: vi.fn(),
};
- beforeEach(() => {
+ beforeEach(async () => {
vi.clearAllMocks();
- vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
- vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
+ const { getOrganizationWatchlistOperationsService } = await import(
+ "@calcom/features/di/watchlist/containers/watchlist"
+ );
+ const { WatchlistRepository } = await import("@calcom/lib/server/repository/watchlist.repository");
+
+ vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never);
+ vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as never);
});
describe("access control", () => {
@@ -57,56 +60,11 @@ describe("deleteWatchlistEntryHandler", () => {
message: "You must be part of an organization to manage blocklist",
});
- expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
- });
-
- it("should throw UNAUTHORIZED when user lacks permission", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(false);
-
- await expect(
- deleteWatchlistEntryHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- })
- ).rejects.toMatchObject({
- code: "UNAUTHORIZED",
- message: "You are not authorized to delete blocklist entries",
- });
-
- expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
- });
-
- it("should check permission with correct parameters", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: [],
- });
- mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
-
- await deleteWatchlistEntryHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
- userId: 1,
- teamId: 100,
- permission: "watchlist.delete",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
+ expect(mockService.deleteWatchlistEntry).not.toHaveBeenCalled();
});
});
- describe("validation", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
+ describe("validation in handler", () => {
it("should throw NOT_FOUND when entry does not exist", async () => {
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: null,
@@ -124,8 +82,6 @@ describe("deleteWatchlistEntryHandler", () => {
code: "NOT_FOUND",
message: "Blocklist entry not found",
});
-
- expect(mockWatchlistRepo.deleteEntry).not.toHaveBeenCalled();
});
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
@@ -145,22 +101,63 @@ describe("deleteWatchlistEntryHandler", () => {
code: "FORBIDDEN",
message: "You can only delete blocklist entries from your organization",
});
-
- expect(mockWatchlistRepo.deleteEntry).not.toHaveBeenCalled();
});
});
- describe("successful deletion", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
- it("should successfully delete entry when authorized", async () => {
+ describe("error mapping from service", () => {
+ it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => {
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
- mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
+ mockService.deleteWatchlistEntry.mockRejectedValue(
+ WatchlistErrors.permissionDenied("You are not authorized to delete blocklist entries")
+ );
+
+ await expect(
+ deleteWatchlistEntryHandler({
+ ctx: { user: mockUser },
+ input: {
+ id: "entry-123",
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to delete blocklist entries",
+ });
+ });
+
+ it("should map unknown service errors to INTERNAL_SERVER_ERROR", async () => {
+ mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
+ entry: mockEntry,
+ auditHistory: [],
+ });
+ mockService.deleteWatchlistEntry.mockRejectedValue(new Error("Database connection failed"));
+
+ await expect(
+ deleteWatchlistEntryHandler({
+ ctx: { user: mockUser },
+ input: {
+ id: "entry-123",
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to delete blocklist entry",
+ });
+ });
+ });
+
+ describe("successful delegation", () => {
+ it("should delegate to service with correct parameters", async () => {
+ mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
+ entry: mockEntry,
+ auditHistory: [],
+ });
+ mockService.deleteWatchlistEntry.mockResolvedValue({
+ success: true,
+ message: "Blocklist entry deleted successfully",
+ });
const result = await deleteWatchlistEntryHandler({
ctx: { user: mockUser },
@@ -169,26 +166,12 @@ describe("deleteWatchlistEntryHandler", () => {
},
});
+ expect(mockService.deleteWatchlistEntry).toHaveBeenCalledWith({
+ entryId: "entry-123",
+ userId: 1,
+ });
expect(result.success).toBe(true);
expect(result.message).toBe("Blocklist entry deleted successfully");
- expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 1);
- });
-
- it("should pass correct userId to deleteEntry", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: [],
- });
- mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
-
- await deleteWatchlistEntryHandler({
- ctx: { user: { ...mockUser, id: 42 } },
- input: {
- id: "entry-123",
- },
- });
-
- expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
});
});
});
diff --git a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts
index 2599e6f540..e6f24f3035 100644
--- a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts
@@ -1,7 +1,7 @@
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
+import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { prisma } from "@calcom/prisma";
-import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -26,23 +26,7 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
});
}
- const permissionCheckService = new PermissionCheckService();
- const hasPermission = await permissionCheckService.checkPermission({
- userId: user.id,
- teamId: organizationId,
- permission: "watchlist.delete",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
-
- if (!hasPermission) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "You are not authorized to delete blocklist entries",
- });
- }
-
const watchlistRepo = new WatchlistRepository(prisma);
-
const { entry } = await watchlistRepo.findEntryWithAuditAndReports(input.id);
if (!entry) {
@@ -59,19 +43,33 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
});
}
- try {
- await watchlistRepo.deleteEntry(input.id, user.id);
+ const service = getOrganizationWatchlistOperationsService(organizationId);
- return {
- success: true,
- message: "Blocklist entry deleted successfully",
- };
+ try {
+ return await service.deleteWatchlistEntry({
+ entryId: input.id,
+ userId: user.id,
+ });
} catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
+
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete blocklist entry",
});
}
};
-
-export default deleteWatchlistEntryHandler;
diff --git a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts
index b31263ab1e..64b6d1c080 100644
--- a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts
+++ b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts
@@ -1,15 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { MembershipRole, WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
+import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
+import { WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
import { getWatchlistEntryDetailsHandler } from "./getWatchlistEntryDetails.handler";
-vi.mock("@calcom/features/pbac/services/permission-check.service");
-vi.mock("@calcom/features/users/repositories/UserRepository");
-vi.mock("@calcom/lib/server/repository/watchlist.repository");
+vi.mock("@calcom/features/di/watchlist/containers/watchlist");
describe("getWatchlistEntryDetailsHandler", () => {
const mockUser = {
@@ -39,40 +35,20 @@ describe("getWatchlistEntryDetailsHandler", () => {
timestamp: new Date("2025-01-01"),
previousValue: null,
newValue: "spam@example.com",
- },
- {
- id: "audit-2",
- watchlistId: "entry-123",
- action: "UPDATED" as const,
- changedByUserId: 2,
- timestamp: new Date("2025-01-02"),
- previousValue: null,
- newValue: "Known spammer",
+ changedByUser: { id: 1, name: "Admin", email: "admin@example.com" },
},
];
- const mockUsers = [
- { id: 1, name: "Admin", email: "admin@example.com" },
- { id: 2, name: "Editor", email: "editor@example.com" },
- ];
-
- const mockPermissionCheckService = {
- checkPermission: vi.fn(),
+ const mockService = {
+ getWatchlistEntryDetails: vi.fn(),
};
- const mockWatchlistRepo = {
- findEntryWithAuditAndReports: vi.fn(),
- };
-
- const mockUserRepo = {
- findUsersByIds: vi.fn(),
- };
-
- beforeEach(() => {
+ beforeEach(async () => {
vi.clearAllMocks();
- vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
- vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
- vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
+ const { getOrganizationWatchlistQueryService } = await import(
+ "@calcom/features/di/watchlist/containers/watchlist"
+ );
+ vi.mocked(getOrganizationWatchlistQueryService).mockReturnValue(mockService as never);
});
describe("access control", () => {
@@ -89,12 +65,89 @@ describe("getWatchlistEntryDetailsHandler", () => {
message: "You must be part of an organization to view blocklist",
});
- expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
+ expect(mockService.getWatchlistEntryDetails).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("error mapping", () => {
+ it("should map NOT_FOUND error to NOT_FOUND", async () => {
+ mockService.getWatchlistEntryDetails.mockRejectedValue(
+ WatchlistErrors.notFound("Blocklist entry not found")
+ );
+
+ await expect(
+ getWatchlistEntryDetailsHandler({
+ ctx: { user: mockUser },
+ input: {
+ id: "non-existent-id",
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ message: "Blocklist entry not found",
+ });
+ });
+
+ it("should map PERMISSION_DENIED error to FORBIDDEN", async () => {
+ mockService.getWatchlistEntryDetails.mockRejectedValue(
+ WatchlistErrors.permissionDenied("You are not authorized to view blocklist entries")
+ );
+
+ await expect(
+ getWatchlistEntryDetailsHandler({
+ ctx: { user: mockUser },
+ input: {
+ id: "entry-123",
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "You are not authorized to view blocklist entries",
+ });
+ });
+
+ it("should map unknown service errors to INTERNAL_SERVER_ERROR", async () => {
+ mockService.getWatchlistEntryDetails.mockRejectedValue(new Error("Database connection failed"));
+
+ await expect(
+ getWatchlistEntryDetailsHandler({
+ ctx: { user: mockUser },
+ input: {
+ id: "entry-123",
+ },
+ })
+ ).rejects.toMatchObject({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to get blocklist entry details",
+ });
+ });
+ });
+
+ describe("successful delegation", () => {
+ it("should delegate to service with correct parameters", async () => {
+ mockService.getWatchlistEntryDetails.mockResolvedValue({
+ entry: mockEntry,
+ auditHistory: mockAuditHistory,
+ });
+
+ const result = await getWatchlistEntryDetailsHandler({
+ ctx: { user: mockUser },
+ input: {
+ id: "entry-123",
+ },
+ });
+
+ expect(mockService.getWatchlistEntryDetails).toHaveBeenCalledWith({
+ organizationId: 100,
+ userId: 1,
+ entryId: "entry-123",
+ });
+ expect(result.entry).toEqual(mockEntry);
+ expect(result.auditHistory).toEqual(mockAuditHistory);
});
it("should use profile.organizationId when available", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
+ mockService.getWatchlistEntryDetails.mockResolvedValue({
entry: { ...mockEntry, organizationId: 200 },
auditHistory: [],
});
@@ -112,246 +165,11 @@ describe("getWatchlistEntryDetailsHandler", () => {
},
});
- expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
+ expect(mockService.getWatchlistEntryDetails).toHaveBeenCalledWith({
+ organizationId: 200,
userId: 1,
- teamId: 200,
- permission: "watchlist.read",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ entryId: "entry-123",
});
});
-
- it("should throw UNAUTHORIZED when user lacks permission", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(false);
-
- await expect(
- getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- })
- ).rejects.toMatchObject({
- code: "UNAUTHORIZED",
- message: "You are not authorized to view blocklist entries",
- });
-
- expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
- });
-
- it("should check permission with correct parameters", async () => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: [],
- });
-
- await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
- userId: 1,
- teamId: 100,
- permission: "watchlist.read",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
- });
- });
-
- describe("validation", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
- it("should throw NOT_FOUND when entry does not exist", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: null,
- auditHistory: [],
- });
-
- await expect(
- getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "non-existent-id",
- },
- })
- ).rejects.toMatchObject({
- code: "NOT_FOUND",
- message: "Blocklist entry not found",
- });
- });
-
- it("should throw FORBIDDEN when entry belongs to different organization", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: { ...mockEntry, organizationId: 999 },
- auditHistory: [],
- });
-
- await expect(
- getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- })
- ).rejects.toMatchObject({
- code: "FORBIDDEN",
- message: "You can only view blocklist entries from your organization",
- });
- });
- });
-
- describe("successful retrieval", () => {
- beforeEach(() => {
- mockPermissionCheckService.checkPermission.mockResolvedValue(true);
- });
-
- it("should return entry and audit history when authorized", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: mockAuditHistory,
- });
- mockUserRepo.findUsersByIds.mockResolvedValue(mockUsers);
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(result.entry).toEqual(mockEntry);
- expect(result.auditHistory).toHaveLength(2);
- expect(mockWatchlistRepo.findEntryWithAuditAndReports).toHaveBeenCalledWith("entry-123");
- });
-
- it("should enrich audit history with user details", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: mockAuditHistory,
- });
- mockUserRepo.findUsersByIds.mockResolvedValue(mockUsers);
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(result.auditHistory[0].changedByUser).toEqual(mockUsers[0]);
- expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
- expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([1, 2]);
- });
-
- it("should handle audit history with duplicate user IDs", async () => {
- const auditWithDuplicates = [
- { ...mockAuditHistory[0], changedByUserId: 1 },
- { ...mockAuditHistory[1], changedByUserId: 1 },
- ];
-
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: auditWithDuplicates,
- });
- mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[0]]);
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([1]);
- expect(result.auditHistory[0].changedByUser).toEqual(mockUsers[0]);
- expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[0]);
- });
-
- it("should handle audit history with null user IDs", async () => {
- const auditWithNulls = [
- { ...mockAuditHistory[0], changedByUserId: null },
- { ...mockAuditHistory[1], changedByUserId: 2 },
- ];
-
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: auditWithNulls,
- });
- mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[1]]);
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([2]);
- expect(result.auditHistory[0].changedByUser).toBeUndefined();
- expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
- });
-
- it("should handle audit history with undefined user IDs", async () => {
- const auditWithUndefined = [
- { ...mockAuditHistory[0], changedByUserId: undefined },
- { ...mockAuditHistory[1], changedByUserId: 2 },
- ];
-
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: auditWithUndefined,
- });
- mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[1]]);
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([2]);
- expect(result.auditHistory[0].changedByUser).toBeUndefined();
- expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
- });
-
- it("should not call findUsersByIds when audit history is empty", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: [],
- });
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(result.auditHistory).toHaveLength(0);
- expect(mockUserRepo.findUsersByIds).not.toHaveBeenCalled();
- });
-
- it("should handle when user is not found in user map", async () => {
- mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
- entry: mockEntry,
- auditHistory: [mockAuditHistory[0]],
- });
- mockUserRepo.findUsersByIds.mockResolvedValue([]);
-
- const result = await getWatchlistEntryDetailsHandler({
- ctx: { user: mockUser },
- input: {
- id: "entry-123",
- },
- });
-
- expect(result.auditHistory[0].changedByUser).toBeUndefined();
- });
});
});
diff --git a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts
index 36be8c3530..8016f8794c 100644
--- a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts
@@ -1,8 +1,5 @@
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { prisma } from "@calcom/prisma";
-import { MembershipRole } from "@calcom/prisma/enums";
+import { getOrganizationWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
import { TRPCError } from "@trpc/server";
@@ -27,59 +24,39 @@ export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchli
});
}
- const permissionCheckService = new PermissionCheckService();
- const hasPermission = await permissionCheckService.checkPermission({
- userId: user.id,
- teamId: organizationId,
- permission: "watchlist.read",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
+ const service = getOrganizationWatchlistQueryService();
+
+ try {
+ return await service.getWatchlistEntryDetails({
+ organizationId,
+ userId: user.id,
+ entryId: input.id,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.NOT_FOUND:
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: error.message,
+ });
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
- if (!hasPermission) {
throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "You are not authorized to view blocklist entries",
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to get blocklist entry details",
});
}
-
- const watchlistRepo = new WatchlistRepository(prisma);
- const userRepo = new UserRepository(prisma);
-
- const result = await watchlistRepo.findEntryWithAuditAndReports(input.id);
-
- if (!result.entry) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Blocklist entry not found",
- });
- }
-
- if (result.entry.organizationId !== organizationId) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "You can only view blocklist entries from your organization",
- });
- }
-
- const userIds = result.auditHistory
- .map((audit) => audit.changedByUserId)
- .filter((id): id is number => id !== null && id !== undefined);
-
- const uniqueUserIds = Array.from(new Set(userIds));
-
- const users = uniqueUserIds.length > 0 ? await userRepo.findUsersByIds(uniqueUserIds) : [];
-
- const userMap = new Map(users.map((u) => [u.id, u]));
-
- const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
- ...audit,
- changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
- }));
-
- return {
- entry: result.entry,
- auditHistory: auditHistoryWithUsers,
- };
};
-
-export default getWatchlistEntryDetailsHandler;
diff --git a/packages/trpc/server/routers/viewer/organizations/listWatchlistEntries.handler.ts b/packages/trpc/server/routers/viewer/organizations/listWatchlistEntries.handler.ts
index 486b1e469d..abac310999 100644
--- a/packages/trpc/server/routers/viewer/organizations/listWatchlistEntries.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listWatchlistEntries.handler.ts
@@ -1,8 +1,5 @@
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
-import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
-import { prisma } from "@calcom/prisma";
-import { MembershipRole } from "@calcom/prisma/enums";
+import { getOrganizationWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
+import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
import { TRPCError } from "@trpc/server";
@@ -27,63 +24,37 @@ export const listWatchlistEntriesHandler = async ({ ctx, input }: ListWatchlistE
});
}
- const permissionCheckService = new PermissionCheckService();
- const hasPermission = await permissionCheckService.checkPermission({
- userId: user.id,
- teamId: organizationId,
- permission: "watchlist.read",
- fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
- });
+ const service = getOrganizationWatchlistQueryService();
+
+ try {
+ return await service.listWatchlistEntries({
+ organizationId,
+ userId: user.id,
+ limit: input.limit,
+ offset: input.offset,
+ searchTerm: input.searchTerm,
+ filters: input.filters,
+ });
+ } catch (error) {
+ if (error instanceof WatchlistError) {
+ switch (error.code) {
+ case WatchlistErrorCode.UNAUTHORIZED:
+ case WatchlistErrorCode.PERMISSION_DENIED:
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: error.message,
+ });
+ default:
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: error.message,
+ });
+ }
+ }
- if (!hasPermission) {
throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "You are not authorized to view blocklist entries",
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to list blocklist entries",
});
}
-
- const watchlistRepo = new WatchlistRepository(prisma);
- const userRepo = new UserRepository(prisma);
-
- const result = await watchlistRepo.findAllEntries({
- organizationId,
- limit: input.limit,
- offset: input.offset,
- searchTerm: input.searchTerm,
- filters: input.filters,
- });
-
- const userIds = result.rows
- .map((entry) => entry.audits?.[0]?.changedByUserId)
- .filter((id): id is number => id !== null && id !== undefined);
-
- const uniqueUserIds = Array.from(new Set(userIds));
-
- const users = uniqueUserIds.length > 0 ? await userRepo.findUsersByIds(uniqueUserIds) : [];
-
- const userMap = new Map(users.map((u) => [u.id, u]));
-
- const rowsWithCreators = result.rows.map((entry) => {
- const audit = entry.audits?.[0];
- if (audit?.changedByUserId) {
- const changedByUser = userMap.get(audit.changedByUserId);
- return {
- ...entry,
- audits: [
- {
- ...audit,
- changedByUser,
- },
- ],
- };
- }
- return entry;
- });
-
- return {
- rows: rowsWithCreators,
- meta: result.meta,
- };
};
-
-export default listWatchlistEntriesHandler;