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:
co-authored by
CarinaWolli
parent
17ab088af8
commit
5a59bb86cd
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
+296
@@ -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>;
|
||||
+194
@@ -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>;
|
||||
+357
@@ -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>;
|
||||
Reference in New Issue
Block a user