feat: blocklist table (#24459)

* feat: blocklist table

* feat: blocklist table

* refactor: feedback

* chore: add select

* UI improvements

* chore: remove un unsed

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Udit Takkar
2025-10-15 11:20:49 +00:00
committed by GitHub
co-authored by CarinaWolli
parent 17ab088af8
commit 5a59bb86cd
30 changed files with 2209 additions and 24 deletions
@@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
]
: []),
{
name: "privacy",
name: "privacy_and_security",
href: "/settings/organizations/privacy",
},
@@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
const organizationAdminKeys = [
"privacy",
"privacy_and_security",
"OAuth Clients",
"SSO",
"directory_sync",
"delegation_credential",
];
export interface SettingsPermissions {
canViewRoles?: boolean;
@@ -11,7 +11,7 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("privacy"),
(t) => t("privacy_and_security"),
(t) => t("privacy_organization_description"),
undefined,
undefined,
@@ -19,9 +19,8 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrg();
const t = await getTranslate();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
@@ -42,13 +41,31 @@ const Page = async () => {
},
});
const watchlistPermissions = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Watchlist,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
<PrivacyView permissions={{ canRead, canEdit }} />
<SettingsHeader title={t("privacy_and_security")} description={t("privacy_organization_description")}>
<PrivacyView permissions={{ canRead, canEdit }} watchlistPermissions={watchlistPermissions} />
</SettingsHeader>
);
};
@@ -0,0 +1,207 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { DataTableWrapper, DataTableToolbar, useDataTable } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
import { BlocklistEntryDetailsSheet } from "./components/blocklist-entry-details-sheet";
import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlocklistTableProps {
permissions?: {
canRead: boolean;
canCreate: boolean;
canDelete: boolean;
};
}
export function BlocklistTable({ permissions }: BlocklistTableProps) {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [showCreateModal, setShowCreateModal] = useState(false);
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 handleDelete = (entry: BlocklistEntry) => {
setEntryToDelete(entry);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (entryToDelete) {
deleteWatchlistEntry.mutate({ id: entryToDelete.id });
}
};
const handleViewDetails = (entry: BlocklistEntry) => {
setSelectedEntry(entry);
setShowDetailsSheet(true);
};
const totalRowCount = data?.meta?.totalRowCount ?? 0;
const flatData = useMemo<BlocklistEntry[]>(() => data?.rows ?? [], [data]);
const columns = useMemo<ColumnDef<BlocklistEntry>[]>(
() => [
{
id: "value",
header: t("value"),
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.audits?.[0] as
| { changedByUserId: number | null }
| {
changedByUser?: { id: number; email: string; name: string | null } | undefined;
changedByUserId: number | null;
}
| undefined;
const email =
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
return <span className="text-default">{email ?? "—"}</span>;
},
},
{
id: "actions",
header: "",
size: 120,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
return (
<div className="flex items-center justify-end">
<ButtonGroup combined containerProps={{ className: "border-default" }}>
<Button
color="secondary"
variant="icon"
StartIcon="eye"
onClick={() => handleViewDetails(entry)}
tooltip={t("view")}
/>
{permissions?.canDelete && (
<Button
color="destructive"
variant="icon"
StartIcon="trash"
onClick={() => handleDelete(entry)}
tooltip={t("delete")}
/>
)}
</ButtonGroup>
</div>
);
},
},
],
[t, permissions?.canDelete]
);
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"
totalRowCount={totalRowCount}>
<div className="flex items-center justify-between">
<DataTableToolbar.SearchBar />
<div className="flex items-center gap-2">
{permissions?.canCreate && (
<Button color="primary" StartIcon="plus" onClick={() => setShowCreateModal(true)}>
{t("create_block_entry")}
</Button>
)}
</div>
</div>
</DataTableWrapper>
<CreateBlocklistEntryModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
<BlocklistEntryDetailsSheet
entry={selectedEntry}
isOpen={showDetailsSheet}
onClose={() => {
setShowDetailsSheet(false);
setSelectedEntry(null);
}}
/>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_blocklist_entry")}
confirmBtnText={t("delete")}
onConfirm={confirmDelete}>
{t("delete_blocklist_entry_confirmation", { value: entryToDelete?.value })}
</ConfirmationDialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,131 @@
"use client";
import { format } from "date-fns";
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
import { SkeletonText } from "@calcom/ui/components/skeleton";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlocklistEntryDetailsSheetProps {
entry: BlocklistEntry | null;
isOpen: boolean;
onClose: () => void;
}
export function BlocklistEntryDetailsSheet({ entry, isOpen, onClose }: BlocklistEntryDetailsSheetProps) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery(
{ id: entry?.id ?? "" },
{
enabled: !!entry?.id && isOpen,
}
);
const getActionVariant = (action: string) => {
switch (action) {
case "BLOCK":
return "red";
case "REPORT":
return "orange";
case "ALERT":
return "blue";
default:
return "gray";
}
};
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{t("blocklist_entry_details")}</SheetTitle>
</SheetHeader>
{isLoading ? (
<div className="mt-6 space-y-4">
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-10 w-full" />
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-10 w-full" />
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-20 w-full" />
</div>
) : data?.entry ? (
<div className="mt-6">
<div className="space-y-4">
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("type")}</label>
<Badge variant="blue" size="lg">
{data.entry.type === "EMAIL" ? t("email") : t("domain")}
</Badge>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("value")}</label>
<p className="text-emphasis text-base">{data.entry.value}</p>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("action")}</label>
<Badge variant={getActionVariant(data.entry.action)} size="lg">
{t(data.entry.action.toLowerCase())}
</Badge>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("description")}</label>
<p className="text-default">{data.entry.description || t("no_description_provided")}</p>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("created_by")}</label>
<p className="text-default">{data.auditHistory[0]?.changedByUser?.email || "—"}</p>
</div>
</div>
<div className="border-subtle my-6 border-t" />
<div>
<h3 className="text-emphasis mb-4 font-semibold">{t("audit_history")}</h3>
{data.auditHistory.length > 0 ? (
<div className="space-y-0">
{data.auditHistory.map((audit, index) => (
<div
key={audit.id}
className={`py-3 ${
index !== data.auditHistory.length - 1 ? "border-subtle border-b" : ""
}`}>
<div className="mb-1 flex items-start justify-between">
<div className="flex items-center gap-1.5">
<span className="text-muted text-xs">{t("by")}:</span>
<span className="text-default text-sm">{audit.changedByUser?.email || "—"}</span>
</div>
<span className="text-muted text-xs">
{format(new Date(audit.changedAt), "MMM d, h:mm a")}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted text-xs">{t("added")}:</span>
<span className="text-emphasis text-sm">{audit.value}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-muted text-sm">{t("no_audit_history")}</p>
)}
</div>
</div>
) : (
<div className="text-muted mt-8 text-center">{t("blocklist_entry_not_found")}</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,173 @@
"use client";
import { Controller, useForm } from "react-hook-form";
import { domainWithAtRegex, 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 { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Input, Select, Label, TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
interface CreateBlocklistEntryModalProps {
isOpen: boolean;
onClose: () => void;
}
interface FormData {
type: WatchlistType;
value: string;
description?: string;
}
export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const {
control,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
defaultValues: {
type: WatchlistType.EMAIL,
value: "",
description: "",
},
});
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();
reset();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const onSubmit = (data: FormData) => {
createWatchlistEntry.mutate({
type: data.type,
value: data.value,
description: data.description,
});
};
const validateValue = (value: string) => {
if (!value) return t("required");
if (watchType === WatchlistType.EMAIL) {
if (!emailRegex.test(value)) {
return t("invalid_email_address");
}
} else if (watchType === WatchlistType.DOMAIN) {
if (!domainWithAtRegex.test(value)) {
return t("invalid_domain_format");
}
}
return true;
};
const typeOptions = [
{ label: t("email"), value: WatchlistType.EMAIL },
{ label: t("domain"), value: WatchlistType.DOMAIN },
];
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent enableOverflow>
<DialogHeader title={t("create_block_entry")} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<Label htmlFor="type" className="text-emphasis mb-2 block text-sm font-medium">
{t("what_would_you_like_to_block")} <span className="text-destructive">*</span>
</Label>
<Controller
name="type"
control={control}
rules={{ required: t("field_required") }}
render={({ field }) => (
<Select
{...field}
options={typeOptions}
onChange={(option) => {
if (option) {
field.onChange(option.value);
setValue("value", "");
}
}}
value={typeOptions.find((opt) => opt.value === field.value)}
/>
)}
/>
{errors.type && <p className="text-destructive mt-1 text-sm">{errors.type.message}</p>}
</div>
<div>
<Label htmlFor="value" className="text-emphasis mb-2 block text-sm font-medium">
{watchType === WatchlistType.EMAIL ? t("email_address") : t("domain_name")}{" "}
<span className="text-destructive">*</span>
</Label>
<Controller
name="value"
control={control}
rules={{
required: t("field_required"),
validate: validateValue,
}}
render={({ field }) => (
<Input
{...field}
placeholder={watchType === WatchlistType.EMAIL ? "user@example.com" : "@spammer.com"}
/>
)}
/>
{errors.value && <p className="text-destructive mt-1 text-sm">{errors.value.message}</p>}
</div>
<div>
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
</Label>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextArea {...field} placeholder={t("reason_for_adding_to_blocklist")} rows={3} />
)}
/>
</div>
</div>
<DialogFooter className="mt-6">
<Button
type="button"
color="secondary"
onClick={onClose}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={isSubmitting || createWatchlistEntry.isPending}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("create_entry")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -793,6 +793,7 @@
"remove_attribute": "Remove attribute",
"remove_attribute_confirmation_message": "Are you sure you want to remove the attribute '{{name}}'? All users currently assigned to it will be unassigned.",
"delete": "Delete",
"view": "View",
"update": "Update",
"save": "Save",
"pending": "Pending",
@@ -2853,6 +2854,7 @@
"organization_price_per_user_month": "$37 per user per month",
"privacy_organization_description": "Manage privacy settings for your organization",
"privacy": "Privacy",
"privacy_and_security": "Privacy & Security",
"team_will_be_under_org": "New teams will be under your organization",
"add_group_name": "Add group name",
"group_name": "Group Name",
@@ -3545,6 +3547,7 @@
"pbac_resource_webhook": "Webhook",
"pbac_resource_availability": "Availability",
"pbac_resource_out_of_office": "Out of Office",
"pbac_resource_blocklist": "Blocklist",
"pbac_desc_create_webhooks": "Create webhooks",
"pbac_desc_view_webhooks": "View webhooks",
"pbac_desc_update_webhooks": "Update webhooks",
@@ -3811,5 +3814,26 @@
"edit_configuration": "Edit Configuration",
"select_event_type_for_inbound_calls": "Inbound calls can book only one event type. Select the event type where meetings will be scheduled when callers reach your agent.",
"setup_inbound_agent_for_incoming_calls": "Set up inbound agent for incoming calls",
"create_block_entry": "Create Block Entry",
"blocklist_entry_details": "Blocklist Entry Details",
"blocklist_entry_not_found": "Blocklist entry not found",
"blocklist_entry_created": "Blocklist entry created successfully",
"blocklist_entry_deleted": "Blocklist entry deleted successfully",
"audit_history": "Audit History",
"no_audit_history": "No audit history available",
"delete_blocklist_entry": "Delete Blocklist Entry",
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
"created_by": "Created By",
"blocked_by": "Blocked By",
"by": "By",
"added": "Added",
"no_description_provided": "No description provided",
"organization_blocklist": "Organization Blocklist",
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
"invalid_domain_format": "Invalid domain format. Example: @example.com",
"invalid_email_address": "Invalid email address. Example: user@example.com",
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
"what_would_you_like_to_block": "What would you like to block?",
"domain_name": "Domain Name",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,4 +1,6 @@
// eslint-disable-next-line no-restricted-imports
"use client";
import debounce from "lodash/debounce";
import { useState, useEffect } from "react";
@@ -38,7 +38,7 @@ function MembersList(props: MembersListProps) {
return (
<div className="flex flex-col gap-y-3">
{members?.length && team ? (
<ul className="divide-subtle border-subtle divide-y rounded-md border ">
<ul className="divide-subtle border-subtle divide-y rounded-md border">
{members.map((member) => {
return <MemberListItem key={member.id} member={member} />;
})}
@@ -95,7 +95,6 @@ const MembersView = () => {
const router = useRouter();
const params = useParamsWithFallback();
const teamId = Number(params.id);
const session = useSession();
const utils = trpc.useUtils();
// const [query, setQuery] = useState<string | undefined>("");
// const [queryToFetch, setQueryToFetch] = useState<string | undefined>("");
@@ -193,7 +192,7 @@ const MembersView = () => {
{team && (
<>
<hr className="border-subtle my-8" />
<hr className="border-subtle my-8 mt-6" />
<MakeTeamPrivateSwitch
teamId={team.id}
isPrivate={team.isPrivate}
@@ -1,10 +1,26 @@
"use client";
import { DataTableProvider } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import MakeTeamPrivateSwitch from "@calcom/features/ee/teams/components/MakeTeamPrivateSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
const PrivacyView = ({ permissions }: { permissions: { canRead: boolean; canEdit: boolean } }) => {
import { BlocklistTable } from "~/settings/organizations/privacy/blocklist-table";
const PrivacyView = ({
permissions,
watchlistPermissions,
}: {
permissions: { canRead: boolean; canEdit: boolean };
watchlistPermissions?: {
canRead: boolean;
canCreate: boolean;
canDelete: boolean;
};
}) => {
const { t } = useLocale();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery();
const isInviteOpen = !currentOrg?.user.accepted;
@@ -14,13 +30,27 @@ const PrivacyView = ({ permissions }: { permissions: { canRead: boolean; canEdit
return (
<LicenseRequired>
<div>
<div className="space-y-8">
<MakeTeamPrivateSwitch
isOrg={true}
teamId={currentOrg.id}
isPrivate={currentOrg.isPrivate}
disabled={isDisabled}
/>
{watchlistPermissions?.canRead && (
<div>
<div>
<h2 className="text-emphasis text-base font-semibold">{t("organization_blocklist")}</h2>
<p className="text-muted text-sm">{t("manage_blocked_emails_and_domains")}</p>
</div>
<div className="mt-2">
<DataTableProvider useSegments={useSegments} defaultPageSize={25}>
<BlocklistTable permissions={watchlistPermissions} />
</DataTableProvider>
</div>
</div>
)}
</div>
</LicenseRequired>
);
@@ -45,7 +45,6 @@ const MakeTeamPrivateSwitch = ({
setTeamPrivate(checked);
mutation.mutate({ id: teamId, isPrivate: checked });
}}
switchContainerClassName="mt-6"
data-testid="make-team-private-check"
/>
</>
@@ -159,7 +159,6 @@ const PrivacySettingsView = ({ team }: ProfileViewProps) => {
const isAdmin = team && checkAdminOrOwner(team.membership.role);
const isOrgAdminOrOwner = checkAdminOrOwner(session?.data?.user.org?.role);
const isInviteOpen = !team?.membership.accepted;
const { t } = useLocale();
return (
<>
@@ -173,12 +172,14 @@ const PrivacySettingsView = ({ team }: ProfileViewProps) => {
)}
{team && team.id && (isAdmin || isOrgAdminOrOwner) && (
<MakeTeamPrivateSwitch
isOrg={false}
teamId={team.id}
isPrivate={team.isPrivate ?? false}
disabled={isInviteOpen}
/>
<div className="mt-6">
<MakeTeamPrivateSwitch
isOrg={false}
teamId={team.id}
isPrivate={team.isPrivate ?? false}
disabled={isInviteOpen}
/>
</div>
)}
</div>
</>
@@ -12,6 +12,7 @@ export enum Resource {
Webhook = "webhook",
Availability = "availability",
OutOfOffice = "ooo",
Watchlist = "watchlist",
}
export enum CrudAction {
@@ -120,7 +121,7 @@ export const isValidPermissionString = (val: unknown): val is PermissionString =
* @returns A new object without the _resource property
*/
export const filterResourceConfig = (config: ResourceConfig): Omit<ResourceConfig, "_resource"> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _resource, ...rest } = config;
return rest;
};
@@ -147,7 +148,8 @@ export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): Permi
const scopeMatches = !permissionDetails.scope || permissionDetails.scope.includes(scope);
// Check privacy visibility (only if isPrivate is provided)
const privacyMatches = teamPrivacy === undefined ||
const privacyMatches =
teamPrivacy === undefined ||
!permissionDetails.visibleWhen?.teamPrivacy ||
permissionDetails.visibleWhen.teamPrivacy === "both" ||
permissionDetails.visibleWhen.teamPrivacy === teamPrivacy;
@@ -672,4 +674,40 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
dependsOn: ["ooo.read"],
},
},
[Resource.Watchlist]: {
_resource: {
i18nKey: "pbac_resource_blocklist",
},
[CrudAction.Create]: {
description: "Create watchlist entries",
category: "watchlist",
i18nKey: "pbac_action_create",
descriptionI18nKey: "pbac_desc_create_watchlist_entries",
scope: [Scope.Organization],
dependsOn: ["watchlist.read"],
},
[CrudAction.Read]: {
description: "View watchlist entries",
category: "watchlist",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_watchlist_entries",
scope: [Scope.Organization],
},
[CrudAction.Update]: {
description: "Update watchlist entries",
category: "watchlist",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_watchlist_entries",
scope: [Scope.Organization],
dependsOn: ["watchlist.read"],
},
[CrudAction.Delete]: {
description: "Delete watchlist entries",
category: "watchlist",
i18nKey: "pbac_action_delete",
descriptionI18nKey: "pbac_desc_delete_watchlist_entries",
scope: [Scope.Organization],
dependsOn: ["watchlist.read"],
},
},
};
@@ -1042,6 +1042,19 @@ export class UserRepository {
});
}
async findUsersByIds(userIds: number[]) {
return this.prismaClient.user.findMany({
where: {
id: { in: userIds },
},
select: {
id: true,
name: true,
email: true,
},
});
}
async findUsersWithLastBooking({ userIds, eventTypeId }: { userIds: number[]; eventTypeId: number }) {
return this.prismaClient.user.findMany({
where: {
+8
View File
@@ -4,6 +4,14 @@ import { z } from "zod";
export const emailRegex =
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-\.']*)[A-Z0-9_+'-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;
/**
* Domain regex for watchlist entries - requires @ prefix
* Supports international domains with Unicode characters
* Examples: @example.com, @münchen.de, @example.co.uk
*/
export const domainWithAtRegex =
/^@[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?(\.[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?)*$/;
/**
* RFC 5321 Section 4.5.3.1.3 specifies:
* - Maximum email address length: 254 characters
@@ -0,0 +1,67 @@
import type { WatchlistType, WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
export interface WatchlistEntry {
id: string;
type: WatchlistType;
value: string;
action: WatchlistAction;
description: string | null;
organizationId: number | null;
isGlobal: boolean;
source: WatchlistSource;
createdAt?: Date;
lastUpdatedAt?: Date;
}
export interface WatchlistAuditEntry {
id: string;
watchlistId: string;
type: WatchlistType;
value: string;
description: string | null;
action: WatchlistAction;
eventType?: string;
changedByUserId: number | null;
changedAt: Date;
}
export interface CreateWatchlistInput {
type: WatchlistType;
value: string;
organizationId: number;
action: WatchlistAction;
description?: string;
userId: number;
}
export interface CheckWatchlistInput {
type: WatchlistType;
value: string;
organizationId: number;
}
export interface FindAllEntriesInput {
organizationId: number;
limit: number;
offset: number;
searchTerm?: string;
filters?: {
type?: WatchlistType;
};
}
export interface IWatchlistRepository {
createEntry(params: CreateWatchlistInput): Promise<WatchlistEntry>;
checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null>;
findAllEntries(params: FindAllEntriesInput): Promise<{
rows: (WatchlistEntry & {
audits?: { changedByUserId: number | null }[];
})[];
meta: { totalRowCount: number };
}>;
findEntryWithAudit(id: string): Promise<{
entry: WatchlistEntry | null;
auditHistory: WatchlistAuditEntry[];
}>;
deleteEntry(id: string, userId: number): Promise<void>;
}
@@ -0,0 +1,211 @@
import type { PrismaClient } from "@calcom/prisma";
import { WatchlistSource } from "@calcom/prisma/enums";
import type {
IWatchlistRepository,
CreateWatchlistInput,
CheckWatchlistInput,
WatchlistEntry,
FindAllEntriesInput,
WatchlistAuditEntry,
} from "./watchlist.interface";
export class WatchlistRepository implements IWatchlistRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async createEntry(params: CreateWatchlistInput): Promise<WatchlistEntry> {
const existing = await this.checkExists({
type: params.type,
value: params.value,
organizationId: params.organizationId,
});
if (existing) {
throw new Error("Watchlist entry already exists for this organization");
}
const watchlist = await this.prismaClient.$transaction(async (tx) => {
const created = await tx.watchlist.create({
data: {
type: params.type,
value: params.value,
organizationId: params.organizationId,
action: params.action,
description: params.description,
source: WatchlistSource.MANUAL,
isGlobal: false,
},
});
await tx.watchlistAudit.create({
data: {
watchlistId: created.id,
type: params.type,
value: params.value,
description: params.description,
action: params.action,
changedByUserId: params.userId,
},
});
return created;
});
return watchlist;
}
async checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null> {
const entry = await this.prismaClient.watchlist.findUnique({
where: {
type_value_organizationId: {
type: params.type,
value: params.value,
organizationId: params.organizationId,
},
},
});
return entry;
}
async findAllEntries(params: FindAllEntriesInput): Promise<{
rows: (WatchlistEntry & { audits?: { changedByUserId: number | null }[] })[];
meta: { totalRowCount: number };
}> {
const where = {
organizationId: params.organizationId,
...(params.searchTerm && {
value: {
contains: params.searchTerm,
mode: "insensitive" as const,
},
}),
...(params.filters?.type && {
type: params.filters.type,
}),
};
const [rows, 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 }),
]);
return {
rows,
meta: { totalRowCount },
};
}
async findEntryWithAudit(id: string): Promise<{
entry: WatchlistEntry | null;
auditHistory: WatchlistAuditEntry[];
}> {
const entry = await this.prismaClient.watchlist.findUnique({
where: { id },
select: {
id: true,
type: true,
value: true,
action: true,
description: true,
organizationId: true,
isGlobal: true,
source: true,
lastUpdatedAt: true,
audits: {
select: {
id: true,
watchlistId: true,
type: true,
value: true,
description: true,
action: true,
changedByUserId: true,
changedAt: true,
},
orderBy: {
changedAt: "desc",
},
},
},
});
return {
entry: entry
? {
id: entry.id,
type: entry.type,
value: entry.value,
action: entry.action,
description: entry.description ?? null,
organizationId: entry.organizationId ?? null,
isGlobal: entry.isGlobal,
source: entry.source,
lastUpdatedAt: entry.lastUpdatedAt,
}
: null,
auditHistory: entry?.audits || [],
};
}
async deleteEntry(id: string, userId: number): Promise<void> {
const existing = await this.prismaClient.watchlist.findUnique({
where: { id },
select: {
id: true,
type: true,
value: true,
description: true,
action: true,
},
});
if (!existing) {
throw new Error("Watchlist entry not found");
}
await this.prismaClient.$transaction(async (tx) => {
await tx.watchlistAudit.create({
data: {
watchlistId: id,
type: existing.type,
value: existing.value,
description: existing.description,
action: existing.action,
changedByUserId: userId,
},
});
await tx.watchlist.delete({
where: { id },
});
});
}
}
@@ -0,0 +1,2 @@
-- AddForeignKey
ALTER TABLE "WatchlistAudit" ADD CONSTRAINT "WatchlistAudit_watchlistId_fkey" FOREIGN KEY ("watchlistId") REFERENCES "Watchlist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3 -1
View File
@@ -2321,6 +2321,7 @@ model Watchlist {
lastUpdatedAt DateTime @default(now())
bookingReports BookingReport[]
audits WatchlistAudit[]
@@unique([type, value, organizationId])
@@index([type, value, organizationId, action])
@@ -2337,7 +2338,8 @@ model WatchlistAudit {
changedAt DateTime @default(now())
changedByUserId Int?
watchlistId String @db.Uuid
watchlistId String @db.Uuid
watchlist Watchlist? @relation(fields: [watchlistId], references: [id], onDelete: Cascade)
@@index([watchlistId, changedAt])
}
@@ -17,14 +17,18 @@ import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateSelfHostedInputSchema } from "./createSelfHosted.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZCreateWatchlistEntryInputSchema } from "./createWatchlistEntry.schema";
import { ZCreateWithPaymentIntentInputSchema } from "./createWithPaymentIntent.schema";
import { ZDeleteTeamInputSchema } from "./deleteTeam.schema";
import { ZDeleteWatchlistEntryInputSchema } from "./deleteWatchlistEntry.schema";
import { ZGetMembersInput } from "./getMembers.schema";
import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
import { ZGetUserInput } from "./getUser.schema";
import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema";
import { ZIntentToCreateOrgInputSchema } from "./intentToCreateOrg.schema";
import { ZListMembersInputSchema } from "./listMembers.schema";
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
@@ -163,4 +167,29 @@ export const viewerOrganizationsRouter = router({
const { default: handler } = await import("./createSelfHosted.handler");
return handler(opts);
}),
listWatchlistEntries: authedOrgAdminProcedure
.input(ZListWatchlistEntriesInputSchema)
.query(async (opts) => {
const { default: handler } = await import("./listWatchlistEntries.handler");
return handler(opts);
}),
createWatchlistEntry: authedOrgAdminProcedure
.input(ZCreateWatchlistEntryInputSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./createWatchlistEntry.handler");
return handler(opts);
}),
deleteWatchlistEntry: authedOrgAdminProcedure
.input(ZDeleteWatchlistEntryInputSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./deleteWatchlistEntry.handler");
return handler(opts);
}),
getWatchlistEntryDetails: authedOrgAdminProcedure
.input(ZGetWatchlistEntryDetailsInputSchema)
.query(async (opts) => {
const { default: handler } = await import("./getWatchlistEntryDetails.handler");
return handler(opts);
}),
});
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { WatchlistType, WatchlistAction, MembershipRole } from "@calcom/prisma/enums";
import { createWatchlistEntryHandler } from "./createWatchlistEntry.handler";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/lib/server/repository/watchlist.repository");
describe("createWatchlistEntryHandler", () => {
const mockUser = {
id: 1,
email: "admin@example.com",
organizationId: 100,
profile: null,
};
const mockPermissionCheckService = {
checkPermission: vi.fn(),
};
const mockWatchlistRepo = {
createEntry: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
});
describe("access control", () => {
it("should throw FORBIDDEN when user is not part of an organization", async () => {
await expect(
createWatchlistEntryHandler({
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
input: {
type: WatchlistType.EMAIL,
value: "spam@example.com",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You must be part of an organization to manage blocklist",
});
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
});
it("should throw UNAUTHORIZED when user lacks permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
await expect(
createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "spam@example.com",
},
})
).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "You are not authorized to create blocklist entries",
});
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
});
it("should check permission with correct parameters", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "test@example.com",
},
});
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 100,
permission: "watchlist.create",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
});
});
describe("validation", () => {
beforeEach(() => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should throw BAD_REQUEST for invalid email format", async () => {
await expect(
createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "invalid-email",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Invalid email address format",
});
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
});
it("should throw BAD_REQUEST for email missing @ symbol", async () => {
await expect(
createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "notanemail.com",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Invalid email address format",
});
});
it("should throw BAD_REQUEST for invalid domain format without @", async () => {
await expect(
createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.DOMAIN,
value: "example.com",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Invalid domain format. Domain must start with @ (e.g., @example.com)",
});
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
});
it("should throw BAD_REQUEST for domain with invalid format", async () => {
await expect(
createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.DOMAIN,
value: "not-a-domain",
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Invalid domain format. Domain must start with @ (e.g., @example.com)",
});
});
it("should accept valid email format", async () => {
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
const result = await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "valid@example.com",
},
});
expect(result.success).toBe(true);
expect(mockWatchlistRepo.createEntry).toHaveBeenCalled();
});
it("should accept valid domain format with @", async () => {
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
const result = await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.DOMAIN,
value: "@example.com",
},
});
expect(result.success).toBe(true);
expect(mockWatchlistRepo.createEntry).toHaveBeenCalled();
});
});
describe("successful creation", () => {
beforeEach(() => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should create watchlist entry for EMAIL type", async () => {
const mockEntry = {
id: "watchlist-1",
type: WatchlistType.EMAIL,
value: "spam@example.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
};
mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry);
const result = await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "spam@example.com",
},
});
expect(result.success).toBe(true);
expect(result.entry).toEqual(mockEntry);
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
type: WatchlistType.EMAIL,
value: "spam@example.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
description: undefined,
userId: 1,
});
});
it("should create watchlist entry for DOMAIN type", async () => {
const mockEntry = {
id: "watchlist-2",
type: WatchlistType.DOMAIN,
value: "@spammer.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
};
mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry);
const result = await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.DOMAIN,
value: "@spammer.com",
},
});
expect(result.success).toBe(true);
expect(result.entry).toEqual(mockEntry);
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
type: WatchlistType.DOMAIN,
value: "@spammer.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
description: undefined,
userId: 1,
});
});
it("should convert value to lowercase", async () => {
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-4" });
await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "SPAM@EXAMPLE.COM",
},
});
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
type: WatchlistType.EMAIL,
value: "spam@example.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
description: undefined,
userId: 1,
});
});
it("should always set action to BLOCK", async () => {
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-5" });
await createWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
type: WatchlistType.EMAIL,
value: "test@example.com",
},
});
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith(
expect.objectContaining({
action: WatchlistAction.BLOCK,
})
);
});
});
});
@@ -0,0 +1,86 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { domainWithAtRegex, emailRegex } from "@calcom/lib/emailSchema";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { prisma } from "@calcom/prisma";
import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TCreateWatchlistEntryInputSchema } from "./createWatchlistEntry.schema";
type CreateWatchlistEntryOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateWatchlistEntryInputSchema;
};
export const createWatchlistEntryHandler = async ({ ctx, input }: CreateWatchlistEntryOptions) => {
const { user } = ctx;
const organizationId = user.profile?.organizationId || user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You must be part of an organization to manage blocklist",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: organizationId,
permission: "watchlist.create",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to create blocklist entries",
});
}
const watchlistRepo = new WatchlistRepository(prisma);
if (input.type === "EMAIL" && !emailRegex.test(input.value)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid email address format",
});
}
if (input.type === "DOMAIN" && !domainWithAtRegex.test(input.value)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid domain format. Domain must start with @ (e.g., @example.com)",
});
}
try {
const entry = await watchlistRepo.createEntry({
type: input.type,
value: input.value.toLowerCase(),
organizationId,
action: WatchlistAction.BLOCK,
description: input.description,
userId: user.id,
});
return {
success: true,
entry,
};
} catch (error) {
if (error instanceof Error && error.message.includes("already exists")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This entry already exists in the blocklist for your organization",
});
}
throw error;
}
};
export default createWatchlistEntryHandler;
@@ -0,0 +1,11 @@
import { z } from "zod";
import { WatchlistType } from "@calcom/prisma/enums";
export const ZCreateWatchlistEntryInputSchema = z.object({
type: z.nativeEnum(WatchlistType),
value: z.string().min(1).max(255),
description: z.string().optional(),
});
export type TCreateWatchlistEntryInputSchema = z.infer<typeof ZCreateWatchlistEntryInputSchema>;
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { MembershipRole, WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
import { deleteWatchlistEntryHandler } from "./deleteWatchlistEntry.handler";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/lib/server/repository/watchlist.repository");
describe("deleteWatchlistEntryHandler", () => {
const mockUser = {
id: 1,
email: "admin@example.com",
organizationId: 100,
profile: null,
};
const mockEntry = {
id: "entry-123",
type: WatchlistType.EMAIL,
value: "spam@example.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
description: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockPermissionCheckService = {
checkPermission: vi.fn(),
};
const mockWatchlistRepo = {
findEntryWithAudit: vi.fn(),
deleteEntry: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
});
describe("access control", () => {
it("should throw FORBIDDEN when user is not part of an organization", async () => {
await expect(
deleteWatchlistEntryHandler({
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
input: {
id: "entry-123",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You must be part of an organization to manage blocklist",
});
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
});
it("should throw UNAUTHORIZED when user lacks permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
await expect(
deleteWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
})
).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "You are not authorized to delete blocklist entries",
});
expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled();
});
it("should check permission with correct parameters", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
await deleteWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 100,
permission: "watchlist.delete",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
});
});
describe("validation", () => {
beforeEach(() => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should throw NOT_FOUND when entry does not exist", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: null,
auditHistory: [],
});
await expect(
deleteWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
id: "non-existent-id",
},
})
).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Blocklist entry not found",
});
expect(mockWatchlistRepo.deleteEntry).not.toHaveBeenCalled();
});
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: { ...mockEntry, organizationId: 999 },
auditHistory: [],
});
await expect(
deleteWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You can only delete blocklist entries from your organization",
});
expect(mockWatchlistRepo.deleteEntry).not.toHaveBeenCalled();
});
});
describe("successful deletion", () => {
beforeEach(() => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should successfully delete entry when authorized", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
const result = await deleteWatchlistEntryHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(result.success).toBe(true);
expect(result.message).toBe("Blocklist entry deleted successfully");
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 1);
});
it("should pass correct userId to deleteEntry", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
await deleteWatchlistEntryHandler({
ctx: { user: { ...mockUser, id: 42 } },
input: {
id: "entry-123",
},
});
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
});
});
});
@@ -0,0 +1,77 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TDeleteWatchlistEntryInputSchema } from "./deleteWatchlistEntry.schema";
type DeleteWatchlistEntryOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TDeleteWatchlistEntryInputSchema;
};
export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlistEntryOptions) => {
const { user } = ctx;
const organizationId = user.profile?.organizationId || user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You must be part of an organization to manage blocklist",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: organizationId,
permission: "watchlist.delete",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete blocklist entries",
});
}
const watchlistRepo = new WatchlistRepository(prisma);
const { entry } = await watchlistRepo.findEntryWithAudit(input.id);
if (!entry) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Blocklist entry not found",
});
}
if (entry.organizationId !== organizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only delete blocklist entries from your organization",
});
}
try {
await watchlistRepo.deleteEntry(input.id, user.id);
return {
success: true,
message: "Blocklist entry deleted successfully",
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete blocklist entry",
});
}
};
export default deleteWatchlistEntryHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZDeleteWatchlistEntryInputSchema = z.object({
id: z.string().uuid(),
});
export type TDeleteWatchlistEntryInputSchema = z.infer<typeof ZDeleteWatchlistEntryInputSchema>;
@@ -0,0 +1,357 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { MembershipRole, WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
import { getWatchlistEntryDetailsHandler } from "./getWatchlistEntryDetails.handler";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/users/repositories/UserRepository");
vi.mock("@calcom/lib/server/repository/watchlist.repository");
describe("getWatchlistEntryDetailsHandler", () => {
const mockUser = {
id: 1,
email: "admin@example.com",
organizationId: 100,
profile: null,
};
const mockEntry = {
id: "entry-123",
type: WatchlistType.EMAIL,
value: "spam@example.com",
organizationId: 100,
action: WatchlistAction.BLOCK,
description: "Known spammer",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-02"),
};
const mockAuditHistory = [
{
id: "audit-1",
watchlistId: "entry-123",
action: "CREATED" as const,
changedByUserId: 1,
timestamp: new Date("2025-01-01"),
previousValue: null,
newValue: "spam@example.com",
},
{
id: "audit-2",
watchlistId: "entry-123",
action: "UPDATED" as const,
changedByUserId: 2,
timestamp: new Date("2025-01-02"),
previousValue: null,
newValue: "Known spammer",
},
];
const mockUsers = [
{ id: 1, name: "Admin", email: "admin@example.com" },
{ id: 2, name: "Editor", email: "editor@example.com" },
];
const mockPermissionCheckService = {
checkPermission: vi.fn(),
};
const mockWatchlistRepo = {
findEntryWithAudit: vi.fn(),
};
const mockUserRepo = {
findUsersByIds: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
});
describe("access control", () => {
it("should throw FORBIDDEN when user is not part of an organization", async () => {
await expect(
getWatchlistEntryDetailsHandler({
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
input: {
id: "entry-123",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You must be part of an organization to view blocklist",
});
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
});
it("should use profile.organizationId when available", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: { ...mockEntry, organizationId: 200 },
auditHistory: [],
});
await getWatchlistEntryDetailsHandler({
ctx: {
user: {
...mockUser,
organizationId: undefined,
profile: { organizationId: 200 },
},
},
input: {
id: "entry-123",
},
});
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 200,
permission: "watchlist.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
});
it("should throw UNAUTHORIZED when user lacks permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
await expect(
getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
})
).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "You are not authorized to view blocklist entries",
});
expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled();
});
it("should check permission with correct parameters", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 100,
permission: "watchlist.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
});
});
describe("validation", () => {
beforeEach(() => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should throw NOT_FOUND when entry does not exist", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: null,
auditHistory: [],
});
await expect(
getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "non-existent-id",
},
})
).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Blocklist entry not found",
});
});
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: { ...mockEntry, organizationId: 999 },
auditHistory: [],
});
await expect(
getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You can only view blocklist entries from your organization",
});
});
});
describe("successful retrieval", () => {
beforeEach(() => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should return entry and audit history when authorized", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: mockAuditHistory,
});
mockUserRepo.findUsersByIds.mockResolvedValue(mockUsers);
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(result.entry).toEqual(mockEntry);
expect(result.auditHistory).toHaveLength(2);
expect(mockWatchlistRepo.findEntryWithAudit).toHaveBeenCalledWith("entry-123");
});
it("should enrich audit history with user details", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: mockAuditHistory,
});
mockUserRepo.findUsersByIds.mockResolvedValue(mockUsers);
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(result.auditHistory[0].changedByUser).toEqual(mockUsers[0]);
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([1, 2]);
});
it("should handle audit history with duplicate user IDs", async () => {
const auditWithDuplicates = [
{ ...mockAuditHistory[0], changedByUserId: 1 },
{ ...mockAuditHistory[1], changedByUserId: 1 },
];
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: auditWithDuplicates,
});
mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[0]]);
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([1]);
expect(result.auditHistory[0].changedByUser).toEqual(mockUsers[0]);
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[0]);
});
it("should handle audit history with null user IDs", async () => {
const auditWithNulls = [
{ ...mockAuditHistory[0], changedByUserId: null },
{ ...mockAuditHistory[1], changedByUserId: 2 },
];
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: auditWithNulls,
});
mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[1]]);
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([2]);
expect(result.auditHistory[0].changedByUser).toBeUndefined();
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
});
it("should handle audit history with undefined user IDs", async () => {
const auditWithUndefined = [
{ ...mockAuditHistory[0], changedByUserId: undefined },
{ ...mockAuditHistory[1], changedByUserId: 2 },
];
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: auditWithUndefined,
});
mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[1]]);
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([2]);
expect(result.auditHistory[0].changedByUser).toBeUndefined();
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
});
it("should not call findUsersByIds when audit history is empty", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(result.auditHistory).toHaveLength(0);
expect(mockUserRepo.findUsersByIds).not.toHaveBeenCalled();
});
it("should handle when user is not found in user map", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
entry: mockEntry,
auditHistory: [mockAuditHistory[0]],
});
mockUserRepo.findUsersByIds.mockResolvedValue([]);
const result = await getWatchlistEntryDetailsHandler({
ctx: { user: mockUser },
input: {
id: "entry-123",
},
});
expect(result.auditHistory[0].changedByUser).toBeUndefined();
});
});
});
@@ -0,0 +1,85 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema";
type GetWatchlistEntryDetailsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetWatchlistEntryDetailsInputSchema;
};
export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchlistEntryDetailsOptions) => {
const { user } = ctx;
const organizationId = user.profile?.organizationId || user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You must be part of an organization to view blocklist",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: organizationId,
permission: "watchlist.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to view blocklist entries",
});
}
const watchlistRepo = new WatchlistRepository(prisma);
const userRepo = new UserRepository(prisma);
const result = await watchlistRepo.findEntryWithAudit(input.id);
if (!result.entry) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Blocklist entry not found",
});
}
if (result.entry.organizationId !== organizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view blocklist entries from your organization",
});
}
const userIds = result.auditHistory
.map((audit) => audit.changedByUserId)
.filter((id): id is number => id !== null && id !== undefined);
const uniqueUserIds = Array.from(new Set(userIds));
const users = uniqueUserIds.length > 0 ? await userRepo.findUsersByIds(uniqueUserIds) : [];
const userMap = new Map(users.map((u) => [u.id, u]));
const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
...audit,
changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
}));
return {
entry: result.entry,
auditHistory: auditHistoryWithUsers,
};
};
export default getWatchlistEntryDetailsHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetWatchlistEntryDetailsInputSchema = z.object({
id: z.string().uuid(),
});
export type TGetWatchlistEntryDetailsInputSchema = z.infer<typeof ZGetWatchlistEntryDetailsInputSchema>;
@@ -0,0 +1,89 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
type ListWatchlistEntriesOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TListWatchlistEntriesInputSchema;
};
export const listWatchlistEntriesHandler = async ({ ctx, input }: ListWatchlistEntriesOptions) => {
const { user } = ctx;
const organizationId = user.profile?.organizationId || user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You must be part of an organization to manage blocklist",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: organizationId,
permission: "watchlist.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to view blocklist entries",
});
}
const watchlistRepo = new WatchlistRepository(prisma);
const userRepo = new UserRepository(prisma);
const result = await watchlistRepo.findAllEntries({
organizationId,
limit: input.limit,
offset: input.offset,
searchTerm: input.searchTerm,
filters: input.filters,
});
const userIds = result.rows
.map((entry) => entry.audits?.[0]?.changedByUserId)
.filter((id): id is number => id !== null && id !== undefined);
const uniqueUserIds = Array.from(new Set(userIds));
const users = uniqueUserIds.length > 0 ? await userRepo.findUsersByIds(uniqueUserIds) : [];
const userMap = new Map(users.map((u) => [u.id, u]));
const rowsWithCreators = result.rows.map((entry) => {
const audit = entry.audits?.[0];
if (audit?.changedByUserId) {
const changedByUser = userMap.get(audit.changedByUserId);
return {
...entry,
audits: [
{
...audit,
changedByUser,
},
],
};
}
return entry;
});
return {
rows: rowsWithCreators,
meta: result.meta,
};
};
export default listWatchlistEntriesHandler;
@@ -0,0 +1,16 @@
import { z } from "zod";
import { WatchlistType } from "@calcom/prisma/enums";
export const ZListWatchlistEntriesInputSchema = z.object({
limit: z.number().min(1).max(100).default(25),
offset: z.number().min(0).default(0),
searchTerm: z.string().optional(),
filters: z
.object({
type: z.nativeEnum(WatchlistType).optional(),
})
.optional(),
});
export type TListWatchlistEntriesInputSchema = z.infer<typeof ZListWatchlistEntriesInputSchema>;