From a40c3eebd666ab5b432de81598263029c57ed02b Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:01:56 +0530 Subject: [PATCH] feat: group same booking reports together (#26529) Co-authored-by: unknown <> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../organizations/privacy/blocklist-table.tsx | 45 +- apps/web/public/static/locales/en/common.json | 14 +- .../components/BlockedEntriesColumns.tsx | 4 +- .../components/BlockedEntriesTable.tsx | 2 +- .../components/BookingReportDetailsModal.tsx | 32 +- .../components/PendingReportsBadge.tsx | 2 +- .../components/PendingReportsColumns.tsx | 17 +- .../components/PendingReportsTable.tsx | 20 +- packages/features/blocklist/types.ts | 5 + .../repositories/IBookingReportRepository.ts | 52 ++- .../PrismaBookingReportRepository.ts | 397 ++++++++++-------- .../ee/admin/pages/settings/blocklist.tsx | 45 +- .../settings/blocklist/BulkDismissReports.tsx | 2 +- .../lib/repository/WatchlistRepository.ts | 30 +- .../AdminWatchlistOperationsService.ts | 247 ++++------- .../lib/service/AdminWatchlistQueryService.ts | 4 +- .../OrganizationWatchlistOperationsService.ts | 89 +++- .../lib/service/WatchlistOperationsService.ts | 86 ---- .../admin/watchlist/addToWatchlist.handler.ts | 52 +-- .../admin/watchlist/addToWatchlist.schema.ts | 2 +- .../admin/watchlist/bulkDismiss.handler.ts | 30 +- .../admin/watchlist/bulkDismiss.schema.ts | 2 +- .../admin/watchlist/dismissReport.handler.ts | 39 +- .../admin/watchlist/dismissReport.schema.ts | 2 +- .../admin/watchlist/listReports.handler.ts | 1 + .../admin/watchlist/listReports.schema.ts | 1 + .../addToWatchlist.handler.test.ts | 93 ++-- .../organizations/addToWatchlist.handler.ts | 47 +-- .../organizations/addToWatchlist.schema.ts | 2 +- .../dismissBookingReport.handler.test.ts | 170 +++----- .../dismissBookingReport.handler.ts | 52 +-- .../dismissBookingReport.schema.ts | 2 +- .../listBookingReports.handler.test.ts | 43 +- .../listBookingReports.handler.ts | 3 +- .../listBookingReports.schema.ts | 1 + 35 files changed, 760 insertions(+), 875 deletions(-) diff --git a/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx b/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx index da9fad6b73..8f8cb0c951 100644 --- a/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx +++ b/apps/web/modules/ee/organizations/privacy/blocklist-table.tsx @@ -8,12 +8,20 @@ import { CreateBlocklistEntryModal, PendingReportsBadge, PendingReportsTable, + type SortByOption, } from "@calcom/features/blocklist"; import { useDataTable } from "@calcom/features/data-table"; import { DataTableToolbar } from "~/data-table/components"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; import { ToggleGroup } from "@calcom/ui/components/form"; import { showToast } from "@calcom/ui/components/toast"; @@ -33,6 +41,7 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) { const [activeView, setActiveView] = useState("blocked"); const [showCreateModal, setShowCreateModal] = useState(false); const [selectedEntryId, setSelectedEntryId] = useState(null); + const [sortBy, setSortBy] = useState("createdAt"); const utils = trpc.useUtils(); @@ -48,6 +57,7 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) { limit, offset, searchTerm, + sortBy, filters: { hasWatchlist: false, status: ["PENDING"] }, }, { placeholderData: keepPreviousData, enabled: activeView === "pending" } @@ -133,6 +143,33 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) { ]} /> + {activeView === "pending" && ( + + + + + + + setSortBy("createdAt")} + className={sortBy === "createdAt" ? "font-medium" : ""}> + {t("most_recent")} + + + + setSortBy("reportCount")} + className={sortBy === "reportCount" ? "font-medium" : ""}> + {t("most_reports")} + + + + + )}
{permissions?.canCreate && ( @@ -170,11 +207,11 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) { totalRowCount={reportsData?.meta?.totalRowCount ?? 0} isPending={isReportsPending} limit={limit} - onAddToBlocklist={(reportIds, type, onSuccess) => - addToWatchlist.mutate({ reportIds, type }, { onSuccess }) + onAddToBlocklist={(email, type, onSuccess) => + addToWatchlist.mutate({ email, type }, { onSuccess }) } - onDismiss={(reportId, onSuccess) => - dismissReport.mutate({ reportId }, { onSuccess }) + onDismiss={(email, onSuccess) => + dismissReport.mutate({ email }, { onSuccess }) } isAddingToBlocklist={addToWatchlist.isPending} isDismissing={dismissReport.isPending} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 198f53066a..d169cbef2b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2519,6 +2519,7 @@ "reschedule_reason_info": "The reason for rescheduling the booking", "invalid_event_name_variables": "{{item}} is an invalid variable in your event name", "select_all": "Select all", + "select_row": "Select row", "default_conferencing_bulk_title": "Bulk update existing event types", "members_default_schedule": "Member's default schedule", "set_by_admin": "Set by team admin", @@ -4188,6 +4189,8 @@ "blocked": "Blocked", "reported_by": "Reported by", "no_pending_reports": "No pending reports", + "most_recent": "Most recent", + "most_reports": "Most reports", "spam": "Spam", "dont_know_person": "Don't know person", "booking_report_dismissed": "Booking report dismissed", @@ -4195,18 +4198,19 @@ "booker_email": "Booker email", "email_slash_domain": "Email/Domain", "related_booking": "Related booking", + "related_bookings": "Related bookings", "dont_block": "Don't block", "block_this_email": "Email only", "block_all_from_domain": "All from this domain", "review_report": "Review report", "block": "Block", - "system_blocklist": "System blocklist", + "system_blocklist": "System Blocklist", "system_blocklist_description": "Manage blocked emails and domains for all bookings on the platform", "system_blocklist_entry_created": "System blocklist entry created successfully", "system_blocklist_entry_deleted": "System blocklist entry deleted successfully", - "blocked_by_system_admin": "Blocked by (System Admin)", - "add_to_system_blocklist": "Add to system blocklist", - "remove_from_system_blocklist": "Remove from system blocklist", + "blocked_by_system_admin": "Blocked By (System Admin)", + "add_to_system_blocklist": "Add to System Blocklist", + "remove_from_system_blocklist": "Remove from System Blocklist", "add_people_to_system_blocklist": "Block emails and domains platform-wide for all users and teams", "remove_value_from_system_blocklist": "Remove {{value}} from system blocklist?", "remove_value_from_system_blocklist_description": "This will unblock the entry platform-wide for all users and teams. This action is irreversible.", @@ -4217,7 +4221,7 @@ "system_wide_blocklist_warning_description": "This entry will block bookings for All users and teams on the platform.", "source": "Source", "manual": "Manual", - "free_domain_policy": "Free domain policy", + "free_domain_policy": "Free Domain Policy", "automatic": "Automatic", "scope": "Scope", "bulk_delete": "Delete", diff --git a/packages/features/blocklist/components/BlockedEntriesColumns.tsx b/packages/features/blocklist/components/BlockedEntriesColumns.tsx index 73271abc18..41a762740d 100644 --- a/packages/features/blocklist/components/BlockedEntriesColumns.tsx +++ b/packages/features/blocklist/components/BlockedEntriesColumns.tsx @@ -45,14 +45,14 @@ export function useBlockedEntriesColumns({ table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" + aria-label={t("select_all")} /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} - aria-label="Select row" + aria-label={t("select_row")} /> ), enableSorting: false, diff --git a/packages/features/blocklist/components/BlockedEntriesTable.tsx b/packages/features/blocklist/components/BlockedEntriesTable.tsx index 5e701e6e11..9aa7e42b27 100644 --- a/packages/features/blocklist/components/BlockedEntriesTable.tsx +++ b/packages/features/blocklist/components/BlockedEntriesTable.tsx @@ -179,7 +179,7 @@ export function BlockedEntriesTable({ } totalRowCount={totalRowCount}> {enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && ( - +

{t("number_selected", { count: numberOfSelectedRows })}

diff --git a/packages/features/blocklist/components/BookingReportDetailsModal.tsx b/packages/features/blocklist/components/BookingReportDetailsModal.tsx index cd26b81eec..eb07bfba49 100644 --- a/packages/features/blocklist/components/BookingReportDetailsModal.tsx +++ b/packages/features/blocklist/components/BookingReportDetailsModal.tsx @@ -10,24 +10,24 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/co import { ToggleGroup } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; -import type { BookingReport, BlocklistScope } from "../types"; +import type { GroupedBookingReport, BlocklistScope } from "../types"; interface FormData { blockType: WatchlistType; } -export interface BookingReportDetailsModalProps { +export interface BookingReportDetailsModalProps { scope: BlocklistScope; entry: T | null; isOpen: boolean; onClose: () => void; - onAddToBlocklist: (reportIds: string[], type: WatchlistType) => void; - onDismiss: (reportId: string) => void; + onAddToBlocklist: (email: string, type: WatchlistType) => void; + onDismiss: (email: string) => void; isAddingToBlocklist?: boolean; isDismissing?: boolean; } -export function BookingReportDetailsModal({ +export function BookingReportDetailsModal({ scope, entry, isOpen, @@ -53,12 +53,12 @@ export function BookingReportDetailsModal({ const onSubmit = (data: FormData) => { if (!entry) return; - onAddToBlocklist([entry.id], data.blockType); + onAddToBlocklist(entry.bookerEmail, data.blockType); }; const handleDismiss = () => { if (!entry) return; - onDismiss(entry.id); + onDismiss(entry.bookerEmail); }; const handleGoBack = () => { @@ -123,14 +123,18 @@ export function BookingReportDetailsModal({
- -
- {entry.booking.title} - -
- +
+ {entry.reports.map((report) => ( + +
+ {report.booking.title || t("untitled")} + +
+ + ))} +
diff --git a/packages/features/blocklist/components/PendingReportsBadge.tsx b/packages/features/blocklist/components/PendingReportsBadge.tsx index 6e67bba6ac..8c5d9b5402 100644 --- a/packages/features/blocklist/components/PendingReportsBadge.tsx +++ b/packages/features/blocklist/components/PendingReportsBadge.tsx @@ -9,7 +9,7 @@ export interface PendingReportsBadgeProps { export function PendingReportsBadge({ count }: PendingReportsBadgeProps) { if (!count) return null; return ( - + {count} ); diff --git a/packages/features/blocklist/components/PendingReportsColumns.tsx b/packages/features/blocklist/components/PendingReportsColumns.tsx index 1b50c93ac6..f37d25d58e 100644 --- a/packages/features/blocklist/components/PendingReportsColumns.tsx +++ b/packages/features/blocklist/components/PendingReportsColumns.tsx @@ -14,16 +14,16 @@ import { } from "@calcom/ui/components/dropdown"; import { Checkbox } from "@calcom/ui/components/form"; -import type { BookingReport, BlocklistScope } from "../types"; +import type { GroupedBookingReport, BlocklistScope } from "../types"; -interface UsePendingReportsColumnsProps { +interface UsePendingReportsColumnsProps { t: (key: string) => string; scope: BlocklistScope; enableRowSelection?: boolean; onViewDetails: (entry: T) => void; } -export function usePendingReportsColumns({ +export function usePendingReportsColumns({ t, scope, enableRowSelection = false, @@ -66,12 +66,17 @@ export function usePendingReportsColumns({ enableHiding: false, size: isSystem ? 200 : 250, cell: ({ row }) => { - const email = row.original.bookerEmail; + const { bookerEmail, reportCount } = row.original; return ( -
+
- {email} + {bookerEmail} + {reportCount > 1 && ( + + {reportCount} + + )}
); }, diff --git a/packages/features/blocklist/components/PendingReportsTable.tsx b/packages/features/blocklist/components/PendingReportsTable.tsx index f7caa27599..2791218a7a 100644 --- a/packages/features/blocklist/components/PendingReportsTable.tsx +++ b/packages/features/blocklist/components/PendingReportsTable.tsx @@ -10,25 +10,27 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { WatchlistType } from "@calcom/prisma/enums"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; -import type { BookingReport, BlocklistScope } from "../types"; +import type { GroupedBookingReport, BlocklistScope } from "../types"; import { BookingReportDetailsModal } from "./BookingReportDetailsModal"; import { usePendingReportsColumns } from "./PendingReportsColumns"; -export interface PendingReportsTableProps { +export type SortByOption = "createdAt" | "reportCount"; + +export interface PendingReportsTableProps { scope: BlocklistScope; data: T[]; totalRowCount: number; isPending: boolean; limit: number; - onAddToBlocklist: (reportIds: string[], type: WatchlistType, onSuccess: () => void) => void; - onDismiss: (reportId: string, onSuccess: () => void) => void; + onAddToBlocklist: (email: string, type: WatchlistType, onSuccess: () => void) => void; + onDismiss: (email: string, onSuccess: () => void) => void; isAddingToBlocklist?: boolean; isDismissing?: boolean; enableRowSelection?: boolean; renderBulkActions?: (selectedReports: T[], clearSelection: () => void) => ReactNode; } -export function PendingReportsTable({ +export function PendingReportsTable({ scope, data, totalRowCount, @@ -78,7 +80,7 @@ export function PendingReportsTable({ state: { rowSelection, }, - getRowId: (row) => row.id, + getRowId: (row) => row.bookerEmail, }); const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length; @@ -103,7 +105,7 @@ export function PendingReportsTable({ } totalRowCount={totalRowCount}> {enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && ( - +

{t("number_selected", { count: numberOfSelectedRows })}

@@ -117,8 +119,8 @@ export function PendingReportsTable({ entry={selectedReport} isOpen={showReviewDialog} onClose={handleCloseModal} - onAddToBlocklist={(reportIds, type) => onAddToBlocklist(reportIds, type, handleCloseModal)} - onDismiss={(reportId) => onDismiss(reportId, handleCloseModal)} + onAddToBlocklist={(email, type) => onAddToBlocklist(email, type, handleCloseModal)} + onDismiss={(email) => onDismiss(email, handleCloseModal)} isAddingToBlocklist={isAddingToBlocklist} isDismissing={isDismissing} /> diff --git a/packages/features/blocklist/types.ts b/packages/features/blocklist/types.ts index 1cecc50a89..6dce627a15 100644 --- a/packages/features/blocklist/types.ts +++ b/packages/features/blocklist/types.ts @@ -59,6 +59,11 @@ export interface BookingReport { organization?: { name: string } | null; } +export interface GroupedBookingReport extends BookingReport { + reportCount: number; + reports: BookingReport[]; +} + export interface CreateBlocklistEntryFormData { type: WatchlistType; value: string; diff --git a/packages/features/bookingReport/repositories/IBookingReportRepository.ts b/packages/features/bookingReport/repositories/IBookingReportRepository.ts index 4d7fd01c1f..630d0b0dc1 100644 --- a/packages/features/bookingReport/repositories/IBookingReportRepository.ts +++ b/packages/features/bookingReport/repositories/IBookingReportRepository.ts @@ -76,6 +76,11 @@ export interface BookingReportWithDetails { } | null; } +export interface GroupedBookingReportWithDetails extends BookingReportWithDetails { + reportCount: number; + reports: BookingReportWithDetails[]; +} + export interface IBookingReportRepository { createReport(input: CreateBookingReportInput): Promise<{ id: string }>; @@ -90,6 +95,18 @@ export interface IBookingReportRepository { meta: { totalRowCount: number }; }>; + findGroupedReportedBookings(params: { + organizationId?: number; + skip?: number; + take?: number; + searchTerm?: string; + filters?: ListBookingReportsFilters; + systemFilters?: SystemBookingReportsFilters; + }): Promise<{ + rows: GroupedBookingReportWithDetails[]; + meta: { totalRowCount: number }; + }>; + findReportsByIds(params: { reportIds: string[]; organizationId?: number; @@ -103,12 +120,6 @@ 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; @@ -134,4 +145,33 @@ export interface IBookingReportRepository { }): Promise<{ updated: number }>; countSystemPendingReports(): Promise; + + dismissReportsByEmail(params: { + email: string; + status: BookingReportStatus; + organizationId: number; + }): Promise<{ count: number }>; + + dismissSystemReportsByEmail(params: { + email: string; + systemStatus: SystemReportStatus; + }): Promise<{ count: number }>; + + findPendingReportsByEmail(params: { + email: string; + organizationId: number; + }): Promise>; + + findPendingReportsByDomain(params: { + domain: string; + organizationId: number; + }): Promise>; + + findPendingSystemReportsByEmail(params: { + email: string; + }): Promise>; + + findPendingSystemReportsByDomain(params: { + domain: string; + }): Promise>; } diff --git a/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts b/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts index be6e6aeab8..e4ae21efba 100644 --- a/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts +++ b/packages/features/bookingReport/repositories/PrismaBookingReportRepository.ts @@ -6,6 +6,7 @@ import type { IBookingReportRepository, CreateBookingReportInput, BookingReportWithDetails, + GroupedBookingReportWithDetails, ListBookingReportsFilters, SystemBookingReportsFilters, } from "./IBookingReportRepository"; @@ -13,6 +14,69 @@ import type { export class PrismaBookingReportRepository implements IBookingReportRepository { constructor(private readonly prismaClient: PrismaClient) {} + private buildWhereClause(params: { + organizationId?: number; + searchTerm?: string; + filters?: ListBookingReportsFilters; + systemFilters?: SystemBookingReportsFilters; + }): Prisma.BookingReportWhereInput { + const where: Prisma.BookingReportWhereInput = {}; + + if (params.organizationId) { + where.organizationId = params.organizationId; + } + + if (params.searchTerm) { + where.OR = [ + { bookerEmail: { contains: params.searchTerm, mode: "insensitive" } }, + { reportedBy: { email: { contains: params.searchTerm, mode: "insensitive" } } }, + ]; + } + + if (params.filters?.hasWatchlist !== undefined) { + where.watchlistId = params.filters.hasWatchlist ? { not: null } : null; + } + + if (params.filters?.reason && params.filters.reason.length > 0) { + where.reason = { in: params.filters.reason }; + } + + if (params.filters?.cancelled !== undefined) { + where.cancelled = params.filters.cancelled; + } + + if (params.filters?.status && params.filters.status.length > 0) { + where.status = { in: params.filters.status }; + } + + if (params.systemFilters?.systemStatus && params.systemFilters.systemStatus.length > 0) { + where.systemStatus = { in: params.systemFilters.systemStatus }; + } + + return where; + } + + private readonly reportSelect = { + id: true, + bookingUid: true, + bookerEmail: true, + reportedById: true, + reason: true, + description: true, + cancelled: true, + createdAt: true, + status: true, + systemStatus: true, + watchlistId: true, + globalWatchlistId: true, + organizationId: true, + reportedBy: { select: { id: true, email: true, name: true } }, + booking: { select: { id: true, uid: true, title: true, startTime: true, endTime: true } }, + watchlist: { select: { id: true, type: true, value: true, action: true, description: true } }, + globalWatchlist: { select: { id: true, type: true, value: true, action: true, description: true } }, + organization: { select: { id: true, name: true, slug: true } }, + } as const; + async createReport(input: CreateBookingReportInput): Promise<{ id: string }> { const report = await this.prismaClient.bookingReport.create({ data: { @@ -40,38 +104,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { rows: BookingReportWithDetails[]; meta: { totalRowCount: number }; }> { - const where: Prisma.BookingReportWhereInput = {}; - - if (params.organizationId) { - where.organizationId = params.organizationId; - } - - if (params.searchTerm) { - where.OR = [ - { bookerEmail: { contains: params.searchTerm, mode: "insensitive" } }, - { - reportedBy: { - email: { contains: params.searchTerm, mode: "insensitive" }, - }, - }, - ]; - } - - if (params.filters?.hasWatchlist !== undefined) { - where.watchlistId = params.filters.hasWatchlist ? { not: null } : null; - } - - if (params.filters?.reason && params.filters.reason.length > 0) { - where.reason = { in: params.filters.reason }; - } - - if (params.filters?.cancelled !== undefined) { - where.cancelled = params.filters.cancelled; - } - - if (params.filters?.status && params.filters.status.length > 0) { - where.status = { in: params.filters.status }; - } + const where = this.buildWhereClause(params); if (params.systemFilters?.systemStatus && params.systemFilters.systemStatus.length > 0) { where.systemStatus = { in: params.systemFilters.systemStatus }; @@ -82,85 +115,80 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { where, skip: params.skip, take: params.take, - select: { - id: true, - bookingUid: true, - bookerEmail: true, - reportedById: true, - reason: true, - description: true, - cancelled: true, - createdAt: true, - status: true, - systemStatus: true, - watchlistId: true, - globalWatchlistId: true, - organizationId: true, - reportedBy: { - select: { - id: true, - email: true, - name: true, - }, - }, - booking: { - select: { - id: true, - uid: true, - title: true, - startTime: true, - endTime: true, - }, - }, - watchlist: { - select: { - id: true, - type: true, - value: true, - action: true, - description: true, - }, - }, - globalWatchlist: { - select: { - id: true, - type: true, - value: true, - action: true, - description: true, - }, - }, - organization: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, + select: this.reportSelect, orderBy: { createdAt: "desc" }, }), this.prismaClient.bookingReport.count({ where }), ]); - const rows = reports.map((report) => ({ - ...report, - reporter: report.reportedBy, - })); + const rows = reports.map((report) => ({ ...report, reporter: report.reportedBy })); - return { - rows, - meta: { totalRowCount: totalCount }, - }; + return { rows, meta: { totalRowCount: totalCount } }; + } + + async findGroupedReportedBookings(params: { + organizationId?: number; + skip?: number; + take?: number; + searchTerm?: string; + filters?: ListBookingReportsFilters; + systemFilters?: SystemBookingReportsFilters; + sortBy?: "createdAt" | "reportCount"; + }): Promise<{ + rows: GroupedBookingReportWithDetails[]; + meta: { totalRowCount: number }; + }> { + const where = this.buildWhereClause(params); + + const orderBy = + params.sortBy === "reportCount" + ? { _count: { bookerEmail: "desc" as const } } + : { _max: { createdAt: "desc" as const } }; + + const groupedEmails = await this.prismaClient.bookingReport.groupBy({ + by: ["bookerEmail"], + where, + _count: { bookerEmail: true }, + _max: { createdAt: true }, + orderBy, + }); + + const totalRowCount = groupedEmails.length; + const paginatedEmails = groupedEmails.slice(params.skip ?? 0, (params.skip ?? 0) + (params.take ?? 25)); + const emailList = paginatedEmails.map((g) => g.bookerEmail); + + if (emailList.length === 0) { + return { rows: [], meta: { totalRowCount } }; + } + + const reports = await this.prismaClient.bookingReport.findMany({ + where: { ...where, bookerEmail: { in: emailList } }, + select: this.reportSelect, + orderBy: { createdAt: "asc" }, + }); + + const reportsByEmail = new Map(); + for (const report of reports) { + const mapped: BookingReportWithDetails = { ...report, reporter: report.reportedBy }; + const existing = reportsByEmail.get(report.bookerEmail) || []; + existing.push(mapped); + reportsByEmail.set(report.bookerEmail, existing); + } + + const rows: GroupedBookingReportWithDetails[] = paginatedEmails.map((group) => { + const emailReports = reportsByEmail.get(group.bookerEmail) || []; + const firstReport = emailReports[0]; + return { ...firstReport, reportCount: group._count.bookerEmail, reports: emailReports }; + }); + + return { rows, meta: { totalRowCount } }; } async findReportsByIds(params: { reportIds: string[]; organizationId?: number; }): Promise { - const where: Prisma.BookingReportWhereInput = { - id: { in: params.reportIds }, - }; + const where: Prisma.BookingReportWhereInput = { id: { in: params.reportIds } }; if (params.organizationId) { where.organizationId = params.organizationId; @@ -168,68 +196,10 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { const reports = await this.prismaClient.bookingReport.findMany({ where, - select: { - id: true, - bookingUid: true, - bookerEmail: true, - reportedById: true, - reason: true, - description: true, - cancelled: true, - createdAt: true, - status: true, - systemStatus: true, - watchlistId: true, - globalWatchlistId: true, - organizationId: true, - reportedBy: { - select: { - id: true, - email: true, - name: true, - }, - }, - booking: { - select: { - id: true, - uid: true, - title: true, - startTime: true, - endTime: true, - }, - }, - watchlist: { - select: { - id: true, - type: true, - value: true, - action: true, - description: true, - }, - }, - globalWatchlist: { - select: { - id: true, - type: true, - value: true, - action: true, - description: true, - }, - }, - organization: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, + select: this.reportSelect, }); - return reports.map((report) => ({ - ...report, - reporter: report.reportedBy, - })); + return reports.map((report) => ({ ...report, reporter: report.reportedBy })); } async linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise { @@ -258,27 +228,6 @@ 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; @@ -371,4 +320,92 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { }, }); } + + async dismissReportsByEmail(params: { + email: string; + status: BookingReportStatus; + organizationId: number; + }): Promise<{ count: number }> { + const result = await this.prismaClient.bookingReport.updateMany({ + where: { + bookerEmail: { equals: params.email, mode: "insensitive" }, + watchlistId: null, + status: "PENDING", + organizationId: params.organizationId, + }, + data: { status: params.status }, + }); + return { count: result.count }; + } + + async dismissSystemReportsByEmail(params: { + email: string; + systemStatus: SystemReportStatus; + }): Promise<{ count: number }> { + const result = await this.prismaClient.bookingReport.updateMany({ + where: { + bookerEmail: { equals: params.email, mode: "insensitive" }, + globalWatchlistId: null, + systemStatus: "PENDING", + }, + data: { systemStatus: params.systemStatus }, + }); + return { count: result.count }; + } + + async findPendingReportsByEmail(params: { + email: string; + organizationId: number; + }): Promise> { + return this.prismaClient.bookingReport.findMany({ + where: { + bookerEmail: { equals: params.email, mode: "insensitive" }, + status: "PENDING", + watchlistId: null, + organizationId: params.organizationId, + }, + select: { id: true, bookerEmail: true, watchlistId: true }, + }); + } + + async findPendingReportsByDomain(params: { + domain: string; + organizationId: number; + }): Promise> { + return this.prismaClient.bookingReport.findMany({ + where: { + bookerEmail: { endsWith: `@${params.domain}`, mode: "insensitive" }, + status: "PENDING", + watchlistId: null, + organizationId: params.organizationId, + }, + select: { id: true, bookerEmail: true, watchlistId: true }, + }); + } + + async findPendingSystemReportsByEmail(params: { + email: string; + }): Promise> { + return this.prismaClient.bookingReport.findMany({ + where: { + bookerEmail: { equals: params.email, mode: "insensitive" }, + systemStatus: "PENDING", + globalWatchlistId: null, + }, + select: { id: true, bookerEmail: true, globalWatchlistId: true }, + }); + } + + async findPendingSystemReportsByDomain(params: { + domain: string; + }): Promise> { + return this.prismaClient.bookingReport.findMany({ + where: { + bookerEmail: { endsWith: `@${params.domain}`, mode: "insensitive" }, + systemStatus: "PENDING", + globalWatchlistId: null, + }, + select: { id: true, bookerEmail: true, globalWatchlistId: true }, + }); + } } diff --git a/packages/features/ee/admin/pages/settings/blocklist.tsx b/packages/features/ee/admin/pages/settings/blocklist.tsx index b60eafccae..45313f86aa 100644 --- a/packages/features/ee/admin/pages/settings/blocklist.tsx +++ b/packages/features/ee/admin/pages/settings/blocklist.tsx @@ -9,12 +9,20 @@ import { CreateBlocklistEntryModal, PendingReportsBadge, PendingReportsTable, + type SortByOption, } from "@calcom/features/blocklist"; import { DataTableProvider, useDataTable } from "@calcom/features/data-table"; import { DataTableToolbar } from "~/data-table/components"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@calcom/ui/components/dropdown"; import { ToggleGroup } from "@calcom/ui/components/form"; import { showToast } from "@calcom/ui/components/toast"; @@ -29,6 +37,7 @@ function SystemBlocklistContent() { const [activeView, setActiveView] = useState("blocked"); const [showCreateModal, setShowCreateModal] = useState(false); const [selectedEntryId, setSelectedEntryId] = useState(null); + const [sortBy, setSortBy] = useState("createdAt"); const utils = trpc.useUtils(); @@ -44,6 +53,7 @@ function SystemBlocklistContent() { limit, offset, searchTerm, + sortBy, systemFilters: { systemStatus: ["PENDING"] }, }, { placeholderData: keepPreviousData, enabled: activeView === "pending" } @@ -128,6 +138,33 @@ function SystemBlocklistContent() { ]} /> + {activeView === "pending" && ( + + + + + + + setSortBy("createdAt")} + className={sortBy === "createdAt" ? "font-medium" : ""}> + {t("most_recent")} + + + + setSortBy("reportCount")} + className={sortBy === "reportCount" ? "font-medium" : ""}> + {t("most_reports")} + + + + + )}
{activeView === "blocked" && ( @@ -171,11 +208,11 @@ function SystemBlocklistContent() { totalRowCount={reportsData?.meta?.totalRowCount ?? 0} isPending={isReportsPending} limit={limit} - onAddToBlocklist={(reportIds, type, onSuccess) => - addToWatchlist.mutate({ reportIds, type }, { onSuccess }) + onAddToBlocklist={(email, type, onSuccess) => + addToWatchlist.mutate({ email, type }, { onSuccess }) } - onDismiss={(reportId, onSuccess) => - dismissReport.mutate({ reportId }, { onSuccess }) + onDismiss={(email, onSuccess) => + dismissReport.mutate({ email }, { onSuccess }) } isAddingToBlocklist={addToWatchlist.isPending} isDismissing={dismissReport.isPending} diff --git a/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx b/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx index 257fcd7118..a577d37f64 100644 --- a/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx +++ b/packages/features/ee/admin/pages/settings/blocklist/BulkDismissReports.tsx @@ -56,7 +56,7 @@ export function BulkDismissReports({ reports, onRemove }: Props) { isPending={bulkDismissMutation.isPending} onConfirm={() => { bulkDismissMutation.mutate({ - reportIds: reports.map((report) => report.id), + emails: reports.map((group) => group.bookerEmail), }); }} > diff --git a/packages/features/watchlist/lib/repository/WatchlistRepository.ts b/packages/features/watchlist/lib/repository/WatchlistRepository.ts index a365e810b6..8d0aebe30e 100644 --- a/packages/features/watchlist/lib/repository/WatchlistRepository.ts +++ b/packages/features/watchlist/lib/repository/WatchlistRepository.ts @@ -169,6 +169,17 @@ export class WatchlistRepository implements IWatchlistRepository { | null; auditHistory: WatchlistAuditEntry[]; }> { + const bookingReportSelect = { + select: { + booking: { + select: { + uid: true, + title: true, + }, + }, + }, + } as const; + const entry = await this.prismaClient.watchlist.findUnique({ where: { id }, select: { @@ -181,16 +192,8 @@ export class WatchlistRepository implements IWatchlistRepository { isGlobal: true, source: true, lastUpdatedAt: true, - bookingReports: { - select: { - booking: { - select: { - uid: true, - title: true, - }, - }, - }, - }, + bookingReports: bookingReportSelect, + globalBookingReports: bookingReportSelect, audits: { select: { id: true, @@ -209,6 +212,11 @@ export class WatchlistRepository implements IWatchlistRepository { }, }); + const allBookingReports = [ + ...(entry?.bookingReports ?? []), + ...(entry?.globalBookingReports ?? []), + ]; + return { entry: entry ? { @@ -221,7 +229,7 @@ export class WatchlistRepository implements IWatchlistRepository { isGlobal: entry.isGlobal, source: entry.source, lastUpdatedAt: entry.lastUpdatedAt, - bookingReports: entry.bookingReports, + bookingReports: allBookingReports, } : null, auditHistory: entry?.audits || [], diff --git a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts index 0b393f624f..588c53232d 100644 --- a/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts +++ b/packages/features/watchlist/lib/service/AdminWatchlistOperationsService.ts @@ -6,7 +6,6 @@ import { SystemReportStatus, WatchlistType } from "@calcom/prisma/enums"; import { WatchlistErrors } from "../errors/WatchlistErrors"; import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization"; import type { - AddReportsToWatchlistInput, AddReportsToWatchlistResult, DeleteWatchlistEntryInput, DeleteWatchlistEntryResult, @@ -25,24 +24,32 @@ export interface BulkDeleteWatchlistEntriesResult { 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; } +export interface AddToWatchlistByEmailInput { + email: string; + type: WatchlistType; + description?: string; + userId: number; +} + +export interface DismissReportByEmailInput { + email: string; +} + +export interface DismissReportByEmailResult { + success: boolean; + count: number; +} + +export interface BulkDismissReportsByEmailInput { + emails: string[]; +} + type Deps = { watchlistRepo: WatchlistRepository; bookingReportRepo: PrismaBookingReportRepository; @@ -62,89 +69,6 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService }; } - protected async findReports( - reportIds: string[] - ): Promise< - Array<{ id: string; bookerEmail: string; watchlistId: string | null; globalWatchlistId: string | null }> - > { - const reports = await this.deps.bookingReportRepo.findReportsByIds({ - reportIds, - }); - - return reports; - } - - 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.globalWatchlistId); - - if (reportsToAdd.length === 0) { - throw WatchlistErrors.alreadyInWatchlist("All selected reports are already in the global blocklist"); - } - - 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.adminLog.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.bulkLinkGlobalWatchlistWithSystemStatus({ - links: results.map((r) => ({ reportId: r.reportId, globalWatchlistId: r.watchlistId })), - systemStatus: SystemReportStatus.BLOCKED, - }); - - return { - success: true, - message: `Successfully added ${results.length} report(s) to global blocklist`, - addedCount: results.length, - skippedCount: validReports.length - reportsToAdd.length, - results, - }; - } - async bulkDeleteWatchlistEntries( input: BulkDeleteWatchlistEntriesInput ): Promise { @@ -187,7 +111,6 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService return { success: successCount, failed: failed.length, - // TODO: use translate keys in follow up frontend PR message: failed.length === 0 ? "All entries deleted successfully" @@ -214,77 +137,83 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService }; } - async dismissReport(input: DismissReportInput): Promise { - const reports = await this.deps.bookingReportRepo.findReportsByIds({ - reportIds: [input.reportId], - }); + async addToWatchlistByEmail(input: AddToWatchlistByEmailInput): Promise { + const scope = this.getScope(); - if (reports.length === 0) { - throw WatchlistErrors.notFound("Report not found"); - } + let watchlistValue: string; + let reports: Array<{ id: string; bookerEmail: string; globalWatchlistId: string | null }>; - const report = reports[0]; - - if (report.globalWatchlistId) { - throw WatchlistErrors.validationError( - "Cannot dismiss a report that has already been added to the global blocklist" - ); - } - - await this.deps.bookingReportRepo.updateSystemReportStatus({ - reportId: input.reportId, - systemStatus: SystemReportStatus.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.globalWatchlistId) { - failed.push({ id: reportId, reason: "Already added to global blocklist" }); - continue; - } - - validReportIds.push(reportId); - } - - let successCount = 0; - if (validReportIds.length > 0) { - const result = await this.deps.bookingReportRepo.bulkUpdateSystemReportStatus({ - reportIds: validReportIds, - systemStatus: SystemReportStatus.DISMISSED, + if (input.type === WatchlistType.EMAIL) { + watchlistValue = normalizeEmail(input.email); + reports = await this.deps.bookingReportRepo.findPendingSystemReportsByEmail({ + email: watchlistValue, + }); + } else { + watchlistValue = extractDomainFromEmail(input.email); + reports = await this.deps.bookingReportRepo.findPendingSystemReportsByDomain({ + domain: watchlistValue, }); - 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}`); + const { watchlistEntry } = await this.deps.watchlistRepo.createEntryFromReport({ + type: input.type, + value: watchlistValue, + organizationId: scope.organizationId, + isGlobal: scope.isGlobal, + userId: input.userId, + description: input.description, + }); + + if (reports.length > 0) { + await this.deps.bookingReportRepo.bulkLinkGlobalWatchlistWithSystemStatus({ + links: reports.map((r) => ({ reportId: r.id, globalWatchlistId: watchlistEntry.id })), + systemStatus: SystemReportStatus.BLOCKED, + }); } return { - success: successCount, - failed: failed.length, + success: true, + message: `Successfully added ${input.type === WatchlistType.EMAIL ? "email" : "domain"} to global blocklist`, + addedCount: reports.length, + skippedCount: 0, + results: reports.map((r) => ({ reportId: r.id, watchlistId: watchlistEntry.id })), + }; + } + + async dismissReportByEmail(input: DismissReportByEmailInput): Promise { + const normalizedEmail = normalizeEmail(input.email); + const result = await this.deps.bookingReportRepo.dismissSystemReportsByEmail({ + email: normalizedEmail, + systemStatus: SystemReportStatus.DISMISSED, + }); + + if (result.count === 0) { + throw WatchlistErrors.notFound("No pending reports found for this email"); + } + + return { success: true, count: result.count }; + } + + async bulkDismissReportsByEmail(input: BulkDismissReportsByEmailInput): Promise { + const results = await Promise.all( + input.emails.map(async (email) => { + const normalizedEmail = normalizeEmail(email); + return this.deps.bookingReportRepo.dismissSystemReportsByEmail({ + email: normalizedEmail, + systemStatus: SystemReportStatus.DISMISSED, + }); + }) + ); + + const totalDismissed = results.reduce((sum, r) => sum + r.count, 0); + + return { + success: totalDismissed, + failed: 0, message: - failed.length === 0 - ? "All reports dismissed successfully" - : `Dismissed ${successCount} reports, ${failed.length} failed`, + totalDismissed === 0 + ? "No pending reports found" + : `Dismissed ${totalDismissed} report(s) successfully`, }; } } diff --git a/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts b/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts index 213ec7b63a..8b0b14b93a 100644 --- a/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts +++ b/packages/features/watchlist/lib/service/AdminWatchlistQueryService.ts @@ -30,6 +30,7 @@ export interface ListBookingReportsInput { searchTerm?: string; filters?: ListBookingReportsFilters; systemFilters?: SystemBookingReportsFilters; + sortBy?: "createdAt" | "reportCount"; } type Deps = { @@ -115,12 +116,13 @@ export class AdminWatchlistQueryService { } async listBookingReports(input: ListBookingReportsInput) { - const result = await this.deps.bookingReportRepo.findAllReportedBookings({ + const result = await this.deps.bookingReportRepo.findGroupedReportedBookings({ skip: input.offset, take: input.limit, searchTerm: input.searchTerm, filters: input.filters, systemFilters: input.systemFilters, + sortBy: input.sortBy, }); return result; diff --git a/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts b/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts index 62e824aeee..3a980705a0 100644 --- a/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts +++ b/packages/features/watchlist/lib/service/OrganizationWatchlistOperationsService.ts @@ -2,11 +2,11 @@ import type { PrismaBookingReportRepository } from "@calcom/features/bookingRepo import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { BookingReportStatus, MembershipRole, WatchlistType } from "@calcom/prisma/enums"; import { WatchlistErrors } from "../errors/WatchlistErrors"; +import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization"; import type { - AddReportsToWatchlistInput, AddReportsToWatchlistResult, CreateWatchlistEntryInput, CreateWatchlistEntryResult, @@ -40,17 +40,6 @@ export class OrganizationWatchlistOperationsService extends WatchlistOperationsS }; } - 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, @@ -66,11 +55,6 @@ export class OrganizationWatchlistOperationsService extends WatchlistOperationsS } } - 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); @@ -80,4 +64,73 @@ export class OrganizationWatchlistOperationsService extends WatchlistOperationsS await this.checkPermission(input.userId, "watchlist.delete"); return super.deleteWatchlistEntry(input); } + + async addToWatchlistByEmail(input: { + email: string; + type: WatchlistType; + description?: string; + userId: number; + }): Promise { + await this.checkPermission(input.userId, "watchlist.create"); + + const scope = this.getScope(); + + let watchlistValue: string; + let reports: Array<{ id: string; bookerEmail: string; watchlistId: string | null }>; + + if (input.type === WatchlistType.EMAIL) { + watchlistValue = normalizeEmail(input.email); + reports = await this.deps.bookingReportRepo.findPendingReportsByEmail({ + email: watchlistValue, + organizationId: this.organizationId, + }); + } else { + watchlistValue = extractDomainFromEmail(input.email); + reports = await this.deps.bookingReportRepo.findPendingReportsByDomain({ + domain: watchlistValue, + organizationId: this.organizationId, + }); + } + + const { watchlistEntry } = await this.deps.watchlistRepo.createEntryFromReport({ + type: input.type, + value: watchlistValue, + organizationId: scope.organizationId, + isGlobal: scope.isGlobal, + userId: input.userId, + description: input.description, + }); + + if (reports.length > 0) { + await this.deps.bookingReportRepo.bulkLinkWatchlistWithStatus({ + links: reports.map((r) => ({ reportId: r.id, watchlistId: watchlistEntry.id })), + status: BookingReportStatus.BLOCKED, + }); + } + + return { + success: true, + message: `Successfully added ${input.type === WatchlistType.EMAIL ? "email" : "domain"} to blocklist`, + addedCount: reports.length, + skippedCount: 0, + results: reports.map((r) => ({ reportId: r.id, watchlistId: watchlistEntry.id })), + }; + } + + async dismissReportByEmail(input: { email: string; userId: number }): Promise<{ success: boolean; count: number }> { + await this.checkPermission(input.userId, "watchlist.update"); + + const normalizedEmail = normalizeEmail(input.email); + const result = await this.deps.bookingReportRepo.dismissReportsByEmail({ + email: normalizedEmail, + status: BookingReportStatus.DISMISSED, + organizationId: this.organizationId, + }); + + if (result.count === 0) { + throw WatchlistErrors.notFound("No pending reports found for this email"); + } + + return { success: true, count: result.count }; + } } diff --git a/packages/features/watchlist/lib/service/WatchlistOperationsService.ts b/packages/features/watchlist/lib/service/WatchlistOperationsService.ts index 6261e47287..3e8c18417c 100644 --- a/packages/features/watchlist/lib/service/WatchlistOperationsService.ts +++ b/packages/features/watchlist/lib/service/WatchlistOperationsService.ts @@ -2,18 +2,9 @@ import type { PrismaBookingReportRepository } from "@calcom/features/bookingRepo import type { WatchlistEntry } from "@calcom/features/watchlist/lib/repository/IWatchlistRepository"; import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository"; import { domainRegex, emailRegex } from "@calcom/lib/emailSchema"; -import logger from "@calcom/lib/logger"; 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; @@ -56,16 +47,10 @@ type Deps = { }; 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"); @@ -76,77 +61,6 @@ export abstract class WatchlistOperationsService { } } - 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(); diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts index e76c77f4d0..ed97474f9d 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.handler.ts @@ -1,7 +1,4 @@ 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"; @@ -14,48 +11,11 @@ type AddToWatchlistOptions = { }; 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; - } + return service.addToWatchlistByEmail({ + email: input.email, + type: input.type, + description: input.description, + userId: ctx.user.id, + }); }; diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts index b081ef675b..5024f69dd1 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/addToWatchlist.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { WatchlistType } from "@calcom/prisma/enums"; export const ZAddToWatchlistInputSchema = z.object({ - reportIds: z.array(z.string().uuid()).min(1), + email: z.string().email(), type: z.nativeEnum(WatchlistType), description: z.string().optional(), }); diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts index 544cb65082..c912cd8d90 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.handler.ts @@ -1,7 +1,4 @@ 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"; @@ -15,30 +12,5 @@ type BulkDismissReportsOptions = { 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", - }); - } + return service.bulkDismissReportsByEmail({ emails: input.emails }); }; diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts index b1fa896a36..3ba9759c61 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/bulkDismiss.schema.ts @@ -1,7 +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"), + emails: z.array(z.string().email()).min(1, "At least one email must be selected"), }); export type TBulkDismissReportsInputSchema = 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 index c082a13cce..e0e027da84 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.handler.ts @@ -1,7 +1,4 @@ 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"; @@ -15,35 +12,11 @@ type DismissReportOptions = { export const dismissReportHandler = async ({ input }: DismissReportOptions) => { const service = getAdminWatchlistOperationsService(); + const result = await service.dismissReportByEmail({ email: input.email }); - 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", - }); - } + return { + success: result.count, + failed: 0, + message: `Dismissed ${result.count} report(s) successfully`, + }; }; diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts index e4e1b32d6a..5eaad95f76 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/dismissReport.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ZDismissReportInputSchema = z.object({ - reportId: z.string().uuid(), + email: z.string().email(), }); export type TDismissReportInputSchema = 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 index 289751cba1..9e41a26d2d 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.handler.ts @@ -19,5 +19,6 @@ export const listReportsHandler = async ({ input }: ListReportsOptions) => { searchTerm: input.searchTerm, filters: input.filters, systemFilters: input.systemFilters, + sortBy: input.sortBy, }); }; diff --git a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts index bf27566955..859810b8fa 100644 --- a/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts +++ b/packages/trpc/server/routers/viewer/admin/watchlist/listReports.schema.ts @@ -6,6 +6,7 @@ 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(), + sortBy: z.enum(["createdAt", "reportCount"]).optional().default("createdAt"), filters: z .object({ status: z.array(z.nativeEnum(BookingReportStatus)).optional(), 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 f9f0eaee42..a4659e2220 100644 --- a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts @@ -16,7 +16,7 @@ describe("addToWatchlistHandler (Organization)", () => { }; const mockService = { - addReportsToWatchlist: vi.fn(), + addToWatchlistByEmail: vi.fn(), }; beforeEach(async () => { @@ -33,7 +33,7 @@ describe("addToWatchlistHandler (Organization)", () => { addToWatchlistHandler({ ctx: { user: { ...mockUser, organizationId: undefined, profile: null } }, input: { - reportIds: ["report-1"], + email: "spammer@example.com", type: WatchlistType.EMAIL, }, }) @@ -42,77 +42,77 @@ describe("addToWatchlistHandler (Organization)", () => { message: "You must be part of an organization to add to watchlist", }); - expect(mockService.addReportsToWatchlist).not.toHaveBeenCalled(); + expect(mockService.addToWatchlistByEmail).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") + it("should propagate NOT_FOUND error from service", async () => { + mockService.addToWatchlistByEmail.mockRejectedValue( + WatchlistErrors.notFound("No pending reports found for this email") ); await expect( addToWatchlistHandler({ ctx: { user: mockUser }, input: { - reportIds: ["report-1"], + email: "spammer@example.com", type: WatchlistType.EMAIL, }, }) ).rejects.toMatchObject({ code: "NOT_FOUND", - message: "Booking report(s) not found: report-1", + message: "No pending reports found for this email", }); }); - it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => { - mockService.addReportsToWatchlist.mockRejectedValue( - WatchlistErrors.permissionDenied("You are not authorized to add entries to the watchlist") + it("should propagate PERMISSION_DENIED error from service", async () => { + mockService.addToWatchlistByEmail.mockRejectedValue( + WatchlistErrors.permissionDenied("You are not authorized to create watchlist entries") ); await expect( addToWatchlistHandler({ ctx: { user: mockUser }, input: { - reportIds: ["report-1"], + email: "spammer@example.com", type: WatchlistType.EMAIL, }, }) ).rejects.toMatchObject({ - code: "UNAUTHORIZED", - message: "You are not authorized to add entries to the watchlist", + code: "PERMISSION_DENIED", + message: "You are not authorized to create watchlist entries", }); }); - it("should map ALREADY_IN_WATCHLIST error to BAD_REQUEST", async () => { - mockService.addReportsToWatchlist.mockRejectedValue( - WatchlistErrors.alreadyInWatchlist("All selected bookers are already in the watchlist") + it("should propagate ALREADY_IN_WATCHLIST error from service", async () => { + mockService.addToWatchlistByEmail.mockRejectedValue( + WatchlistErrors.alreadyInWatchlist("Email is already in the blocklist") ); await expect( addToWatchlistHandler({ ctx: { user: mockUser }, input: { - reportIds: ["report-1"], + email: "spammer@example.com", type: WatchlistType.EMAIL, }, }) ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: "All selected bookers are already in the watchlist", + code: "ALREADY_IN_WATCHLIST", + message: "Email is already in the blocklist", }); }); it("should re-throw unknown service errors", async () => { const unknownError = new Error("Database connection failed"); - mockService.addReportsToWatchlist.mockRejectedValue(unknownError); + mockService.addToWatchlistByEmail.mockRejectedValue(unknownError); await expect( addToWatchlistHandler({ ctx: { user: mockUser }, input: { - reportIds: ["report-1"], + email: "spammer@example.com", type: WatchlistType.EMAIL, }, }) @@ -121,30 +121,63 @@ describe("addToWatchlistHandler (Organization)", () => { }); describe("successful delegation", () => { - it("should delegate to service with correct parameters", async () => { - mockService.addReportsToWatchlist.mockResolvedValue({ + it("should delegate to service with correct parameters for email blocking", async () => { + mockService.addToWatchlistByEmail.mockResolvedValue({ success: true, addedCount: 2, - message: "Successfully added 2 entries to watchlist", + skippedCount: 0, + message: "Successfully added email to blocklist", + results: [ + { reportId: "report-1", watchlistId: "watchlist-1" }, + { reportId: "report-2", watchlistId: "watchlist-1" }, + ], }); const result = await addToWatchlistHandler({ ctx: { user: mockUser }, input: { - reportIds: ["report-1", "report-2"], + email: "spammer@example.com", type: WatchlistType.EMAIL, - description: "Spam users", + description: "Spam user", }, }); - expect(mockService.addReportsToWatchlist).toHaveBeenCalledWith({ - reportIds: ["report-1", "report-2"], + expect(mockService.addToWatchlistByEmail).toHaveBeenCalledWith({ + email: "spammer@example.com", type: WatchlistType.EMAIL, - description: "Spam users", + description: "Spam user", userId: 1, }); expect(result.success).toBe(true); expect(result.addedCount).toBe(2); }); + + it("should delegate to service with correct parameters for domain blocking", async () => { + mockService.addToWatchlistByEmail.mockResolvedValue({ + success: true, + addedCount: 5, + skippedCount: 0, + message: "Successfully added domain to blocklist", + results: [], + }); + + const result = await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + email: "spammer@spamdomain.com", + type: WatchlistType.DOMAIN, + description: "Spam domain", + }, + }); + + expect(mockService.addToWatchlistByEmail).toHaveBeenCalledWith({ + email: "spammer@spamdomain.com", + type: WatchlistType.DOMAIN, + description: "Spam domain", + userId: 1, + }); + expect(result.success).toBe(true); + expect(result.addedCount).toBe(5); + }); }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts index 734a770dd3..14a642ef67 100644 --- a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts @@ -1,5 +1,4 @@ import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist"; -import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors"; import { TRPCError } from "@trpc/server"; @@ -25,44 +24,10 @@ export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOption } const service = getOrganizationWatchlistOperationsService(organizationId); - - 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.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; - } + return service.addToWatchlistByEmail({ + email: input.email, + type: input.type, + description: input.description, + userId: user.id, + }); }; diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts index b081ef675b..5024f69dd1 100644 --- a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { WatchlistType } from "@calcom/prisma/enums"; export const ZAddToWatchlistInputSchema = z.object({ - reportIds: z.array(z.string().uuid()).min(1), + email: z.string().email(), type: z.nativeEnum(WatchlistType), description: z.string().optional(), }); diff --git a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts index 59f1ab8ac6..dee1d527cf 100644 --- a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts @@ -1,13 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; -import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { MembershipRole, BookingReportReason } from "@calcom/prisma/enums"; +import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors"; import { dismissBookingReportHandler } from "./dismissBookingReport.handler"; -vi.mock("@calcom/features/pbac/services/permission-check.service"); -vi.mock("@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"); +vi.mock("@calcom/features/di/watchlist/containers/watchlist"); describe("dismissBookingReportHandler", () => { const mockUser = { @@ -17,44 +14,16 @@ describe("dismissBookingReportHandler", () => { profile: null, }; - const mockReport = { - 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, + const mockService = { + dismissReportByEmail: vi.fn(), }; - const mockPermissionCheckService = { - checkPermission: vi.fn(), - }; - - const mockReportRepo = { - findReportsByIds: vi.fn(), - updateReportStatus: vi.fn(), - }; - - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); - vi.mocked(PermissionCheckService).mockImplementation(function () { - return mockPermissionCheckService as InstanceType; - }); - vi.mocked(PrismaBookingReportRepository).mockImplementation(function () { - return mockReportRepo as InstanceType; - }); + const { getOrganizationWatchlistOperationsService } = await import( + "@calcom/features/di/watchlist/containers/watchlist" + ); + vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never); }); describe("access control", () => { @@ -64,7 +33,7 @@ describe("dismissBookingReportHandler", () => { await expect( dismissBookingReportHandler({ ctx: { user: userWithoutOrg }, - input: { reportId: "report-1" }, + input: { email: "spammer@example.com" }, }) ).rejects.toThrow( expect.objectContaining({ @@ -72,112 +41,81 @@ describe("dismissBookingReportHandler", () => { message: "You must be part of an organization to dismiss booking reports", }) ); + + expect(mockService.dismissReportByEmail).not.toHaveBeenCalled(); }); it("should throw UNAUTHORIZED when user lacks permission", async () => { - mockPermissionCheckService.checkPermission.mockResolvedValue(false); + mockService.dismissReportByEmail.mockRejectedValue( + WatchlistErrors.permissionDenied("You are not authorized to update watchlist entries") + ); await expect( dismissBookingReportHandler({ ctx: { user: mockUser }, - input: { reportId: "report-1" }, + input: { email: "spammer@example.com" }, }) - ).rejects.toThrow( - expect.objectContaining({ - code: "UNAUTHORIZED", - message: "You are not authorized to dismiss booking reports", - }) - ); - }); - - it("should check for watchlist.update permission", async () => { - mockPermissionCheckService.checkPermission.mockResolvedValue(true); - mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]); - mockReportRepo.updateReportStatus.mockResolvedValue(undefined); - - await dismissBookingReportHandler({ - ctx: { user: mockUser }, - input: { reportId: "report-1" }, - }); - - expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({ - userId: 1, - teamId: 100, - permission: "watchlist.update", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + ).rejects.toMatchObject({ + code: "PERMISSION_DENIED", + message: "You are not authorized to update watchlist entries", }); }); }); describe("report verification", () => { - beforeEach(() => { - mockPermissionCheckService.checkPermission.mockResolvedValue(true); - }); - - it("should throw NOT_FOUND when report does not exist", async () => { - mockReportRepo.findReportsByIds.mockResolvedValue([]); + it("should throw NOT_FOUND when no pending reports exist for email", async () => { + mockService.dismissReportByEmail.mockRejectedValue( + WatchlistErrors.notFound("No pending reports found for this email") + ); await expect( dismissBookingReportHandler({ ctx: { user: mockUser }, - input: { reportId: "non-existent-report" }, + input: { email: "nonexistent@example.com" }, }) - ).rejects.toThrow( - expect.objectContaining({ - code: "NOT_FOUND", - message: "Booking report not found", - }) - ); - - expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({ - reportIds: ["non-existent-report"], - organizationId: 100, + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "No pending reports found for this email", }); - }); - it("should throw BAD_REQUEST when report already has a watchlist entry", async () => { - const blockedReport = { ...mockReport, watchlistId: "watchlist-123" }; - mockReportRepo.findReportsByIds.mockResolvedValue([blockedReport]); - - await expect( - dismissBookingReportHandler({ - ctx: { user: mockUser }, - input: { reportId: "report-1" }, - }) - ).rejects.toThrow( - expect.objectContaining({ - code: "BAD_REQUEST", - message: "Cannot dismiss a report that has already been added to the blocklist", - }) - ); - - expect(mockReportRepo.updateReportStatus).not.toHaveBeenCalled(); + expect(mockService.dismissReportByEmail).toHaveBeenCalledWith({ + email: "nonexistent@example.com", + userId: 1, + }); }); }); describe("successful dismissal", () => { - beforeEach(() => { - mockPermissionCheckService.checkPermission.mockResolvedValue(true); - mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]); - mockReportRepo.updateReportStatus.mockResolvedValue(undefined); - }); + it("should successfully dismiss all booking reports for an email", async () => { + mockService.dismissReportByEmail.mockResolvedValue({ + success: true, + count: 3, + }); - it("should successfully dismiss a booking report after verifying it belongs to organization", async () => { const result = await dismissBookingReportHandler({ ctx: { user: mockUser }, - input: { reportId: "report-1" }, + input: { email: "spammer@example.com" }, }); - expect(result).toEqual({ success: true }); - expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({ - reportIds: ["report-1"], - organizationId: 100, + expect(result).toEqual({ success: true, dismissed: 3 }); + expect(mockService.dismissReportByEmail).toHaveBeenCalledWith({ + email: "spammer@example.com", + userId: 1, }); - expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({ - reportId: "report-1", - status: "DISMISSED", - organizationId: 100, + }); + + it("should dismiss a single report when only one exists", async () => { + mockService.dismissReportByEmail.mockResolvedValue({ + success: true, + count: 1, }); + + const result = await dismissBookingReportHandler({ + ctx: { user: mockUser }, + input: { email: "single-report@example.com" }, + }); + + expect(result).toEqual({ success: true, dismissed: 1 }); }); }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts index fcdd89de5d..f534c27763 100644 --- a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts @@ -1,7 +1,4 @@ -import { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository"; -import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist"; import { TRPCError } from "@trpc/server"; @@ -26,51 +23,10 @@ export const dismissBookingReportHandler = async ({ ctx, input }: DismissBooking }); } - const permissionCheckService = new PermissionCheckService(); - const hasPermission = await permissionCheckService.checkPermission({ - userId: user.id, - teamId: organizationId, - permission: "watchlist.update", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], - }); + const service = getOrganizationWatchlistOperationsService(organizationId); + const result = await service.dismissReportByEmail({ email: input.email, userId: user.id }); - if (!hasPermission) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to dismiss booking reports", - }); - } - - const bookingReportRepo = new PrismaBookingReportRepository(prisma); - - const reports = await bookingReportRepo.findReportsByIds({ - reportIds: [input.reportId], - organizationId, - }); - - if (reports.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Booking report not found", - }); - } - - const report = reports[0]; - - if (report.watchlistId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Cannot dismiss a report that has already been added to the blocklist", - }); - } - - await bookingReportRepo.updateReportStatus({ - reportId: input.reportId, - status: "DISMISSED", - organizationId, - }); - - return { success: true }; + return { success: true, dismissed: result.count }; }; export default dismissBookingReportHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts index 3fed5e0262..77281fb403 100644 --- a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ZDismissBookingReportInputSchema = z.object({ - reportId: z.string().uuid(), + email: z.string().email(), }); export type TDismissBookingReportInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts index 5f65823598..8a8f326d31 100644 --- a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts @@ -91,7 +91,7 @@ describe("listBookingReportsHandler (Organization)", () => { }; const mockReportRepo = { - findAllReportedBookings: vi.fn(), + findGroupedReportedBookings: vi.fn(), }; beforeEach(() => { @@ -138,12 +138,12 @@ describe("listBookingReportsHandler (Organization)", () => { message: "You are not authorized to view booking reports", }); - expect(mockReportRepo.findAllReportedBookings).not.toHaveBeenCalled(); + expect(mockReportRepo.findGroupedReportedBookings).not.toHaveBeenCalled(); }); it("should check permission with correct parameters", async () => { mockPermissionCheckService.checkPermission.mockResolvedValue(true); - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -168,7 +168,7 @@ describe("listBookingReportsHandler (Organization)", () => { }); it("should return paginated booking reports for organization", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); const result = await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -181,17 +181,18 @@ describe("listBookingReportsHandler (Organization)", () => { expect(result).toEqual(mockReportData); expect(result.rows).toHaveLength(2); expect(result.meta.totalRowCount).toBe(2); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 0, take: 10, searchTerm: undefined, filters: undefined, + sortBy: undefined, }); }); it("should pass search term to repository", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -202,17 +203,18 @@ describe("listBookingReportsHandler (Organization)", () => { }, }); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 0, take: 10, searchTerm: "spam@example.com", filters: undefined, + sortBy: undefined, }); }); it("should pass filters to repository", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); const filters = { reason: [BookingReportReason.SPAM, BookingReportReason.DONT_KNOW_PERSON], @@ -229,12 +231,13 @@ describe("listBookingReportsHandler (Organization)", () => { }, }); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 10, take: 20, searchTerm: undefined, filters, + sortBy: undefined, }); }); @@ -243,7 +246,7 @@ describe("listBookingReportsHandler (Organization)", () => { rows: [mockReportData.rows[0]], meta: { totalRowCount: 1 }, }; - mockReportRepo.findAllReportedBookings.mockResolvedValue(customData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(customData); const result = await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -264,7 +267,7 @@ describe("listBookingReportsHandler (Organization)", () => { }); it("should correctly pass limit and offset to repository", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue({ + mockReportRepo.findGroupedReportedBookings.mockResolvedValue({ rows: [], meta: { totalRowCount: 0 }, }); @@ -277,17 +280,18 @@ describe("listBookingReportsHandler (Organization)", () => { }, }); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 100, take: 50, searchTerm: undefined, filters: undefined, + sortBy: undefined, }); }); it("should handle first page correctly", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -297,17 +301,18 @@ describe("listBookingReportsHandler (Organization)", () => { }, }); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 0, take: 25, searchTerm: undefined, filters: undefined, + sortBy: undefined, }); }); it("should handle pagination with search and filters", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -319,12 +324,13 @@ describe("listBookingReportsHandler (Organization)", () => { }, }); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 30, take: 15, searchTerm: "test@example.com", filters: { hasWatchlist: false }, + sortBy: undefined, }); }); }); @@ -335,7 +341,7 @@ describe("listBookingReportsHandler (Organization)", () => { }); it("should only return reports from user's organization", async () => { - mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + mockReportRepo.findGroupedReportedBookings.mockResolvedValue(mockReportData); await listBookingReportsHandler({ ctx: { user: mockUser }, @@ -345,12 +351,13 @@ describe("listBookingReportsHandler (Organization)", () => { }, }); - expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + expect(mockReportRepo.findGroupedReportedBookings).toHaveBeenCalledWith({ organizationId: 100, skip: 0, take: 10, searchTerm: undefined, filters: undefined, + sortBy: undefined, }); }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts index b2086a8427..fd23726e04 100644 --- a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts @@ -43,12 +43,13 @@ export const listBookingReportsHandler = async ({ ctx, input }: ListBookingRepor const bookingReportRepo = new PrismaBookingReportRepository(prisma); - const result = await bookingReportRepo.findAllReportedBookings({ + const result = await bookingReportRepo.findGroupedReportedBookings({ organizationId, skip: input.offset, take: input.limit, searchTerm: input.searchTerm, filters: input.filters, + sortBy: input.sortBy, }); return result; diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts index 8a787170cb..10b9360264 100644 --- a/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts @@ -6,6 +6,7 @@ export const ZListBookingReportsInputSchema = z.object({ limit: z.number().min(1).max(100).default(25), offset: z.number().min(0).default(0), searchTerm: z.string().optional(), + sortBy: z.enum(["createdAt", "reportCount"]).optional().default("createdAt"), filters: z .object({ reason: z.array(z.nativeEnum(BookingReportReason)).optional(),