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:
@@ -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;
|
||||
+5
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+62
-32
@@ -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>
|
||||
+49
-55
@@ -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>
|
||||
+42
-41
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
|
||||
+16
-1
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user