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;