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>
This commit is contained in:
Udit Takkar
2026-01-23 16:31:56 +00:00
committed by GitHub
co-authored by unknown Devin AI
parent 09194c58e1
commit a40c3eebd6
35 changed files with 760 additions and 875 deletions
@@ -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<ViewType>("blocked");
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortByOption>("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) {
]}
/>
<DataTableToolbar.SearchBar />
{activeView === "pending" && (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button type="button" color="secondary" EndIcon="chevron-down">
{sortBy === "reportCount" ? t("most_reports") : t("most_recent")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => setSortBy("createdAt")}
className={sortBy === "createdAt" ? "font-medium" : ""}>
{t("most_recent")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => setSortBy("reportCount")}
className={sortBy === "reportCount" ? "font-medium" : ""}>
{t("most_reports")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
)}
</div>
<div className="flex items-center gap-2">
{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}
@@ -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",
@@ -45,14 +45,14 @@ export function useBlockedEntriesColumns<T extends BlocklistEntry>({
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
aria-label={t("select_all")}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
aria-label={t("select_row")}
/>
),
enableSorting: false,
@@ -179,7 +179,7 @@ export function BlockedEntriesTable<T extends BlocklistEntry>({
}
totalRowCount={totalRowCount}>
{enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && (
<DataTableSelectionBar.Root className="!bottom-16 justify-center md:w-max">
<DataTableSelectionBar.Root className="bottom-16! justify-center md:w-max">
<p className="text-brand-subtle px-2 text-center text-xs leading-none sm:text-sm sm:font-medium">
{t("number_selected", { count: numberOfSelectedRows })}
</p>
@@ -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<T extends BookingReport> {
export interface BookingReportDetailsModalProps<T extends GroupedBookingReport> {
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<T extends BookingReport>({
export function BookingReportDetailsModal<T extends GroupedBookingReport>({
scope,
entry,
isOpen,
@@ -53,12 +53,12 @@ export function BookingReportDetailsModal<T extends BookingReport>({
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<T extends BookingReport>({
<div>
<label className="text-emphasis mb-1 block text-sm font-semibold">
{t("related_booking")}
{t("related_bookings")} ({entry.reports.length})
</label>
<Link href={`/booking/${entry.booking.uid}`}>
<div className="text-subtle flex items-center gap-1 text-sm">
{entry.booking.title}
<Icon name="external-link" className="h-4 w-4" />
</div>
</Link>
<div className="max-h-32 space-y-1 overflow-y-auto">
{entry.reports.map((report) => (
<Link key={report.id} href={`/booking/${report.booking.uid}`}>
<div className="text-subtle hover:text-emphasis flex items-center gap-1 text-sm">
{report.booking.title || t("untitled")}
<Icon name="external-link" className="h-3 w-3" />
</div>
</Link>
))}
</div>
</div>
</div>
</div>
@@ -9,7 +9,7 @@ export interface PendingReportsBadgeProps {
export function PendingReportsBadge({ count }: PendingReportsBadgeProps) {
if (!count) return null;
return (
<Badge rounded variant="orange" className="ml-1">
<Badge variant="orange" className="ml-1 min-h-5 min-w-5 rounded-full px-1.5">
{count}
</Badge>
);
@@ -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<T extends BookingReport> {
interface UsePendingReportsColumnsProps<T extends GroupedBookingReport> {
t: (key: string) => string;
scope: BlocklistScope;
enableRowSelection?: boolean;
onViewDetails: (entry: T) => void;
}
export function usePendingReportsColumns<T extends BookingReport>({
export function usePendingReportsColumns<T extends GroupedBookingReport>({
t,
scope,
enableRowSelection = false,
@@ -66,12 +66,17 @@ export function usePendingReportsColumns<T extends BookingReport>({
enableHiding: false,
size: isSystem ? 200 : 250,
cell: ({ row }) => {
const email = row.original.bookerEmail;
const { bookerEmail, reportCount } = row.original;
return (
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={`text-emphasis ${isSystem ? "break-words text-sm" : ""} font-medium`}>
{email}
{bookerEmail}
</span>
{reportCount > 1 && (
<Badge variant="gray" size="sm">
{reportCount}
</Badge>
)}
</div>
);
},
@@ -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<T extends BookingReport> {
export type SortByOption = "createdAt" | "reportCount";
export interface PendingReportsTableProps<T extends GroupedBookingReport> {
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<T extends BookingReport>({
export function PendingReportsTable<T extends GroupedBookingReport>({
scope,
data,
totalRowCount,
@@ -78,7 +80,7 @@ export function PendingReportsTable<T extends BookingReport>({
state: {
rowSelection,
},
getRowId: (row) => row.id,
getRowId: (row) => row.bookerEmail,
});
const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length;
@@ -103,7 +105,7 @@ export function PendingReportsTable<T extends BookingReport>({
}
totalRowCount={totalRowCount}>
{enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && (
<DataTableSelectionBar.Root className="!bottom-16 justify-center md:w-max">
<DataTableSelectionBar.Root className="bottom-16! justify-center md:w-max">
<p className="text-brand-subtle px-2 text-center text-xs leading-none sm:text-sm sm:font-medium">
{t("number_selected", { count: numberOfSelectedRows })}
</p>
@@ -117,8 +119,8 @@ export function PendingReportsTable<T extends BookingReport>({
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}
/>
+5
View File
@@ -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;
@@ -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<void>;
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<number>;
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<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>>;
findPendingReportsByDomain(params: {
domain: string;
organizationId: number;
}): Promise<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>>;
findPendingSystemReportsByEmail(params: {
email: string;
}): Promise<Array<{ id: string; bookerEmail: string; globalWatchlistId: string | null }>>;
findPendingSystemReportsByDomain(params: {
domain: string;
}): Promise<Array<{ id: string; bookerEmail: string; globalWatchlistId: string | null }>>;
}
@@ -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<string, BookingReportWithDetails[]>();
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<BookingReportWithDetails[]> {
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<void> {
@@ -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<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>> {
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<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>> {
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<Array<{ id: string; bookerEmail: string; globalWatchlistId: string | null }>> {
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<Array<{ id: string; bookerEmail: string; globalWatchlistId: string | null }>> {
return this.prismaClient.bookingReport.findMany({
where: {
bookerEmail: { endsWith: `@${params.domain}`, mode: "insensitive" },
systemStatus: "PENDING",
globalWatchlistId: null,
},
select: { id: true, bookerEmail: true, globalWatchlistId: true },
});
}
}
@@ -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<ViewType>("blocked");
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortByOption>("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() {
]}
/>
<DataTableToolbar.SearchBar />
{activeView === "pending" && (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button type="button" color="secondary" EndIcon="chevron-down">
{sortBy === "reportCount" ? t("most_reports") : t("most_recent")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => setSortBy("createdAt")}
className={sortBy === "createdAt" ? "font-medium" : ""}>
{t("most_recent")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => setSortBy("reportCount")}
className={sortBy === "reportCount" ? "font-medium" : ""}>
{t("most_reports")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
)}
</div>
<div className="flex items-center gap-2">
{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}
@@ -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),
});
}}
>
@@ -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 || [],
@@ -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<AddReportsToWatchlistResult> {
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<string, string>();
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<BulkDeleteWatchlistEntriesResult> {
@@ -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<DismissReportResult> {
const reports = await this.deps.bookingReportRepo.findReportsByIds({
reportIds: [input.reportId],
});
async addToWatchlistByEmail(input: AddToWatchlistByEmailInput): Promise<AddReportsToWatchlistResult> {
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<BulkDismissReportsResult> {
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<DismissReportByEmailResult> {
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<BulkDismissReportsResult> {
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`,
};
}
}
@@ -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;
@@ -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<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>> {
const reports = await this.deps.bookingReportRepo.findReportsByIds({
reportIds,
organizationId: this.organizationId,
});
return reports;
}
private async checkPermission(userId: number, permission: PermissionString): Promise<void> {
const hasPermission = await this.permissionCheckService.checkPermission({
userId,
@@ -66,11 +55,6 @@ export class OrganizationWatchlistOperationsService extends WatchlistOperationsS
}
}
async addReportsToWatchlist(input: AddReportsToWatchlistInput): Promise<AddReportsToWatchlistResult> {
await this.checkPermission(input.userId, "watchlist.create");
return super.addReportsToWatchlist(input);
}
async createWatchlistEntry(input: CreateWatchlistEntryInput): Promise<CreateWatchlistEntryResult> {
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<AddReportsToWatchlistResult> {
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 };
}
}
@@ -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<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>>;
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<AddReportsToWatchlistResult> {
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<string, string>();
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<CreateWatchlistEntryResult> {
const scope = this.getScope();
@@ -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,
});
};
@@ -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(),
});
@@ -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 });
};
@@ -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<typeof ZBulkDismissReportsInputSchema>;
@@ -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`,
};
};
@@ -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<typeof ZDismissReportInputSchema>;
@@ -19,5 +19,6 @@ export const listReportsHandler = async ({ input }: ListReportsOptions) => {
searchTerm: input.searchTerm,
filters: input.filters,
systemFilters: input.systemFilters,
sortBy: input.sortBy,
});
};
@@ -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(),
@@ -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);
});
});
});
@@ -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,
});
};
@@ -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(),
});
@@ -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<typeof PermissionCheckService>;
});
vi.mocked(PrismaBookingReportRepository).mockImplementation(function () {
return mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>;
});
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 });
});
});
});
@@ -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;
@@ -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<typeof ZDismissBookingReportInputSchema>;
@@ -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,
});
});
});
@@ -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;
@@ -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(),