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:
co-authored by
unknown
Devin AI
parent
09194c58e1
commit
a40c3eebd6
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
+54
-116
@@ -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>;
|
||||
|
||||
+25
-18
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user