feat: system admin blocklist table (#25039)

* feat: system admin blocklist table

* chore: text

* fix: type error

* fix: type error

* fix: type error

* fix: frontend bug

* chore: improvments

* refactor: use shared components

* refactor: improvements

* fix: delete

* refactor: support multiple watchlist

* chore: update translation

* fix: type err

* fix: bulk bug

* tests: add unit test

* refactor: file

* refactor: file

* fix: type error

* fix: type error

* chore: translation

* refactor: use shared comments

* fix: imports

* fix: type error
This commit is contained in:
Udit Takkar
2026-01-09 08:19:54 -03:00
committed by GitHub
parent d865f6cb5f
commit 0330d14210
34 changed files with 1970 additions and 633 deletions
@@ -0,0 +1,24 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import SystemBlocklistView from "@calcom/features/ee/admin/pages/settings/blocklist";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("system_blocklist"),
(t) => t("system_blocklist_description"),
undefined,
undefined,
"/settings/admin/blocklist"
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("system_blocklist")} description={t("system_blocklist_description")}>
<SystemBlocklistView />
</SettingsHeader>
);
};
export default Page;
@@ -240,6 +240,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/admin/lockedSMS",
trackingMetadata: { section: "admin", page: "locked_sms" },
},
{
name: "pbac_resource_blocklist",
href: "/settings/admin/blocklist",
trackingMetadata: { section: "admin", page: "blocklist" },
},
{
name: "oAuth",
href: "/settings/admin/oAuth",
@@ -1,16 +1,21 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { useState } from "react";
import {
BlockedEntriesTable,
CreateBlocklistEntryModal,
PendingReportsBadge,
PendingReportsTable,
} 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 { ToggleGroup } from "@calcom/ui/components/form";
import PendingReportsBadge from "./components/PendingReportsBadge";
import { BlockedEntriesTable } from "./components/blocked-entries-table";
import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal";
import { PendingReportsTable } from "./components/pending-reports-table";
import { showToast } from "@calcom/ui/components/toast";
type ViewType = "blocked" | "pending";
@@ -24,8 +29,86 @@ interface BlocklistTableProps {
export function BlocklistTable({ permissions }: BlocklistTableProps) {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [activeView, setActiveView] = useState<ViewType>("blocked");
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: blockedData, isPending: isBlockedPending } =
trpc.viewer.organizations.listWatchlistEntries.useQuery(
{ limit, offset, searchTerm },
{ placeholderData: keepPreviousData, enabled: activeView === "blocked" }
);
const { data: reportsData, isPending: isReportsPending } =
trpc.viewer.organizations.listBookingReports.useQuery(
{
limit,
offset,
searchTerm,
filters: { hasWatchlist: false, status: ["PENDING"] },
},
{ placeholderData: keepPreviousData, enabled: activeView === "pending" }
);
const { data: pendingReportsCount } =
trpc.viewer.organizations.pendingReportsCount.useQuery();
const { data: entryDetails, isLoading: isDetailsLoading } =
trpc.viewer.organizations.getWatchlistEntryDetails.useQuery(
{ id: selectedEntryId ?? "" },
{ enabled: !!selectedEntryId }
);
const createEntry =
trpc.viewer.organizations.createWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_created"), "success");
setShowCreateModal(false);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const deleteEntry =
trpc.viewer.organizations.deleteWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
setSelectedEntryId(null);
showToast(t("blocklist_entry_deleted"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const addToWatchlist = trpc.viewer.organizations.addToWatchlist.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listBookingReports.invalidate();
await utils.viewer.organizations.listWatchlistEntries.invalidate();
await utils.viewer.organizations.pendingReportsCount.invalidate();
showToast(t("blocklist_entry_created"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const dismissReport =
trpc.viewer.organizations.dismissBookingReport.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listBookingReports.invalidate();
await utils.viewer.organizations.pendingReportsCount.invalidate();
showToast(t("booking_report_dismissed"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<>
@@ -43,7 +126,7 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) {
label: (
<span className="flex items-center">
{t("pending")}
<PendingReportsBadge />
<PendingReportsBadge count={pendingReportsCount} />
</span>
),
},
@@ -53,7 +136,11 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) {
</div>
<div className="flex items-center gap-2">
{permissions?.canCreate && (
<Button color="primary" StartIcon="plus" onClick={() => setShowCreateModal(true)}>
<Button
color="primary"
StartIcon="plus"
onClick={() => setShowCreateModal(true)}
>
{t("add")}
</Button>
)}
@@ -61,12 +148,46 @@ export function BlocklistTable({ permissions }: BlocklistTableProps) {
</div>
{activeView === "blocked" ? (
<BlockedEntriesTable permissions={permissions} onAddClick={() => setShowCreateModal(true)} />
<BlockedEntriesTable
scope="organization"
data={blockedData?.rows ?? []}
totalRowCount={blockedData?.meta?.totalRowCount ?? 0}
isPending={isBlockedPending}
limit={limit}
searchTerm={searchTerm}
permissions={permissions}
onAddClick={() => setShowCreateModal(true)}
onDelete={(entry) => deleteEntry.mutate({ id: entry.id })}
isDeleting={deleteEntry.isPending}
detailsQuery={{ data: entryDetails, isLoading: isDetailsLoading }}
selectedEntryId={selectedEntryId ?? undefined}
onSelectEntry={setSelectedEntryId}
/>
) : (
<PendingReportsTable />
<PendingReportsTable
scope="organization"
data={reportsData?.rows ?? []}
totalRowCount={reportsData?.meta?.totalRowCount ?? 0}
isPending={isReportsPending}
limit={limit}
onAddToBlocklist={(reportIds, type, onSuccess) =>
addToWatchlist.mutate({ reportIds, type }, { onSuccess })
}
onDismiss={(reportId, onSuccess) =>
dismissReport.mutate({ reportId }, { onSuccess })
}
isAddingToBlocklist={addToWatchlist.isPending}
isDismissing={dismissReport.isPending}
/>
)}
<CreateBlocklistEntryModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
<CreateBlocklistEntryModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
scope="organization"
onCreateEntry={(data) => createEntry.mutate(data)}
isPending={createEntry.isPending}
/>
</>
);
}
@@ -1,12 +0,0 @@
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
export default function PendingReportsBadge() {
const { data: pendingReportsCount } = trpc.viewer.organizations.pendingReportsCount.useQuery();
if (!pendingReportsCount) return null;
return (
<Badge rounded variant="orange" className="ml-1">
{pendingReportsCount}
</Badge>
);
}
@@ -1,108 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface UseBlockedEntriesColumnsProps {
t: (key: string) => string;
canDelete?: boolean;
onViewDetails: (entry: BlocklistEntry) => void;
onDelete: (entry: BlocklistEntry) => void;
}
export function useBlockedEntriesColumns({
t,
canDelete,
onViewDetails,
onDelete,
}: UseBlockedEntriesColumnsProps) {
return useMemo<ColumnDef<BlocklistEntry>[]>(
() => [
{
id: "email_slash_domain",
header: t("email_slash_domain"),
accessorKey: "value",
enableHiding: false,
cell: ({ row }) => <span className="text-emphasis">{row.original.value}</span>,
},
{
id: "type",
header: t("type"),
accessorKey: "type",
size: 100,
cell: ({ row }) => (
<Badge variant="blue">{row.original.type === "EMAIL" ? t("email") : t("domain")}</Badge>
),
},
{
id: "createdBy",
header: t("blocked_by"),
size: 180,
cell: ({ row }) => {
const audit = row.original.latestAudit as
| { changedByUserId: number | null }
| {
changedByUser?: { id: number; email: string; name: string | null } | undefined;
changedByUserId: number | null;
}
| null;
const email =
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
return <span className="text-default">{email ?? "—"}</span>;
},
},
{
id: "actions",
header: "",
size: 90,
minSize: 90,
maxSize: 90,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
return (
<div className="flex items-center justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem type="button" StartIcon="eye" onClick={() => onViewDetails(entry)}>
{t("view_details")}
</DropdownItem>
</DropdownMenuItem>
{canDelete && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash"
onClick={() => onDelete(entry)}>
{t("remove_from_blocklist")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
);
},
},
],
[t, canDelete, onViewDetails, onDelete]
);
}
@@ -1,166 +0,0 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useDataTable } from "@calcom/features/data-table";
import { DataTableWrapper } from "~/data-table/components";
import { IS_CALCOM } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { ConfirmationDialogContent, Dialog, DialogClose } from "@calcom/ui/components/dialog";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { showToast } from "@calcom/ui/components/toast";
import { useBlockedEntriesColumns } from "./blocked-entries-columns";
import { BlocklistEntryDetailsSheet } from "./blocklist-entry-details-sheet";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlockedEntriesTableProps {
permissions?: {
canRead: boolean;
canCreate: boolean;
canDelete: boolean;
};
onAddClick: () => void;
}
export function BlockedEntriesTable({ permissions, onAddClick }: BlockedEntriesTableProps) {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [showDetailsSheet, setShowDetailsSheet] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<BlocklistEntry | null>(null);
const [entryToDelete, setEntryToDelete] = useState<BlocklistEntry | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { data, isPending } = trpc.viewer.organizations.listWatchlistEntries.useQuery(
{
limit,
offset,
searchTerm,
},
{
placeholderData: keepPreviousData,
}
);
const utils = trpc.useUtils();
const deleteWatchlistEntry = trpc.viewer.organizations.deleteWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_deleted"), "success");
setShowDeleteDialog(false);
setEntryToDelete(null);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const handleViewDetails = (entry: BlocklistEntry) => {
setSelectedEntry(entry);
setShowDetailsSheet(true);
};
const handleDelete = (entry: BlocklistEntry) => {
setEntryToDelete(entry);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (entryToDelete) {
deleteWatchlistEntry.mutate({ id: entryToDelete.id });
}
};
const columns = useBlockedEntriesColumns({
t,
canDelete: permissions?.canDelete,
onViewDetails: handleViewDetails,
onDelete: handleDelete,
});
const totalRowCount = data?.meta?.totalRowCount ?? 0;
const flatData = useMemo(() => data?.rows ?? [], [data]);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalRowCount / limit),
});
return (
<>
<DataTableWrapper
table={table}
isPending={isPending}
variant="default"
paginationMode="standard"
EmptyView={
<EmptyScreen
customIcon={<img className="mb-6" src="/slash-icon-cards.svg" />}
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("pbac_resource_blocklist")}
description={t("add_people_to_blocklist")}
className="bg-muted mb-16"
iconWrapperClassName="bg-default"
dashedBorder={false}
buttonRaw={
<div className="flex gap-2">
<Button
StartIcon="plus"
onClick={onAddClick}
color="primary"
disabled={!permissions?.canCreate}>
{t("add")}
</Button>
{IS_CALCOM && (
<Button
StartIcon="book"
color="secondary"
onClick={() =>
window.open("https://cal.com/help/security/blocklist", "_blank", "noopener,noreferrer")
}>
{t("docs")}
</Button>
)}
</div>
}
/>
}
totalRowCount={totalRowCount}
/>
<BlocklistEntryDetailsSheet
entry={selectedEntry}
isOpen={showDetailsSheet}
onClose={() => {
setShowDetailsSheet(false);
setSelectedEntry(null);
}}
handleDeleteBlocklistEntry={handleDelete}
/>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_value_from_blocklist", { value: entryToDelete?.value })}
confirmBtn={
<DialogClose color="destructive" onClick={confirmDelete}>
{t("remove")}
</DialogClose>
}>
{t("remove_value_from_blocklist_description")}
</ConfirmationDialogContent>
</Dialog>
</>
);
}
@@ -1,90 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
type BookingReport = RouterOutputs["viewer"]["organizations"]["listBookingReports"]["rows"][number];
interface UsePendingReportsColumnsProps {
t: (key: string) => string;
onViewDetails: (entry: BookingReport) => void;
}
export function usePendingReportsColumns({ t, onViewDetails }: UsePendingReportsColumnsProps) {
return useMemo<ColumnDef<BookingReport>[]>(
() => [
{
id: "emailOrDomain",
header: t("email_slash_domain"),
accessorKey: "bookerEmail",
enableHiding: false,
size: 250,
cell: ({ row }) => {
const email = row.original.bookerEmail;
return (
<div className="flex flex-col">
<span className="text-emphasis font-medium">{email}</span>
</div>
);
},
},
{
id: "reportedBy",
header: t("reported_by"),
accessorFn: (row) => row.reporter?.email ?? "-",
size: 250,
cell: ({ row }) => <span className="text-default">{row.original.reporter?.email ?? "-"}</span>,
},
{
id: "reason",
header: t("reason"),
size: 150,
cell: ({ row }) => {
const reason = t(row.original.reason.toLowerCase());
const capitalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1);
return <Badge variant="blue">{capitalizedReason}</Badge>;
},
},
{
id: "actions",
header: "",
size: 90,
minSize: 90,
maxSize: 90,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
return (
<div className="flex items-center justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem type="button" StartIcon="eye" onClick={() => onViewDetails(entry)}>
{t("view_details")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
);
},
},
],
[t, onViewDetails]
);
}
@@ -1,89 +0,0 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useDataTable } from "@calcom/features/data-table";
import { DataTableWrapper } from "~/data-table/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { BookingReportEntryDetailsModal } from "./booking-report-entry-details-modal";
import { usePendingReportsColumns } from "./pending-reports-columns";
type BookingReport = RouterOutputs["viewer"]["organizations"]["listBookingReports"]["rows"][number];
export function PendingReportsTable() {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [showReviewDialog, setShowReviewDialog] = useState(false);
const [selectedReport, setSelectedReport] = useState<BookingReport | null>(null);
const { data, isPending } = trpc.viewer.organizations.listBookingReports.useQuery(
{
limit,
offset,
searchTerm,
filters: { hasWatchlist: false, status: ["PENDING"] },
},
{
placeholderData: keepPreviousData,
}
);
const handleViewDetails = (entry: BookingReport) => {
setSelectedReport(entry);
setShowReviewDialog(true);
};
const columns = usePendingReportsColumns({
t,
onViewDetails: handleViewDetails,
});
const totalRowCount = data?.meta?.totalRowCount ?? 0;
const flatData = useMemo(() => data?.rows ?? [], [data]);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalRowCount / limit),
});
return (
<>
<DataTableWrapper
table={table}
isPending={isPending}
variant="default"
paginationMode="standard"
EmptyView={
<EmptyScreen
customIcon={<img className="mb-6" src="/slash-icon-cards.svg" />}
headline={t("no_pending_reports")}
className="bg-muted mb-16"
iconWrapperClassName="bg-default"
dashedBorder={false}
/>
}
totalRowCount={totalRowCount}
/>
<BookingReportEntryDetailsModal
entry={selectedReport}
isOpen={showReviewDialog}
onClose={() => {
setShowReviewDialog(false);
setSelectedReport(null);
}}
/>
</>
);
}
@@ -1029,6 +1029,7 @@
"teams": "Teams",
"team": "Team",
"organization": "Organization",
"org": "Org",
"team_billing": "Team Billing",
"team_billing_description": "Manage billing for your team",
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
@@ -4010,6 +4011,36 @@
"block_all_from_domain": "All from this domain",
"review_report": "Review report",
"block": "Block",
"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",
"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.",
"system_wide": "System-wide",
"system_blocked": "System blocked",
"system_wide_all_organizations": "System-wide",
"system_wide_blocklist_warning": "System-wide blocklist entry",
"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",
"automatic": "Automatic",
"scope": "Scope",
"bulk_delete": "Delete",
"bulk_dont_block": "Don't Block",
"entries_deleted_successfully": "{{count}} entries deleted successfully",
"entries_deleted_with_errors": "{{success}} entries deleted, {{failed}} failed",
"reports_dismissed_successfully": "{{count}} reports dismissed successfully",
"reports_dismissed_with_errors": "{{success}} reports dismissed, {{failed}} failed",
"remove_entries_from_system_blocklist": "Remove entries from system blocklist",
"remove_entries_confirm": "Are you sure you want to remove {{count}} entries from the system blocklist? This will unblock them platform-wide for all users and teams. This action cannot be undone.",
"dismiss_reports_title": "Dismiss reports",
"dismiss_reports_confirm": "Are you sure you want to dismiss {{count}} reports? They will no longer appear in the pending reports list.",
"onboarding_org_details_title": "Add your Organization's details",
"onboarding_org_details_subtitle": "Customize your booking pages with your Organization's name and bio",
"onboarding_org_bio_label": "Organization bio",
@@ -0,0 +1,167 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { Checkbox } from "@calcom/ui/components/form";
import type { BlocklistEntry, BlocklistScope } from "../types";
interface UseBlockedEntriesColumnsProps<T extends BlocklistEntry> {
t: (key: string) => string;
scope: BlocklistScope;
canDelete?: boolean;
enableRowSelection?: boolean;
onViewDetails: (entry: T) => void;
onDelete: (entry: T) => void;
}
export function useBlockedEntriesColumns<T extends BlocklistEntry>({
t,
scope,
canDelete = true,
enableRowSelection = false,
onViewDetails,
onDelete,
}: UseBlockedEntriesColumnsProps<T>) {
const isSystem = scope === "system";
return useMemo<ColumnDef<T>[]>(() => {
const columns: ColumnDef<T>[] = [];
if (enableRowSelection) {
columns.push({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 24,
minSize: 24,
maxSize: 24,
});
}
columns.push(
{
id: "email_slash_domain",
header: t("email_slash_domain"),
accessorKey: "value",
enableHiding: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="text-emphasis">{row.original.value}</span>
{row.original.isGlobal && !isSystem && (
<Badge variant="gray" className="text-xs">
{t("system_blocked")}
</Badge>
)}
</div>
),
},
{
id: "type",
header: t("type"),
accessorKey: "type",
size: 100,
cell: ({ row }) => (
<Badge variant="blue">{row.original.type === "EMAIL" ? t("email") : t("domain")}</Badge>
),
}
);
if (isSystem) {
columns.push({
id: "source",
header: t("source"),
accessorKey: "source",
size: 120,
cell: ({ row }) => {
const source = row.original.source;
let label = t("manual");
if (source === "FREE_DOMAIN_POLICY") {
label = t("free_domain_policy");
}
return <span className="text-default">{label}</span>;
},
});
}
columns.push({
id: "createdBy",
header: t("blocked_by"),
size: 180,
cell: ({ row }) => {
const audit = row.original.latestAudit;
const email = audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined;
return <span className="text-default">{email ?? "—"}</span>;
},
});
columns.push({
id: "actions",
header: "",
size: 90,
minSize: 90,
maxSize: 90,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
const isEntryReadOnly = entry.isReadOnly === true;
const showDeleteOption = canDelete && !isEntryReadOnly;
return (
<div className="flex items-center justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem type="button" StartIcon="eye" onClick={() => onViewDetails(entry)}>
{t("view_details")}
</DropdownItem>
</DropdownMenuItem>
{showDeleteOption && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash"
onClick={() => onDelete(entry)}>
{t("remove_from_blocklist")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
);
},
});
return columns;
}, [t, scope, isSystem, canDelete, enableRowSelection, onViewDetails, onDelete]);
}
@@ -0,0 +1,217 @@
"use client";
import type { RowSelectionState } from "@tanstack/react-table";
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { DataTableSelectionBar, DataTableWrapper } from "~/data-table/components";
import { IS_CALCOM } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import type { BlocklistEntry, BlocklistPermissions, BlocklistScope } from "../types";
import { useBlockedEntriesColumns } from "./BlockedEntriesColumns";
import { BlocklistEntryDetailsSheet } from "./BlocklistEntryDetailsSheet";
export interface BlockedEntriesTableProps<T extends BlocklistEntry> {
scope: BlocklistScope;
data: T[];
totalRowCount: number;
isPending: boolean;
limit: number;
searchTerm?: string;
permissions?: BlocklistPermissions;
onAddClick: () => void;
onDelete: (entry: T) => void;
isDeleting?: boolean;
detailsQuery?: {
data?: {
entry: {
id: string;
value: string;
type: import("@calcom/prisma/enums").WatchlistType;
description: string | null;
source?: string;
bookingReports?: Array<{ booking: { uid: string; title: string | null } }>;
};
auditHistory: Array<{
id: string;
value: string;
changedAt: Date | string;
changedByUser?: { name: string | null; email: string } | null;
}>;
};
isLoading: boolean;
};
selectedEntryId?: string;
onSelectEntry?: (id: string | null) => void;
enableRowSelection?: boolean;
renderBulkActions?: (selectedEntries: T[], clearSelection: () => void) => ReactNode;
}
export function BlockedEntriesTable<T extends BlocklistEntry>({
scope,
data,
totalRowCount,
isPending,
limit,
searchTerm,
permissions,
onAddClick,
onDelete,
isDeleting = false,
detailsQuery,
selectedEntryId,
onSelectEntry,
enableRowSelection = false,
renderBulkActions,
}: BlockedEntriesTableProps<T>) {
const { t } = useLocale();
const isSystem = scope === "system";
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [showDetailsSheet, setShowDetailsSheet] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<T | null>(null);
const [entryToDelete, setEntryToDelete] = useState<T | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleViewDetails = (entry: T) => {
setSelectedEntry(entry);
setShowDetailsSheet(true);
onSelectEntry?.(entry.id);
};
const handleDelete = (entry: T) => {
setEntryToDelete(entry);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (entryToDelete) {
onDelete(entryToDelete);
setShowDeleteDialog(false);
setEntryToDelete(null);
}
};
const handleCloseDetailsSheet = () => {
setShowDetailsSheet(false);
setSelectedEntry(null);
onSelectEntry?.(null);
};
const canDelete = isSystem || permissions?.canDelete;
const columns = useBlockedEntriesColumns<T>({
t,
scope,
canDelete,
enableRowSelection,
onViewDetails: handleViewDetails,
onDelete: handleDelete,
});
const flatData = useMemo(() => data ?? [], [data]);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalRowCount / limit),
enableRowSelection,
onRowSelectionChange: setRowSelection,
state: {
rowSelection,
},
getRowId: (row) => row.id,
});
const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length;
const selectedEntries = table.getSelectedRowModel().flatRows.map((row) => row.original);
const clearSelection = () => table.toggleAllPageRowsSelected(false);
return (
<>
<DataTableWrapper
table={table}
isPending={isPending}
variant="default"
paginationMode="standard"
EmptyView={
<EmptyScreen
customIcon={<img className="mb-6" src="/slash-icon-cards.svg" />}
headline={
searchTerm
? t("no_result_found_for", { searchTerm })
: t(isSystem ? "system_blocklist" : "pbac_resource_blocklist")
}
description={t(isSystem ? "add_people_to_system_blocklist" : "add_people_to_blocklist")}
className="bg-muted mb-16"
iconWrapperClassName="bg-default"
dashedBorder={false}
buttonRaw={
<div className="flex gap-2">
<Button
StartIcon="plus"
onClick={onAddClick}
color="primary"
disabled={!isSystem && !permissions?.canCreate}>
{t("add")}
</Button>
{IS_CALCOM && (
<Button
StartIcon="book"
color="secondary"
onClick={() =>
window.open("https://cal.com/help/security/blocklist", "_blank", "noopener,noreferrer")
}>
{t("docs")}
</Button>
)}
</div>
}
/>
}
totalRowCount={totalRowCount}>
{enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && (
<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>
{renderBulkActions(selectedEntries, clearSelection)}
</DataTableSelectionBar.Root>
)}
</DataTableWrapper>
<BlocklistEntryDetailsSheet
scope={scope}
entry={selectedEntry}
isOpen={showDetailsSheet}
onClose={handleCloseDetailsSheet}
handleDeleteBlocklistEntry={handleDelete}
detailsData={detailsQuery?.data}
isLoading={detailsQuery?.isLoading ?? false}
/>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ConfirmationDialogContent
variety="danger"
title={t(isSystem ? "remove_value_from_system_blocklist" : "remove_value_from_blocklist", {
value: entryToDelete?.value,
})}
confirmBtnText={t("remove")}
isPending={isDeleting}
onConfirm={confirmDelete}>
{t(
isSystem ? "remove_value_from_system_blocklist_description" : "remove_value_from_blocklist_description"
)}
</ConfirmationDialogContent>
</Dialog>
</>
);
}
@@ -4,8 +4,7 @@ import { format } from "date-fns";
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import {
@@ -18,29 +17,29 @@ import {
} from "@calcom/ui/components/sheet";
import { SkeletonText } from "@calcom/ui/components/skeleton";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
import type { BlocklistEntry, BlocklistEntryDetails, BlocklistScope } from "../types";
interface BlocklistEntryDetailsSheetProps {
entry: BlocklistEntry | null;
export interface BlocklistEntryDetailsSheetProps<T extends BlocklistEntry> {
scope: BlocklistScope;
entry: T | null;
isOpen: boolean;
onClose: () => void;
handleDeleteBlocklistEntry: (entry: BlocklistEntry) => void;
handleDeleteBlocklistEntry: (entry: T) => void;
detailsData?: BlocklistEntryDetails;
isLoading: boolean;
}
export function BlocklistEntryDetailsSheet({
export function BlocklistEntryDetailsSheet<T extends BlocklistEntry>({
scope,
entry,
isOpen,
onClose,
handleDeleteBlocklistEntry,
}: BlocklistEntryDetailsSheetProps) {
detailsData,
isLoading,
}: BlocklistEntryDetailsSheetProps<T>) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery(
{ id: entry?.id ?? "" },
{
enabled: !!entry?.id && isOpen,
}
);
const isSystem = scope === "system";
return (
<Sheet
@@ -50,54 +49,85 @@ export function BlocklistEntryDetailsSheet({
}}>
<SheetContent className="px-0 pb-0 sm:max-w-xl sm:px-0 sm:pb-0">
<SheetHeader className="px-6">
<SheetTitle>{entry?.value}</SheetTitle>
{isSystem ? (
<div className="flex items-center gap-2">
<SheetTitle>{entry?.value}</SheetTitle>
<Badge variant="gray">{t("system_wide")}</Badge>
</div>
) : (
<SheetTitle>{entry?.value}</SheetTitle>
)}
</SheetHeader>
<SheetBody className="px-6">
{isLoading ? (
<div className="mt-3 stack-y-3">
<div className="mt-3 space-y-3">
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-40 w-full" />
</div>
) : data?.entry ? (
<div className="mt-3 stack-y-6">
) : detailsData?.entry ? (
<div className="mt-3 space-y-6">
<div className="bg-subtle rounded-xl p-1">
<h2 className="text-default p-5 text-sm font-semibold">{t("details")}</h2>
<div className="bg-default stack-y-3 rounded-xl p-5">
<div className="bg-default space-y-3 rounded-xl p-5">
<div>
<label className="text-default block text-sm font-semibold">
{data.entry.type === "EMAIL" ? t("email") : t("domain")}
{detailsData.entry.type === "EMAIL" ? t("email") : t("domain")}
</label>
<p className="text-subtle text-sm">{data.entry.value}</p>
<p className="text-subtle text-sm">{detailsData.entry.value}</p>
</div>
<div>
<label className="text-default block text-sm font-semibold">{t("type")}</label>
<p className="text-subtle text-sm">
{data.entry.type === "EMAIL" ? t("email") : t("domain")}
{detailsData.entry.type === "EMAIL" ? t("email") : t("domain")}
</p>
</div>
{isSystem && (
<>
<div>
<label className="text-default block text-sm font-semibold">{t("scope")}</label>
<Badge variant="gray">{t("system_wide_all_organizations")}</Badge>
</div>
<div>
<label className="text-default block text-sm font-semibold">{t("source")}</label>
<p className="text-subtle text-sm">
{detailsData.entry.source === "MANUAL"
? t("manual")
: detailsData.entry.source === "FREE_DOMAIN_POLICY"
? t("free_domain_policy")
: t("automatic")}
</p>
</div>
</>
)}
<div>
<label className="text-default block text-sm font-semibold">{t("blocked_by")}</label>
<p className="text-subtle text-sm">{data.auditHistory[0]?.changedByUser?.email || "—"}</p>
<label className="text-default block text-sm font-semibold">
{t(isSystem ? "blocked_by_system_admin" : "blocked_by")}
</label>
<p className="text-subtle text-sm">
{detailsData.auditHistory[0]?.changedByUser?.email || "—"}
</p>
</div>
<div>
<label className="text-default block text-sm font-semibold">{t("description")}</label>
<p className="text-subtle text-sm">
{data.entry.description || t("no_description_provided")}
{detailsData.entry.description || t("no_description_provided")}
</p>
</div>
{data.entry.bookingReports && data.entry.bookingReports.length > 0 && (
{detailsData.entry.bookingReports && detailsData.entry.bookingReports.length > 0 && (
<div>
<label className="text-default block text-sm font-semibold">
{t("related_booking")}
</label>
{data.entry.bookingReports.map((report) => {
{detailsData.entry.bookingReports.map((report) => {
return (
<Link key={report.booking.uid} href={`/booking/${report.booking.uid}`}>
<div className="text-subtle flex items-center gap-1 text-sm">
@@ -114,9 +144,9 @@ export function BlocklistEntryDetailsSheet({
<div className="bg-subtle rounded-xl p-1">
<h2 className="text-default p-5 text-sm font-semibold">{t("history")}</h2>
{data.auditHistory.length > 0 ? (
<div className="stack-y-3">
{data.auditHistory.map((audit) => (
{detailsData.auditHistory.length > 0 ? (
<div className="space-y-3">
{detailsData.auditHistory.map((audit) => (
<div key={audit.id} className="bg-default border-subtle rounded-xl border p-4">
<div className="flex items-start justify-between">
<div>
@@ -148,7 +178,7 @@ export function BlocklistEntryDetailsSheet({
color="secondary"
onClick={() => entry && handleDeleteBlocklistEntry(entry)}
disabled={!entry}>
{t("remove_from_blocklist")}
{t(isSystem ? "remove_from_system_blocklist" : "remove_from_blocklist")}
</Button>
</SheetFooter>
</SheetContent>
@@ -5,33 +5,40 @@ import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WatchlistType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
type BookingReport = RouterOutputs["viewer"]["organizations"]["listBookingReports"]["rows"][number];
interface BookingReportEntryDetailsModalProps {
entry: BookingReport | null;
isOpen: boolean;
onClose: () => void;
}
import type { BookingReport, BlocklistScope } from "../types";
interface FormData {
blockType: WatchlistType;
}
export function BookingReportEntryDetailsModal({
export interface BookingReportDetailsModalProps<T extends BookingReport> {
scope: BlocklistScope;
entry: T | null;
isOpen: boolean;
onClose: () => void;
onAddToBlocklist: (reportIds: string[], type: WatchlistType) => void;
onDismiss: (reportId: string) => void;
isAddingToBlocklist?: boolean;
isDismissing?: boolean;
}
export function BookingReportDetailsModal<T extends BookingReport>({
scope,
entry,
isOpen,
onClose,
}: BookingReportEntryDetailsModalProps) {
onAddToBlocklist,
onDismiss,
isAddingToBlocklist = false,
isDismissing = false,
}: BookingReportDetailsModalProps<T>) {
const { t } = useLocale();
const utils = trpc.useUtils();
const isSystem = scope === "system";
const {
control,
@@ -44,46 +51,14 @@ export function BookingReportEntryDetailsModal({
},
});
const addToWatchlist = trpc.viewer.organizations.addToWatchlist.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listBookingReports.invalidate();
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_created"), "success");
onClose();
reset();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const dismissBookingReport = trpc.viewer.organizations.dismissBookingReport.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listBookingReports.invalidate();
showToast(t("booking_report_dismissed"), "success");
onClose();
reset();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const onSubmit = (data: FormData) => {
if (!entry) return;
addToWatchlist.mutate({
reportIds: [entry.id],
type: data.blockType,
});
onAddToBlocklist([entry.id], data.blockType);
};
const handleDismiss = () => {
if (!entry) return;
dismissBookingReport.mutate({
reportId: entry.id,
});
onDismiss(entry.id);
};
const handleGoBack = () => {
@@ -104,15 +79,33 @@ export function BookingReportEntryDetailsModal({
<DialogContent enableOverflow>
<DialogHeader title={t("review_report") + ` - ${entry.bookerEmail}`} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="stack-y-5">
<div className="space-y-5">
<div className="bg-subtle rounded-xl p-1">
<h2 className="text-emphasis px-5 py-4 text-base font-semibold">{t("details")}</h2>
<div className="bg-default stack-y-4 rounded-xl p-5">
<div className="bg-default space-y-4 rounded-xl p-5">
<div>
<label className="text-emphasis mb-1 block text-sm font-semibold">{t("email")}</label>
<p className="text-subtle text-sm">{entry.bookerEmail}</p>
</div>
{isSystem && entry.organization && (
<div>
<label className="text-emphasis mb-1 block text-sm font-semibold">
{t("organization")}
</label>
<p className="text-subtle text-sm">{entry.organization.name}</p>
</div>
)}
{isSystem && !entry.organization && (
<div>
<label className="text-emphasis mb-1 block text-sm font-semibold">
{t("organization")}
</label>
<p className="text-subtle text-sm">{t("individual")}</p>
</div>
)}
<div>
<label className="text-emphasis mb-1 block text-sm font-semibold">{t("reason")}</label>
<p className="text-subtle text-sm">{reasonMap[entry.reason] || entry.reason}</p>
@@ -146,6 +139,7 @@ export function BookingReportEntryDetailsModal({
<h2 className="text-emphasis mb-2 text-base font-semibold">
{t("what_would_you_like_to_block")}
</h2>
{isSystem && <p className="text-subtle mb-3 text-sm">{t("system_wide_blocklist_warning")}</p>}
<Controller
name="blockType"
control={control}
@@ -179,7 +173,7 @@ export function BookingReportEntryDetailsModal({
type="button"
color="minimal"
onClick={handleGoBack}
disabled={isSubmitting || addToWatchlist.isPending || dismissBookingReport.isPending}>
disabled={isSubmitting || isAddingToBlocklist || isDismissing}>
{t("go_back")}
</Button>
@@ -188,15 +182,15 @@ export function BookingReportEntryDetailsModal({
type="button"
color="secondary"
onClick={handleDismiss}
loading={dismissBookingReport.isPending}
disabled={isSubmitting || addToWatchlist.isPending || dismissBookingReport.isPending}>
loading={isDismissing}
disabled={isSubmitting || isAddingToBlocklist || isDismissing}>
{t("dont_block")}
</Button>
<Button
type="submit"
loading={isSubmitting || addToWatchlist.isPending}
disabled={isSubmitting || addToWatchlist.isPending || dismissBookingReport.isPending}>
{t("add_to_blocklist")}
loading={isSubmitting || isAddingToBlocklist}
disabled={isSubmitting || isAddingToBlocklist || isDismissing}>
{t(isSystem ? "add_to_system_blocklist" : "add_to_blocklist")}
</Button>
</div>
</div>
@@ -1,31 +1,36 @@
"use client";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WatchlistType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Input, Label, TextArea, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
interface CreateBlocklistEntryModalProps {
import type { BlocklistScope, CreateBlocklistEntryFormData } from "../types";
export interface CreateBlocklistEntryModalProps {
isOpen: boolean;
onClose: () => void;
scope: BlocklistScope;
onCreateEntry: (data: CreateBlocklistEntryFormData) => void;
isPending: boolean;
}
interface FormData {
type: WatchlistType;
value: string;
description?: string;
}
export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) {
export function CreateBlocklistEntryModal({
isOpen,
onClose,
scope,
onCreateEntry,
isPending,
}: CreateBlocklistEntryModalProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const isSystem = scope === "system";
const {
control,
@@ -34,7 +39,7 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
} = useForm<CreateBlocklistEntryFormData>({
defaultValues: {
type: WatchlistType.EMAIL,
value: "",
@@ -44,24 +49,19 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn
const watchType = watch("type");
const createWatchlistEntry = trpc.viewer.organizations.createWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_created"), "success");
onClose();
useEffect(() => {
if (isOpen) {
reset();
},
onError: (error) => {
showToast(error.message, "error");
},
});
}
}, [isOpen, reset]);
const onSubmit = (data: FormData) => {
createWatchlistEntry.mutate({
type: data.type,
value: data.value,
description: data.description,
});
const onSubmit = (data: CreateBlocklistEntryFormData) => {
onCreateEntry(data);
};
const handleClose = () => {
onClose();
reset();
};
const validateValue = (value: string) => {
@@ -86,11 +86,19 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn
];
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent enableOverflow>
<DialogHeader title={t("add_to_blocklist")} />
<DialogHeader title={t(isSystem ? "add_to_system_blocklist" : "add_to_blocklist")} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="stack-y-4">
<div className="space-y-4">
{isSystem && (
<Alert
severity="warning"
title={t("system_wide_blocklist_warning")}
message={t("system_wide_blocklist_warning_description")}
/>
)}
<div>
<Label htmlFor="type" className="text-emphasis mb-2 block text-sm font-medium">
{t("what_would_you_like_to_block")}
@@ -151,18 +159,11 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn
</div>
<DialogFooter className="mt-6">
<Button
type="button"
color="secondary"
onClick={onClose}
disabled={isSubmitting || createWatchlistEntry.isPending}>
<Button type="button" color="secondary" onClick={handleClose} disabled={isSubmitting || isPending}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={isSubmitting || createWatchlistEntry.isPending}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("add_to_blocklist")}
<Button type="submit" loading={isSubmitting || isPending} disabled={isSubmitting || isPending}>
{t(isSystem ? "add_to_system_blocklist" : "add_to_blocklist")}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,16 @@
"use client";
import { Badge } from "@calcom/ui/components/badge";
export interface PendingReportsBadgeProps {
count: number | undefined;
}
export function PendingReportsBadge({ count }: PendingReportsBadgeProps) {
if (!count) return null;
return (
<Badge rounded variant="orange" className="ml-1">
{count}
</Badge>
);
}
@@ -0,0 +1,155 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { Checkbox } from "@calcom/ui/components/form";
import type { BookingReport, BlocklistScope } from "../types";
interface UsePendingReportsColumnsProps<T extends BookingReport> {
t: (key: string) => string;
scope: BlocklistScope;
enableRowSelection?: boolean;
onViewDetails: (entry: T) => void;
}
export function usePendingReportsColumns<T extends BookingReport>({
t,
scope,
enableRowSelection = false,
onViewDetails,
}: UsePendingReportsColumnsProps<T>) {
const isSystem = scope === "system";
return useMemo<ColumnDef<T>[]>(() => {
const columns: ColumnDef<T>[] = [];
if (enableRowSelection) {
columns.push({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 24,
minSize: 24,
maxSize: 24,
});
}
columns.push({
id: "emailOrDomain",
header: t("email_slash_domain"),
accessorKey: "bookerEmail",
enableHiding: false,
size: isSystem ? 200 : 250,
cell: ({ row }) => {
const email = row.original.bookerEmail;
return (
<div className="flex flex-col">
<span className={`text-emphasis ${isSystem ? "break-words text-sm" : ""} font-medium`}>
{email}
</span>
</div>
);
},
});
if (isSystem) {
columns.push({
id: "organization",
header: t("org"),
size: 140,
cell: ({ row }) => {
const org = row.original.organization;
if (!org) {
return <span className="text-subtle text-sm">{t("individual")}</span>;
}
return <span className="text-default block truncate text-sm">{org.name}</span>;
},
});
}
columns.push({
id: "reportedBy",
header: t("reported_by"),
accessorFn: (row) => row.reporter?.email ?? "-",
size: isSystem ? 180 : 250,
cell: ({ row }) => (
<span className={`text-default ${isSystem ? "wrap-break-word block text-sm" : ""}`}>
{row.original.reporter?.email ?? "-"}
</span>
),
});
columns.push({
id: "reason",
header: t("reason"),
size: isSystem ? 120 : 150,
cell: ({ row }) => {
const reason = t(row.original.reason.toLowerCase());
const capitalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1);
return (
<Badge variant="blue" className={isSystem ? "text-xs" : ""}>
{capitalizedReason}
</Badge>
);
},
});
columns.push({
id: "actions",
header: "",
size: isSystem ? 60 : 90,
minSize: isSystem ? 60 : 90,
maxSize: isSystem ? 60 : 90,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
return (
<div className="flex items-center justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem type="button" StartIcon="eye" onClick={() => onViewDetails(entry)}>
{t("view_details")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
);
},
});
return columns;
}, [t, scope, isSystem, enableRowSelection, onViewDetails]);
}
@@ -0,0 +1,127 @@
"use client";
import type { RowSelectionState } from "@tanstack/react-table";
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { DataTableSelectionBar, DataTableWrapper } from "~/data-table/components";
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 { BookingReportDetailsModal } from "./BookingReportDetailsModal";
import { usePendingReportsColumns } from "./PendingReportsColumns";
export interface PendingReportsTableProps<T extends BookingReport> {
scope: BlocklistScope;
data: T[];
totalRowCount: number;
isPending: boolean;
limit: number;
onAddToBlocklist: (reportIds: string[], type: WatchlistType, onSuccess: () => void) => void;
onDismiss: (reportId: string, onSuccess: () => void) => void;
isAddingToBlocklist?: boolean;
isDismissing?: boolean;
enableRowSelection?: boolean;
renderBulkActions?: (selectedReports: T[], clearSelection: () => void) => ReactNode;
}
export function PendingReportsTable<T extends BookingReport>({
scope,
data,
totalRowCount,
isPending,
limit,
onAddToBlocklist,
onDismiss,
isAddingToBlocklist = false,
isDismissing = false,
enableRowSelection = false,
renderBulkActions,
}: PendingReportsTableProps<T>) {
const { t } = useLocale();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [showReviewDialog, setShowReviewDialog] = useState(false);
const [selectedReport, setSelectedReport] = useState<T | null>(null);
const handleViewDetails = (entry: T) => {
setSelectedReport(entry);
setShowReviewDialog(true);
};
const handleCloseModal = () => {
setShowReviewDialog(false);
setSelectedReport(null);
};
const columns = usePendingReportsColumns<T>({
t,
scope,
enableRowSelection,
onViewDetails: handleViewDetails,
});
const flatData = useMemo(() => data ?? [], [data]);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalRowCount / limit),
enableRowSelection,
onRowSelectionChange: setRowSelection,
state: {
rowSelection,
},
getRowId: (row) => row.id,
});
const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length;
const selectedReports = table.getSelectedRowModel().flatRows.map((row) => row.original);
const clearSelection = () => table.toggleAllPageRowsSelected(false);
return (
<>
<DataTableWrapper
table={table}
isPending={isPending}
variant="default"
paginationMode="standard"
EmptyView={
<EmptyScreen
customIcon={<img className="mb-6" src="/slash-icon-cards.svg" />}
headline={t("no_pending_reports")}
className="bg-muted mb-16"
iconWrapperClassName="bg-default"
dashedBorder={false}
/>
}
totalRowCount={totalRowCount}>
{enableRowSelection && numberOfSelectedRows > 0 && renderBulkActions && (
<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>
{renderBulkActions(selectedReports, clearSelection)}
</DataTableSelectionBar.Root>
)}
</DataTableWrapper>
<BookingReportDetailsModal
scope={scope}
entry={selectedReport}
isOpen={showReviewDialog}
onClose={handleCloseModal}
onAddToBlocklist={(reportIds, type) => onAddToBlocklist(reportIds, type, handleCloseModal)}
onDismiss={(reportId) => onDismiss(reportId, handleCloseModal)}
isAddingToBlocklist={isAddingToBlocklist}
isDismissing={isDismissing}
/>
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
export * from "./types";
export * from "./components/CreateBlocklistEntryModal";
export * from "./components/BlockedEntriesTable";
export * from "./components/BlockedEntriesColumns";
export * from "./components/BlocklistEntryDetailsSheet";
export * from "./components/PendingReportsTable";
export * from "./components/PendingReportsColumns";
export * from "./components/BookingReportDetailsModal";
export * from "./components/PendingReportsBadge";
+66
View File
@@ -0,0 +1,66 @@
"use client";
import type { WatchlistType } from "@calcom/prisma/enums";
export type BlocklistScope = "organization" | "system";
export interface BlocklistPermissions {
canRead: boolean;
canCreate: boolean;
canDelete: boolean;
}
export interface BlocklistEntry {
id: string;
value: string;
type: WatchlistType;
description: string | null;
source?: string;
isGlobal?: boolean;
isReadOnly?: boolean;
latestAudit?: {
changedByUserId: number | null;
changedByUser?: { id: number; email: string; name: string | null };
} | null;
}
export interface BlocklistEntryDetails {
entry: {
id: string;
value: string;
type: WatchlistType;
description: string | null;
source?: string;
bookingReports?: Array<{
booking: {
uid: string;
title: string | null;
};
}>;
};
auditHistory: Array<{
id: string;
value: string;
changedAt: Date | string;
changedByUser?: { name: string | null; email: string } | null;
}>;
}
export interface BookingReport {
id: string;
bookerEmail: string;
reason: string;
description: string | null;
reporter?: { email: string } | null;
booking: {
uid: string;
title: string | null;
};
organization?: { name: string } | null;
}
export interface CreateBlocklistEntryFormData {
type: WatchlistType;
value: string;
description?: string;
}
@@ -1,4 +1,4 @@
import type { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
import type { BookingReportReason, BookingReportStatus, SystemReportStatus } from "@calcom/prisma/enums";
export interface CreateBookingReportInput {
bookingUid: string;
@@ -25,6 +25,10 @@ export interface ListBookingReportsFilters {
status?: BookingReportStatus[];
}
export interface SystemBookingReportsFilters {
systemStatus?: SystemReportStatus[];
}
export interface BookingReportWithDetails {
id: string;
bookingUid: string;
@@ -35,7 +39,10 @@ export interface BookingReportWithDetails {
cancelled: boolean;
createdAt: Date;
status: BookingReportStatus;
systemStatus: SystemReportStatus;
watchlistId: string | null;
globalWatchlistId: string | null;
organizationId: number | null;
reporter: {
id: number;
email: string;
@@ -55,6 +62,13 @@ export interface BookingReportWithDetails {
action: string;
description: string | null;
} | null;
globalWatchlist: {
id: string;
type: string;
value: string;
action: string;
description: string | null;
} | null;
organization: {
id: number;
name: string;
@@ -100,5 +114,24 @@ export interface IBookingReportRepository {
status: BookingReportStatus;
}): Promise<void>;
bulkLinkGlobalWatchlistWithSystemStatus(params: {
links: Array<{ reportId: string; globalWatchlistId: string }>;
systemStatus: SystemReportStatus;
}): Promise<void>;
countPendingReports(params: { organizationId: number }): Promise<number>;
updateSystemReportStatus(params: {
reportId: string;
systemStatus: SystemReportStatus;
globalWatchlistId?: string | null;
}): Promise<void>;
bulkUpdateSystemReportStatus(params: {
reportIds: string[];
systemStatus: SystemReportStatus;
globalWatchlistId?: string | null;
}): Promise<{ updated: number }>;
countSystemPendingReports(): Promise<number>;
}
@@ -19,6 +19,7 @@ describe("PrismaBookingReportRepository", () => {
findUnique: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
@@ -104,7 +105,10 @@ describe("PrismaBookingReportRepository", () => {
cancelled: false,
createdAt: new Date("2025-01-01T10:00:00Z"),
status: "PENDING",
systemStatus: "PENDING",
watchlistId: null,
globalWatchlistId: null,
organizationId: null,
reportedBy: null,
booking: {
id: 1,
@@ -114,6 +118,8 @@ describe("PrismaBookingReportRepository", () => {
endTime: new Date(),
},
watchlist: null,
globalWatchlist: null,
organization: null,
},
{
id: "report-2",
@@ -125,7 +131,10 @@ describe("PrismaBookingReportRepository", () => {
cancelled: false,
createdAt: new Date("2025-01-01T11:00:00Z"),
status: "PENDING",
systemStatus: "PENDING",
watchlistId: null,
globalWatchlistId: null,
organizationId: null,
reportedBy: null,
booking: {
id: 2,
@@ -135,6 +144,8 @@ describe("PrismaBookingReportRepository", () => {
endTime: new Date(),
},
watchlist: null,
globalWatchlist: null,
organization: null,
},
];
@@ -205,4 +216,152 @@ describe("PrismaBookingReportRepository", () => {
});
});
});
describe("updateSystemReportStatus", () => {
it("should update systemStatus with globalWatchlistId", async () => {
mockPrisma.bookingReport.update.mockResolvedValue({ id: "report-1" });
await repository.updateSystemReportStatus({
reportId: "report-123",
systemStatus: "BLOCKED",
globalWatchlistId: "watchlist-456",
});
expect(mockPrisma.bookingReport.update).toHaveBeenCalledWith({
where: { id: "report-123" },
data: {
systemStatus: "BLOCKED",
globalWatchlistId: "watchlist-456",
},
});
});
it("should update systemStatus without globalWatchlistId", async () => {
mockPrisma.bookingReport.update.mockResolvedValue({ id: "report-1" });
await repository.updateSystemReportStatus({
reportId: "report-456",
systemStatus: "DISMISSED",
});
expect(mockPrisma.bookingReport.update).toHaveBeenCalledWith({
where: { id: "report-456" },
data: {
systemStatus: "DISMISSED",
},
});
});
});
describe("bulkUpdateSystemReportStatus", () => {
it("should bulk update systemStatus with globalWatchlistId", async () => {
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 3 });
const result = await repository.bulkUpdateSystemReportStatus({
reportIds: ["r1", "r2", "r3"],
systemStatus: "BLOCKED",
globalWatchlistId: "global-1",
});
expect(result).toEqual({ updated: 3 });
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r1", "r2", "r3"] } },
data: {
systemStatus: "BLOCKED",
globalWatchlistId: "global-1",
},
});
});
it("should bulk update systemStatus without globalWatchlistId", async () => {
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 2 });
const result = await repository.bulkUpdateSystemReportStatus({
reportIds: ["r1", "r2"],
systemStatus: "DISMISSED",
});
expect(result).toEqual({ updated: 2 });
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r1", "r2"] } },
data: {
systemStatus: "DISMISSED",
},
});
});
});
describe("countSystemPendingReports", () => {
it("should count reports with systemStatus PENDING", async () => {
mockPrisma.bookingReport.count.mockResolvedValue(5);
const result = await repository.countSystemPendingReports();
expect(result).toBe(5);
expect(mockPrisma.bookingReport.count).toHaveBeenCalledWith({
where: { systemStatus: "PENDING" },
});
});
});
describe("bulkLinkGlobalWatchlistWithSystemStatus", () => {
it("should do nothing when links array is empty", async () => {
await repository.bulkLinkGlobalWatchlistWithSystemStatus({
links: [],
systemStatus: "BLOCKED",
});
expect(mockPrisma.bookingReport.updateMany).not.toHaveBeenCalled();
});
it("should link each report to its corresponding globalWatchlistId", async () => {
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
await repository.bulkLinkGlobalWatchlistWithSystemStatus({
links: [
{ reportId: "r1", globalWatchlistId: "w1" },
{ reportId: "r2", globalWatchlistId: "w2" },
{ reportId: "r3", globalWatchlistId: "w3" },
],
systemStatus: "BLOCKED",
});
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledTimes(3);
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r1"] } },
data: { globalWatchlistId: "w1", systemStatus: "BLOCKED" },
});
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r2"] } },
data: { globalWatchlistId: "w2", systemStatus: "BLOCKED" },
});
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r3"] } },
data: { globalWatchlistId: "w3", systemStatus: "BLOCKED" },
});
});
it("should group reports with the same globalWatchlistId", async () => {
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 2 });
await repository.bulkLinkGlobalWatchlistWithSystemStatus({
links: [
{ reportId: "r1", globalWatchlistId: "w1" },
{ reportId: "r2", globalWatchlistId: "w1" },
{ reportId: "r3", globalWatchlistId: "w2" },
],
systemStatus: "BLOCKED",
});
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledTimes(2);
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r1", "r2"] } },
data: { globalWatchlistId: "w1", systemStatus: "BLOCKED" },
});
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["r3"] } },
data: { globalWatchlistId: "w2", systemStatus: "BLOCKED" },
});
});
});
});
@@ -1,5 +1,5 @@
import type { PrismaClient } from "@calcom/prisma";
import type { BookingReportStatus } from "@calcom/prisma/enums";
import type { BookingReportStatus, SystemReportStatus } from "@calcom/prisma/enums";
import type { Prisma } from "@calcom/prisma/generated/prisma/client";
import type {
@@ -7,6 +7,7 @@ import type {
CreateBookingReportInput,
BookingReportWithDetails,
ListBookingReportsFilters,
SystemBookingReportsFilters,
} from "./IBookingReportRepository";
export class PrismaBookingReportRepository implements IBookingReportRepository {
@@ -34,6 +35,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
take?: number;
searchTerm?: string;
filters?: ListBookingReportsFilters;
systemFilters?: SystemBookingReportsFilters;
}): Promise<{
rows: BookingReportWithDetails[];
meta: { totalRowCount: number };
@@ -71,6 +73,10 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
where.status = { in: params.filters.status };
}
if (params.systemFilters?.systemStatus && params.systemFilters.systemStatus.length > 0) {
where.systemStatus = { in: params.systemFilters.systemStatus };
}
const [reports, totalCount] = await Promise.all([
this.prismaClient.bookingReport.findMany({
where,
@@ -86,7 +92,9 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
cancelled: true,
createdAt: true,
status: true,
systemStatus: true,
watchlistId: true,
globalWatchlistId: true,
organizationId: true,
reportedBy: {
select: {
@@ -113,6 +121,15 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
description: true,
},
},
globalWatchlist: {
select: {
id: true,
type: true,
value: true,
action: true,
description: true,
},
},
organization: {
select: {
id: true,
@@ -161,7 +178,9 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
cancelled: true,
createdAt: true,
status: true,
systemStatus: true,
watchlistId: true,
globalWatchlistId: true,
organizationId: true,
reportedBy: {
select: {
@@ -188,6 +207,15 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
description: true,
},
},
globalWatchlist: {
select: {
id: true,
type: true,
value: true,
action: true,
description: true,
},
},
organization: {
select: {
id: true,
@@ -274,6 +302,29 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
);
}
async bulkLinkGlobalWatchlistWithSystemStatus(params: {
links: Array<{ reportId: string; globalWatchlistId: string }>;
systemStatus: SystemReportStatus;
}): Promise<void> {
if (params.links.length === 0) return;
const groupedByWatchlist = new Map<string, string[]>();
for (const link of params.links) {
const reportIds = groupedByWatchlist.get(link.globalWatchlistId) || [];
reportIds.push(link.reportId);
groupedByWatchlist.set(link.globalWatchlistId, reportIds);
}
await Promise.all(
Array.from(groupedByWatchlist.entries()).map(([globalWatchlistId, reportIds]) =>
this.prismaClient.bookingReport.updateMany({
where: { id: { in: reportIds } },
data: { globalWatchlistId, systemStatus: params.systemStatus },
})
)
);
}
async countPendingReports(params: { organizationId: number }): Promise<number> {
return this.prismaClient.bookingReport.count({
where: {
@@ -283,4 +334,41 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
},
});
}
async updateSystemReportStatus(params: {
reportId: string;
systemStatus: SystemReportStatus;
globalWatchlistId?: string | null;
}): Promise<void> {
await this.prismaClient.bookingReport.update({
where: { id: params.reportId },
data: {
systemStatus: params.systemStatus,
...(params.globalWatchlistId !== undefined && { globalWatchlistId: params.globalWatchlistId }),
},
});
}
async bulkUpdateSystemReportStatus(params: {
reportIds: string[];
systemStatus: SystemReportStatus;
globalWatchlistId?: string | null;
}): Promise<{ updated: number }> {
const result = await this.prismaClient.bookingReport.updateMany({
where: { id: { in: params.reportIds } },
data: {
systemStatus: params.systemStatus,
...(params.globalWatchlistId !== undefined && { globalWatchlistId: params.globalWatchlistId }),
},
});
return { updated: result.count };
}
async countSystemPendingReports(): Promise<number> {
return this.prismaClient.bookingReport.count({
where: {
systemStatus: "PENDING",
},
});
}
}
@@ -0,0 +1,211 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
BlockedEntriesTable,
CreateBlocklistEntryModal,
PendingReportsBadge,
PendingReportsTable,
} 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 { ToggleGroup } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { BulkDeleteBlocklistEntries } from "./blocklist/BulkDeleteBlocklistEntries";
import { BulkDismissReports } from "./blocklist/BulkDismissReports";
type ViewType = "blocked" | "pending";
function SystemBlocklistContent() {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [activeView, setActiveView] = useState<ViewType>("blocked");
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: blockedData, isPending: isBlockedPending } =
trpc.viewer.admin.watchlist.list.useQuery(
{ limit, offset, searchTerm },
{ placeholderData: keepPreviousData, enabled: activeView === "blocked" }
);
const { data: reportsData, isPending: isReportsPending } =
trpc.viewer.admin.watchlist.listReports.useQuery(
{
limit,
offset,
searchTerm,
systemFilters: { systemStatus: ["PENDING"] },
},
{ placeholderData: keepPreviousData, enabled: activeView === "pending" }
);
const { data: pendingReportsCount } =
trpc.viewer.admin.watchlist.pendingReportsCount.useQuery();
const { data: entryDetails, isLoading: isDetailsLoading } =
trpc.viewer.admin.watchlist.getDetails.useQuery(
{ id: selectedEntryId ?? "" },
{ enabled: !!selectedEntryId }
);
const createEntry = trpc.viewer.admin.watchlist.create.useMutation({
onSuccess: async () => {
await utils.viewer.admin.watchlist.list.invalidate();
showToast(t("system_blocklist_entry_created"), "success");
setShowCreateModal(false);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const deleteEntry = trpc.viewer.admin.watchlist.delete.useMutation({
onSuccess: async () => {
await utils.viewer.admin.watchlist.list.invalidate();
setSelectedEntryId(null);
showToast(t("system_blocklist_entry_deleted"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const addToWatchlist = trpc.viewer.admin.watchlist.addToWatchlist.useMutation(
{
onSuccess: async () => {
await utils.viewer.admin.watchlist.listReports.invalidate();
await utils.viewer.admin.watchlist.list.invalidate();
await utils.viewer.admin.watchlist.pendingReportsCount.invalidate();
showToast(t("system_blocklist_entry_created"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
const dismissReport = trpc.viewer.admin.watchlist.dismissReport.useMutation({
onSuccess: async () => {
await utils.viewer.admin.watchlist.listReports.invalidate();
await utils.viewer.admin.watchlist.pendingReportsCount.invalidate();
showToast(t("booking_report_dismissed"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleGroup
value={activeView}
onValueChange={(value) => {
if (value) setActiveView(value as ViewType);
}}
options={[
{ value: "blocked", label: t("blocked") },
{
value: "pending",
label: (
<span className="flex items-center">
{t("pending")}
<PendingReportsBadge count={pendingReportsCount} />
</span>
),
},
]}
/>
<DataTableToolbar.SearchBar />
</div>
<div className="flex items-center gap-2">
{activeView === "blocked" && (
<Button
color="primary"
StartIcon="plus"
onClick={() => setShowCreateModal(true)}
>
{t("add")}
</Button>
)}
</div>
</div>
{activeView === "blocked" ? (
<BlockedEntriesTable
scope="system"
data={blockedData?.rows ?? []}
totalRowCount={blockedData?.meta?.totalRowCount ?? 0}
isPending={isBlockedPending}
limit={limit}
searchTerm={searchTerm}
onAddClick={() => setShowCreateModal(true)}
onDelete={(entry) => deleteEntry.mutate({ id: entry.id })}
isDeleting={deleteEntry.isPending}
detailsQuery={{ data: entryDetails, isLoading: isDetailsLoading }}
selectedEntryId={selectedEntryId ?? undefined}
onSelectEntry={setSelectedEntryId}
enableRowSelection
renderBulkActions={(selectedEntries, clearSelection) => (
<BulkDeleteBlocklistEntries
entries={selectedEntries}
onRemove={clearSelection}
/>
)}
/>
) : (
<PendingReportsTable
scope="system"
data={reportsData?.rows ?? []}
totalRowCount={reportsData?.meta?.totalRowCount ?? 0}
isPending={isReportsPending}
limit={limit}
onAddToBlocklist={(reportIds, type, onSuccess) =>
addToWatchlist.mutate({ reportIds, type }, { onSuccess })
}
onDismiss={(reportId, onSuccess) =>
dismissReport.mutate({ reportId }, { onSuccess })
}
isAddingToBlocklist={addToWatchlist.isPending}
isDismissing={dismissReport.isPending}
enableRowSelection
renderBulkActions={(selectedReports, clearSelection) => (
<BulkDismissReports
reports={selectedReports}
onRemove={clearSelection}
/>
)}
/>
)}
<CreateBlocklistEntryModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
scope="system"
onCreateEntry={(data) => createEntry.mutate(data)}
isPending={createEntry.isPending}
/>
</>
);
}
export default function SystemBlocklistView() {
const pathname = usePathname();
return (
<DataTableProvider tableIdentifier={pathname ?? "system-blocklist"}>
<SystemBlocklistContent />
</DataTableProvider>
);
}
@@ -0,0 +1,69 @@
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { DataTableSelectionBar } from "~/data-table/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
DialogTrigger,
ConfirmationDialogContent,
} from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
type BlocklistEntry =
RouterOutputs["viewer"]["admin"]["watchlist"]["list"]["rows"][number];
interface Props {
entries: BlocklistEntry[];
onRemove: () => void;
}
export function BulkDeleteBlocklistEntries({ entries, onRemove }: Props) {
const { t } = useLocale();
const utils = trpc.useUtils();
const bulkDeleteMutation = trpc.viewer.admin.watchlist.bulkDelete.useMutation(
{
onSuccess: async (data) => {
await utils.viewer.admin.watchlist.list.invalidate();
showToast(
data.failed === 0
? t("entries_deleted_successfully", { count: data.success })
: t("entries_deleted_with_errors", {
success: data.success,
failed: data.failed,
}),
data.failed === 0 ? "success" : "warning"
);
onRemove();
},
onError: (error) => {
showToast(error.message, "error");
},
}
);
return (
<Dialog>
<DialogTrigger asChild>
<DataTableSelectionBar.Button icon="trash" color="destructive">
{t("remove_from_blocklist")}
</DataTableSelectionBar.Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("remove_entries_from_system_blocklist")}
confirmBtnText={t("remove")}
isPending={bulkDeleteMutation.isPending}
onConfirm={() => {
bulkDeleteMutation.mutate({
ids: entries.map((entry) => entry.id),
});
}}
>
<p className="mt-5">
{t("remove_entries_confirm", { count: entries.length })}
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
@@ -0,0 +1,69 @@
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { DataTableSelectionBar } from "~/data-table/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import {
DialogTrigger,
ConfirmationDialogContent,
} from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
type BookingReport =
RouterOutputs["viewer"]["admin"]["watchlist"]["listReports"]["rows"][number];
interface Props {
reports: BookingReport[];
onRemove: () => void;
}
export function BulkDismissReports({ reports, onRemove }: Props) {
const { t } = useLocale();
const utils = trpc.useUtils();
const bulkDismissMutation =
trpc.viewer.admin.watchlist.bulkDismiss.useMutation({
onSuccess: async (data) => {
await utils.viewer.admin.watchlist.listReports.invalidate();
await utils.viewer.admin.watchlist.pendingReportsCount.invalidate();
showToast(
data.failed === 0
? t("reports_dismissed_successfully", { count: data.success })
: t("reports_dismissed_with_errors", {
success: data.success,
failed: data.failed,
}),
data.failed === 0 ? "success" : "warning"
);
onRemove();
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<Dialog>
<DialogTrigger asChild>
<DataTableSelectionBar.Button icon="x" color="secondary">
{t("dismiss")}
</DataTableSelectionBar.Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="warning"
title={t("dismiss_reports_title")}
confirmBtnText={t("dismiss")}
isPending={bulkDismissMutation.isPending}
onConfirm={() => {
bulkDismissMutation.mutate({
reportIds: reports.map((report) => report.id),
});
}}
>
<p className="mt-5">
{t("dismiss_reports_confirm", { count: reports.length })}
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
@@ -335,4 +335,84 @@ export class WatchlistRepository implements IWatchlistRepository {
return { watchlistEntry, value: params.value };
}
async findOrgAndGlobalEntries(params: {
organizationId: number;
limit: number;
offset: number;
searchTerm?: string;
filters?: {
type?: NonNullable<FindAllEntriesInput["filters"]>["type"];
source?: NonNullable<FindAllEntriesInput["filters"]>["source"];
};
}): Promise<{
rows: (WatchlistEntry & {
latestAudit: { changedByUserId: number | null } | null;
isReadOnly: boolean;
})[];
meta: { totalRowCount: number };
}> {
const where = {
OR: [
{ organizationId: params.organizationId, isGlobal: false },
{ isGlobal: true, organizationId: null },
],
...(params.searchTerm && {
value: {
contains: params.searchTerm,
mode: "insensitive" as const,
},
}),
...(params.filters?.type && {
type: params.filters.type,
}),
...(params.filters?.source && {
source: params.filters.source,
}),
};
const [entries, totalRowCount] = await Promise.all([
this.prismaClient.watchlist.findMany({
where,
take: params.limit,
skip: params.offset,
orderBy: {
lastUpdatedAt: "desc",
},
select: {
id: true,
type: true,
value: true,
action: true,
description: true,
organizationId: true,
isGlobal: true,
source: true,
lastUpdatedAt: true,
audits: {
take: 1,
orderBy: {
changedAt: "desc",
},
select: {
changedByUserId: true,
},
},
},
}),
this.prismaClient.watchlist.count({ where }),
]);
const rows = entries.map((entry) => ({
...entry,
latestAudit: entry.audits[0] ?? null,
audits: undefined,
isReadOnly: entry.isGlobal,
}));
return {
rows,
meta: { totalRowCount },
};
}
}
@@ -1,10 +1,13 @@
import type { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository";
import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository";
import logger from "@calcom/lib/logger";
import { BookingReportStatus } from "@calcom/prisma/enums";
import { SystemReportStatus, WatchlistType } from "@calcom/prisma/enums";
import { WatchlistErrors } from "../errors/WatchlistErrors";
import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization";
import type {
AddReportsToWatchlistInput,
AddReportsToWatchlistResult,
DeleteWatchlistEntryInput,
DeleteWatchlistEntryResult,
WatchlistOperationsScope,
@@ -61,7 +64,9 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
protected async findReports(
reportIds: string[]
): Promise<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>> {
): Promise<
Array<{ id: string; bookerEmail: string; watchlistId: string | null; globalWatchlistId: string | null }>
> {
const reports = await this.deps.bookingReportRepo.findReportsByIds({
reportIds,
});
@@ -69,6 +74,77 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
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> {
@@ -149,15 +225,15 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
const report = reports[0];
if (report.watchlistId) {
if (report.globalWatchlistId) {
throw WatchlistErrors.validationError(
"Cannot dismiss a report that has already been added to the blocklist"
"Cannot dismiss a report that has already been added to the global blocklist"
);
}
await this.deps.bookingReportRepo.updateReportStatus({
await this.deps.bookingReportRepo.updateSystemReportStatus({
reportId: input.reportId,
status: BookingReportStatus.DISMISSED,
systemStatus: SystemReportStatus.DISMISSED,
});
return { success: true };
@@ -180,8 +256,8 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
continue;
}
if (report.watchlistId) {
failed.push({ id: reportId, reason: "Already added to blocklist" });
if (report.globalWatchlistId) {
failed.push({ id: reportId, reason: "Already added to global blocklist" });
continue;
}
@@ -190,9 +266,9 @@ export class AdminWatchlistOperationsService extends WatchlistOperationsService
let successCount = 0;
if (validReportIds.length > 0) {
const result = await this.deps.bookingReportRepo.bulkUpdateReportStatus({
const result = await this.deps.bookingReportRepo.bulkUpdateSystemReportStatus({
reportIds: validReportIds,
status: BookingReportStatus.DISMISSED,
systemStatus: SystemReportStatus.DISMISSED,
});
successCount = result.updated;
}
@@ -1,4 +1,7 @@
import type { ListBookingReportsFilters } from "@calcom/features/bookingReport/repositories/IBookingReportRepository";
import type {
ListBookingReportsFilters,
SystemBookingReportsFilters,
} from "@calcom/features/bookingReport/repositories/IBookingReportRepository";
import type { PrismaBookingReportRepository } from "@calcom/features/bookingReport/repositories/PrismaBookingReportRepository";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { WatchlistRepository } from "@calcom/features/watchlist/lib/repository/WatchlistRepository";
@@ -26,6 +29,7 @@ export interface ListBookingReportsInput {
offset: number;
searchTerm?: string;
filters?: ListBookingReportsFilters;
systemFilters?: SystemBookingReportsFilters;
}
type Deps = {
@@ -116,17 +120,13 @@ export class AdminWatchlistQueryService {
take: input.limit,
searchTerm: input.searchTerm,
filters: input.filters,
systemFilters: input.systemFilters,
});
return result;
}
async getPendingReportsCount(): Promise<number> {
return this.deps.prisma.bookingReport.count({
where: {
status: "PENDING",
watchlistId: null,
},
});
return this.deps.bookingReportRepo.countSystemPendingReports();
}
}
@@ -48,7 +48,7 @@ export class OrganizationWatchlistQueryService {
async listWatchlistEntries(input: ListWatchlistEntriesInput) {
await this.checkReadPermission(input.userId, input.organizationId);
const result = await this.deps.watchlistRepo.findAllEntriesWithLatestAudit({
const result = await this.deps.watchlistRepo.findOrgAndGlobalEntries({
organizationId: input.organizationId,
limit: input.limit,
offset: input.offset,
@@ -95,8 +95,13 @@ export class OrganizationWatchlistQueryService {
throw WatchlistErrors.notFound("Blocklist entry not found");
}
if (result.entry.organizationId !== input.organizationId) {
throw WatchlistErrors.permissionDenied("You can only view blocklist entries from your organization");
const isOrgEntry = result.entry.organizationId === input.organizationId;
const isGlobalEntry = result.entry.isGlobal && result.entry.organizationId === null;
if (!isOrgEntry && !isGlobalEntry) {
throw WatchlistErrors.permissionDenied(
"You can only view blocklist entries from your organization or global entries"
);
}
const userIds = result.auditHistory
@@ -117,6 +122,7 @@ export class OrganizationWatchlistQueryService {
return {
entry: result.entry,
auditHistory: auditHistoryWithUsers,
isReadOnly: isGlobalEntry,
};
}
}
@@ -0,0 +1,15 @@
-- CreateEnum
CREATE TYPE "public"."SystemReportStatus" AS ENUM ('PENDING', 'BLOCKED', 'DISMISSED');
-- AlterTable
ALTER TABLE "public"."BookingReport" ADD COLUMN "globalWatchlistId" UUID,
ADD COLUMN "systemStatus" "public"."SystemReportStatus" NOT NULL DEFAULT 'PENDING';
-- CreateIndex
CREATE INDEX "BookingReport_globalWatchlistId_idx" ON "public"."BookingReport"("globalWatchlistId");
-- CreateIndex
CREATE INDEX "BookingReport_systemStatus_idx" ON "public"."BookingReport"("systemStatus");
-- AddForeignKey
ALTER TABLE "public"."BookingReport" ADD CONSTRAINT "BookingReport_globalWatchlistId_fkey" FOREIGN KEY ("globalWatchlistId") REFERENCES "public"."Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+18 -6
View File
@@ -2408,8 +2408,9 @@ model Watchlist {
source WatchlistSource @default(MANUAL)
lastUpdatedAt DateTime @default(now())
bookingReports BookingReport[]
audits WatchlistAudit[]
bookingReports BookingReport[]
globalBookingReports BookingReport[] @relation("GlobalBlocklist")
audits WatchlistAudit[]
@@unique([type, value, organizationId])
@@index([isGlobal, action, organizationId, type, value])
@@ -2452,6 +2453,12 @@ enum BookingReportStatus {
BLOCKED // Added to watchlist
}
enum SystemReportStatus {
PENDING // Not yet reviewed by system admin
BLOCKED // Added to global blocklist
DISMISSED // System admin dismissed (won't see again)
}
model BookingReport {
id String @id @default(uuid()) @db.Uuid
bookingUid String @unique
@@ -2465,17 +2472,22 @@ model BookingReport {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status BookingReportStatus @default(PENDING)
systemStatus SystemReportStatus @default(PENDING)
watchlistId String? @db.Uuid
watchlistId String? @db.Uuid
globalWatchlistId String? @db.Uuid
reportedBy User? @relation("ReportedBy", fields: [reportedById], references: [id], onDelete: SetNull)
organization Team? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
watchlist Watchlist? @relation(fields: [watchlistId], references: [id], onDelete: SetNull)
reportedBy User? @relation("ReportedBy", fields: [reportedById], references: [id], onDelete: SetNull)
organization Team? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
watchlist Watchlist? @relation(fields: [watchlistId], references: [id], onDelete: SetNull)
globalWatchlist Watchlist? @relation("GlobalBlocklist", fields: [globalWatchlistId], references: [id], onDelete: SetNull)
@@index([bookerEmail])
@@index([reportedById])
@@index([organizationId])
@@index([watchlistId])
@@index([globalWatchlistId])
@@index([systemStatus])
@@index([createdAt])
}
@@ -18,5 +18,6 @@ export const listReportsHandler = async ({ input }: ListReportsOptions) => {
offset: input.offset,
searchTerm: input.searchTerm,
filters: input.filters,
systemFilters: input.systemFilters,
});
};
@@ -1,6 +1,6 @@
import { z } from "zod";
import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
import { BookingReportReason, BookingReportStatus, SystemReportStatus } from "@calcom/prisma/enums";
export const ZListReportsInputSchema = z.object({
limit: z.number().min(1).max(100).default(25),
@@ -12,6 +12,11 @@ export const ZListReportsInputSchema = z.object({
reason: z.array(z.nativeEnum(BookingReportReason)).optional(),
})
.optional(),
systemFilters: z
.object({
systemStatus: z.array(z.nativeEnum(SystemReportStatus)).optional(),
})
.optional(),
});
export type TListReportsInputSchema = z.infer<typeof ZListReportsInputSchema>;
@@ -2,7 +2,12 @@ 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, BookingReportStatus } from "@calcom/prisma/enums";
import {
MembershipRole,
BookingReportReason,
BookingReportStatus,
SystemReportStatus,
} from "@calcom/prisma/enums";
import { listBookingReportsHandler } from "./listBookingReports.handler";
@@ -29,7 +34,10 @@ describe("listBookingReportsHandler (Organization)", () => {
cancelled: false,
createdAt: new Date("2025-01-01"),
status: BookingReportStatus.PENDING,
systemStatus: SystemReportStatus.PENDING,
watchlistId: null,
globalWatchlistId: null,
organizationId: 100,
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
booking: {
id: 1,
@@ -39,6 +47,8 @@ describe("listBookingReportsHandler (Organization)", () => {
uid: "uid-1",
},
watchlist: null,
globalWatchlist: null,
organization: { id: 100, name: "Test Org", slug: "test-org" },
},
{
id: "report-2",
@@ -50,7 +60,10 @@ describe("listBookingReportsHandler (Organization)", () => {
cancelled: true,
createdAt: new Date("2025-01-02"),
status: BookingReportStatus.BLOCKED,
systemStatus: SystemReportStatus.PENDING,
watchlistId: "watchlist-123",
globalWatchlistId: null,
organizationId: 100,
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
booking: {
id: 2,
@@ -66,6 +79,8 @@ describe("listBookingReportsHandler (Organization)", () => {
action: "REPORT" as const,
description: null,
},
globalWatchlist: null,
organization: { id: 100, name: "Test Org", slug: "test-org" },
},
],
meta: { totalRowCount: 2 },