Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf4ebd544 | ||
|
|
805c4d4c09 | ||
|
|
99330b3117 | ||
|
|
cc90a62090 | ||
|
|
79bcd6dc30 | ||
|
|
c6daa61e5e | ||
|
|
b693b6898a | ||
|
|
fb55bc2dcd | ||
|
|
71bd140fe4 | ||
|
|
4c5608147c | ||
|
|
7d16da5c98 | ||
|
|
0ae6e92558 | ||
|
|
052a38e39b |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@calcom/atoms": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||||
Vendored
@@ -13,6 +13,7 @@ import { fetchUsername } from "@calcom/lib/fetchUsername";
|
|||||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||||
@@ -81,7 +82,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use the current username or if it's not set, use the one available from stripe
|
// Use the current username or if it's not set, use the one available from stripe
|
||||||
setInputUsernameValue(currentUsername || stripeCustomer?.username || "");
|
setInputUsernameValue(slugify(currentUsername || stripeCustomer?.username || "", true));
|
||||||
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
|
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,7 +106,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
const updateUsername = trpc.viewer.me.updateProfile.useMutation({
|
const updateUsername = trpc.viewer.me.updateProfile.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
onSuccessMutation && (await onSuccessMutation());
|
onSuccessMutation && (await onSuccessMutation());
|
||||||
await update({ username: inputUsernameValue });
|
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||||
|
await update({ username: sanitizedUsername });
|
||||||
setOpenDialogSaveUsername(false);
|
setOpenDialogSaveUsername(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -171,11 +173,12 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveUsername = () => {
|
const saveUsername = () => {
|
||||||
|
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||||
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
||||||
updateUsername.mutate({
|
updateUsername.mutate({
|
||||||
username: inputUsernameValue,
|
username: sanitizedUsername,
|
||||||
});
|
});
|
||||||
setCurrentUsername(inputUsernameValue);
|
setCurrentUsername(sanitizedUsername);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,7 +237,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
if (searchParams?.toString() !== _searchParams.toString()) {
|
if (searchParams?.toString() !== _searchParams.toString()) {
|
||||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||||
}
|
}
|
||||||
setInputUsernameValue(event.target.value);
|
const sanitized = slugify(event.target.value, true);
|
||||||
|
setInputUsernameValue(sanitized);
|
||||||
}}
|
}}
|
||||||
data-testid="username-input"
|
data-testid="username-input"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/
|
|||||||
import { TextField } from "@calcom/ui/components/form";
|
import { TextField } from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
|
|
||||||
import type { TRPCClientErrorLike } from "@trpc/client";
|
import type { TRPCClientErrorLike } from "@trpc/client";
|
||||||
|
|
||||||
@@ -72,10 +73,11 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
|
|
||||||
const updateUsernameMutation = trpc.viewer.me.updateProfile.useMutation({
|
const updateUsernameMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||||
onSuccessMutation && (await onSuccessMutation());
|
onSuccessMutation && (await onSuccessMutation());
|
||||||
setOpenDialogSaveUsername(false);
|
setOpenDialogSaveUsername(false);
|
||||||
setCurrentUsername(inputUsernameValue);
|
setCurrentUsername(sanitizedUsername);
|
||||||
await update({ username: inputUsernameValue });
|
await update({ username: sanitizedUsername });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
onErrorMutation && onErrorMutation(error);
|
onErrorMutation && onErrorMutation(error);
|
||||||
@@ -108,8 +110,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateUsername = async () => {
|
const updateUsername = async () => {
|
||||||
|
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||||
updateUsernameMutation.mutate({
|
updateUsernameMutation.mutate({
|
||||||
username: inputUsernameValue,
|
username: sanitizedUsername,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +137,8 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
)}
|
)}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setInputUsernameValue(event.target.value);
|
const sanitized = slugify(event.target.value, true);
|
||||||
|
setInputUsernameValue(sanitized);
|
||||||
}}
|
}}
|
||||||
data-testid="username-input"
|
data-testid="username-input"
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
|
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const UsernameAvailabilityField = ({
|
|||||||
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
||||||
const formMethods = useForm({
|
const formMethods = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: currentUsername,
|
username: slugify(currentUsername || user.username || ""),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +79,11 @@ export const UsernameAvailabilityField = ({
|
|||||||
setCurrentUsername={setCurrentUsername}
|
setCurrentUsername={setCurrentUsername}
|
||||||
inputUsernameValue={value}
|
inputUsernameValue={value}
|
||||||
usernameRef={ref}
|
usernameRef={ref}
|
||||||
setInputUsernameValue={onChange}
|
setInputUsernameValue={(val) => {
|
||||||
|
const displayValue = slugify(val, true);
|
||||||
|
formMethods.setValue("username", displayValue);
|
||||||
|
onChange?.(displayValue);
|
||||||
|
}}
|
||||||
onSuccessMutation={onSuccessMutation}
|
onSuccessMutation={onSuccessMutation}
|
||||||
onErrorMutation={onErrorMutation}
|
onErrorMutation={onErrorMutation}
|
||||||
disabled={!!user.organization?.id}
|
disabled={!!user.organization?.id}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function useCreateTeam() {
|
|||||||
if (result.team) {
|
if (result.team) {
|
||||||
// Not sure we need this flag check - keeping it here for safe keeping as this is called only from v3 onboarding flow
|
// Not sure we need this flag check - keeping it here for safe keeping as this is called only from v3 onboarding flow
|
||||||
const gettingStartedPath = flags["onboarding-v3"]
|
const gettingStartedPath = flags["onboarding-v3"]
|
||||||
? "/onboarding/getting-started"
|
? "/onboarding/personal/settings"
|
||||||
: "/getting-started";
|
: "/getting-started";
|
||||||
router.push(gettingStartedPath);
|
router.push(gettingStartedPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
|
||||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -25,14 +26,8 @@ export const useSubmitOnboarding = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
|
||||||
selectedPlan,
|
store;
|
||||||
organizationDetails,
|
|
||||||
organizationBrand,
|
|
||||||
teams,
|
|
||||||
inviteRole,
|
|
||||||
resetOnboarding,
|
|
||||||
} = store;
|
|
||||||
|
|
||||||
if (selectedPlan !== "organization") {
|
if (selectedPlan !== "organization") {
|
||||||
throw new Error("Only organization plan is currently supported");
|
throw new Error("Only organization plan is currently supported");
|
||||||
@@ -83,7 +78,8 @@ export const useSubmitOnboarding = () => {
|
|||||||
// No checkout URL means billing is disabled (self-hosted flow)
|
// No checkout URL means billing is disabled (self-hosted flow)
|
||||||
// Organization has already been created by the backend
|
// Organization has already been created by the backend
|
||||||
showToast("Organization created successfully!", "success");
|
showToast("Organization created successfully!", "success");
|
||||||
// TODO: after this redirect we need to hard refresh the page to see org
|
// Set flag to show welcome modal after personal onboarding redirect
|
||||||
|
setShowNewOrgModalFlag();
|
||||||
skipToPersonal(resetOnboarding);
|
skipToPersonal(resetOnboarding);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
||||||
|
|||||||
@@ -188,14 +188,22 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
|||||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
{/* Email and Team inputs */}
|
{/* Email and Team inputs */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2">
|
||||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
<Label
|
||||||
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
|
className="text-emphasis mb-0 text-sm font-medium"
|
||||||
|
htmlFor="invites.0.email">
|
||||||
|
{t("email")}
|
||||||
|
</Label>
|
||||||
|
<Label
|
||||||
|
className="text-emphasis mb-0 text-sm font-medium"
|
||||||
|
htmlFor="invites.0.team">
|
||||||
|
{t("team")}
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div key={field.id} className="flex items-start gap-0.5 pt-2 md:pt-0">
|
<div key={field.id} className="flex items-start gap-0.5">
|
||||||
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
|
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
|
||||||
<TextField
|
<TextField
|
||||||
labelSrOnly
|
labelSrOnly
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
|||||||
form.setValue("name", e.target.value);
|
form.setValue("name", e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.name && (
|
{form.formState.errors.name && (
|
||||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||||
@@ -128,7 +127,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
|||||||
<TimezoneSelect
|
<TimezoneSelect
|
||||||
value={selectedTimeZone}
|
value={selectedTimeZone}
|
||||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-subtle text-xs font-normal leading-3">
|
<p className="text-subtle text-xs font-normal leading-3">
|
||||||
{t("current_time")}{" "}
|
{t("current_time")}{" "}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
|
||||||
|
import { MailIcon } from "./MailIcon";
|
||||||
|
|
||||||
|
type CompanyEmailOrganizationBannerProps = {
|
||||||
|
onDismissAction: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CompanyEmailOrganizationBanner = ({ onDismissAction }: CompanyEmailOrganizationBannerProps) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const flags = useFlagMap();
|
||||||
|
|
||||||
|
const handleLearnMore = () => {
|
||||||
|
const redirectPath = flags["onboarding-v3"]
|
||||||
|
? "/onboarding/organization/details"
|
||||||
|
: "/settings/organizations/new";
|
||||||
|
router.push(redirectPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!flags["onboarding-v3"]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-subtle from-muted relative mb-6 overflow-hidden rounded-lg border bg-gradient-to-r to-transparent p-4">
|
||||||
|
{/* Dot grid background with fade */}
|
||||||
|
<svg className="pointer-events-none absolute inset-0 h-full w-full" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
{/* Dot pattern - 12px spacing */}
|
||||||
|
<pattern id="banner-dot-pattern" x="0" y="0" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="1" cy="1" r="0.8" fill="currentColor" className="text-subtle" opacity="0.3" />
|
||||||
|
</pattern>
|
||||||
|
{/* Fade mask from left to right */}
|
||||||
|
<linearGradient id="banner-fade-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||||
|
<stop offset="10%" stopColor="white" stopOpacity="0.4" />
|
||||||
|
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="banner-fade-mask">
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="url(#banner-fade-gradient)" />
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
{/* Apply pattern with mask */}
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
fill="url(#banner-dot-pattern)"
|
||||||
|
mask="url(#banner-fade-mask)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex gap-4">
|
||||||
|
<div>
|
||||||
|
<MailIcon />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3 className="text-default text-sm font-semibold">
|
||||||
|
{t("it_appears_you_are_using_company_email")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-default text-sm">{t("explore_organizational_plan_description")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Button color="secondary" onClick={onDismissAction}>
|
||||||
|
{t("dismiss")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" EndIcon="external-link" onClick={handleLearnMore}>
|
||||||
|
{t("upgrade")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||||
|
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo";
|
import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo";
|
||||||
@@ -46,6 +47,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
|||||||
|
|
||||||
import type { TRPCClientErrorLike } from "@trpc/client";
|
import type { TRPCClientErrorLike } from "@trpc/client";
|
||||||
|
|
||||||
|
import { CompanyEmailOrganizationBanner } from "./components/CompanyEmailOrganizationBanner";
|
||||||
|
|
||||||
interface DeleteAccountValues {
|
interface DeleteAccountValues {
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
}
|
}
|
||||||
@@ -72,7 +75,8 @@ type Props = {
|
|||||||
const ProfileView = ({ user }: Props) => {
|
const ProfileView = ({ user }: Props) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { update } = useSession();
|
const session = useSession();
|
||||||
|
const { update } = session;
|
||||||
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
|
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
await update(res);
|
await update(res);
|
||||||
@@ -138,6 +142,7 @@ const ProfileView = ({ user }: Props) => {
|
|||||||
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
||||||
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
|
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
|
||||||
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
|
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
|
||||||
|
const [isCompanyEmailAlertDismissed, setIsCompanyEmailAlertDismissed] = useState(false);
|
||||||
const form = useForm<DeleteAccountValues>();
|
const form = useForm<DeleteAccountValues>();
|
||||||
|
|
||||||
const onDeleteMeSuccessMutation = async () => {
|
const onDeleteMeSuccessMutation = async () => {
|
||||||
@@ -250,6 +255,14 @@ const ProfileView = ({ user }: Props) => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if user should see company email alert
|
||||||
|
const shouldShowCompanyEmailAlert =
|
||||||
|
!isCompanyEmailAlertDismissed &&
|
||||||
|
!session.data?.user?.org?.id &&
|
||||||
|
!user.organization?.id &&
|
||||||
|
userEmail &&
|
||||||
|
isCompanyEmail(userEmail);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t("profile")}
|
title={t("profile")}
|
||||||
@@ -301,6 +314,12 @@ const ProfileView = ({ user }: Props) => {
|
|||||||
isCALIdentityProvider={isCALIdentityProvider}
|
isCALIdentityProvider={isCALIdentityProvider}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{shouldShowCompanyEmailAlert && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<CompanyEmailOrganizationBanner onDismissAction={() => setIsCompanyEmailAlertDismissed(true)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||||
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||||
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
|
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
|
||||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc";
|
import type { RouterOutputs } from "@calcom/trpc";
|
||||||
@@ -52,7 +53,9 @@ const useOrgCreation = () => {
|
|||||||
await utils.viewer.organizations.listCurrent.invalidate();
|
await utils.viewer.organizations.listCurrent.invalidate();
|
||||||
await session.update();
|
await session.update();
|
||||||
reset();
|
reset();
|
||||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
// Set flag to show welcome modal (using both query param and sessionStorage for reliability)
|
||||||
|
setShowNewOrgModalFlag();
|
||||||
|
window.location.href = `${window.location.origin}/settings/organizations/profile?newOrganizationModal=true`;
|
||||||
} else {
|
} else {
|
||||||
// Unexpected state
|
// Unexpected state
|
||||||
setServerErrorMessage("Unexpected response from server");
|
setServerErrorMessage("Unexpected response from server");
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const PaymentStatusView = () => {
|
|||||||
if (organization) {
|
if (organization) {
|
||||||
setOrganizationCreated(true);
|
setOrganizationCreated(true);
|
||||||
// Organization is created, redirect to next step
|
// Organization is created, redirect to next step
|
||||||
router.push(`/settings/organizations`);
|
router.push(`/settings/organizations?newOrganizationModal=true`);
|
||||||
}
|
}
|
||||||
}, [organization, router, useOnboardingStore]);
|
}, [organization, router, useOnboardingStore]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.8.7",
|
"version": "5.8.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|||||||
@@ -1692,6 +1692,8 @@
|
|||||||
"profile_picture": "Profile Picture",
|
"profile_picture": "Profile Picture",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"add_profile_photo": "Add profile photo",
|
"add_profile_photo": "Add profile photo",
|
||||||
|
"it_appears_you_are_using_company_email": "It appears that you are utilizing your company email.",
|
||||||
|
"explore_organizational_plan_description": "Explore our new organizational plan! It boosts collaboration across event types and directs lost customers to the right team member.",
|
||||||
"token_address": "Token Address",
|
"token_address": "Token Address",
|
||||||
"blockchain": "Blockchain",
|
"blockchain": "Blockchain",
|
||||||
"old_password": "Old password",
|
"old_password": "Old password",
|
||||||
@@ -2494,6 +2496,7 @@
|
|||||||
"additional_url_parameters": "Additional URL parameters",
|
"additional_url_parameters": "Additional URL parameters",
|
||||||
"about_your_organization": "About your organization",
|
"about_your_organization": "About your organization",
|
||||||
"about_your_organization_description": "Organizations are shared environments where you can create multiple teams with shared members, event types, apps, workflows and more.",
|
"about_your_organization_description": "Organizations are shared environments where you can create multiple teams with shared members, event types, apps, workflows and more.",
|
||||||
|
"create_your_team": "Create your team",
|
||||||
"create_your_teams": "Create your teams",
|
"create_your_teams": "Create your teams",
|
||||||
"create_your_teams_description": "Start scheduling together by adding your team members to your organization",
|
"create_your_teams_description": "Start scheduling together by adding your team members to your organization",
|
||||||
"invite_organization_admins": "Invite organization members",
|
"invite_organization_admins": "Invite organization members",
|
||||||
@@ -2527,6 +2530,15 @@
|
|||||||
"resolve": "Resolve",
|
"resolve": "Resolve",
|
||||||
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
|
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
|
||||||
"copy_link_org": "Copy link to organization",
|
"copy_link_org": "Copy link to organization",
|
||||||
|
"welcome_to_organizations": "Welcome to Organizations!",
|
||||||
|
"organizations_welcome_description": "We're excited to connect your bookers with your team members. With your Teams's plan you get:",
|
||||||
|
"1_parent_team_unlimited_subteams": "1 parent team and unlimited sub-teams",
|
||||||
|
"organization_workflows": "Organization workflows",
|
||||||
|
"custom_subdomain": "Yourcompany.cal.com subdomain",
|
||||||
|
"instant_meetings": "Cal.com instant meetings",
|
||||||
|
"collective_round_robin_events": "Collective & Round-robin events",
|
||||||
|
"routing_forms": "Routing forms",
|
||||||
|
"team_workflows": "Team workflows",
|
||||||
"404_the_org": "The organization",
|
"404_the_org": "The organization",
|
||||||
"404_the_team": "The team",
|
"404_the_team": "The team",
|
||||||
"404_claim_entity_org": "Claim your subdomain for your organization",
|
"404_claim_entity_org": "Claim your subdomain for your organization",
|
||||||
@@ -3906,13 +3918,18 @@
|
|||||||
"view_previous_week": "View previous week",
|
"view_previous_week": "View previous week",
|
||||||
"view_next_week": "View next week",
|
"view_next_week": "View next week",
|
||||||
"invite_teammates": "Invite Teammates",
|
"invite_teammates": "Invite Teammates",
|
||||||
|
"invite_team_members": "Invite team members",
|
||||||
"organization_brand": "Organization Brand",
|
"organization_brand": "Organization Brand",
|
||||||
"organization_details": "Organization Details",
|
"organization_details": "Organization Details",
|
||||||
"organization_teams": "Organization Teams",
|
"organization_teams": "Organization Teams",
|
||||||
"personal_profile": "Personal Profile",
|
"personal_profile": "Personal Profile",
|
||||||
"personal_settings": "Personal Settings",
|
"personal_settings": "Personal Settings",
|
||||||
|
"customize_team_brand": "Customize your team brand",
|
||||||
"team_brand": "Team Brand",
|
"team_brand": "Team Brand",
|
||||||
|
"team_brand_subtitle": "Customize your team's branding with a logo and color",
|
||||||
"team_details": "Team Details",
|
"team_details": "Team Details",
|
||||||
"team_invite": "Team Invite",
|
"team_invite": "Team Invite",
|
||||||
|
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
|
||||||
|
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ services:
|
|||||||
calcom-api:
|
calcom-api:
|
||||||
container_name: calcom-api
|
container_name: calcom-api
|
||||||
build:
|
build:
|
||||||
context: ./calcom
|
context: .
|
||||||
dockerfile: apps/api/v2/Dockerfile
|
dockerfile: apps/api/v2/Dockerfile
|
||||||
args:
|
args:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const VerifyCodeDialog = ({
|
|||||||
value,
|
value,
|
||||||
email,
|
email,
|
||||||
verifyCodeWithSessionNotRequired,
|
verifyCodeWithSessionNotRequired,
|
||||||
|
setVerificationCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +78,8 @@ export const VerifyCodeDialog = ({
|
|||||||
if (hasVerified || error || isPending || !/^\d{6}$/.test(value.trim())) return;
|
if (hasVerified || error || isPending || !/^\d{6}$/.test(value.trim())) return;
|
||||||
|
|
||||||
verifyCode();
|
verifyCode();
|
||||||
}, [error, isPending, value, verifyCode, hasVerified]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [error, isPending, value, hasVerified]);
|
||||||
|
|
||||||
useEffect(() => setValue(""), [isOpenDialog]);
|
useEffect(() => setValue(""), [isOpenDialog]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class BotDetectionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
|
||||||
|
throw new Error(`Invalid eventTypeId: ${eventTypeId}. Must be a positive integer.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch only the teamId from the event type
|
// Fetch only the teamId from the event type
|
||||||
const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({
|
const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({
|
||||||
id: eventTypeId,
|
id: eventTypeId,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||||
|
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||||
|
import { Logo } from "@calcom/ui/components/logo";
|
||||||
|
|
||||||
|
import { useWelcomeModal } from "../hooks/useWelcomeModal";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
"1_parent_team_unlimited_subteams",
|
||||||
|
"organization_workflows",
|
||||||
|
"custom_subdomain",
|
||||||
|
"instant_meetings",
|
||||||
|
"collective_round_robin_events",
|
||||||
|
"routing_forms",
|
||||||
|
"team_workflows",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WelcomeToOrganizationsModal() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { isOpen, closeModal } = useWelcomeModal();
|
||||||
|
|
||||||
|
const SMALL = { outer: 32, icon: 16 };
|
||||||
|
const LARGE = { outer: 48, icon: 24 };
|
||||||
|
const RINGS = [60, 95, 130]; // Ring radii in px
|
||||||
|
const RING_STROKE = 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
||||||
|
<DialogContent size="default" className="!p-0">
|
||||||
|
<div className="flex flex-col gap-4 p-6">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Logo className="h-10 w-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team illustration with rings */}
|
||||||
|
<div
|
||||||
|
className="relative mx-auto"
|
||||||
|
style={{
|
||||||
|
width: 320,
|
||||||
|
height: 220,
|
||||||
|
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||||
|
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||||
|
}}>
|
||||||
|
{/* Center origin */}
|
||||||
|
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
|
||||||
|
{/* Rings */}
|
||||||
|
{RINGS.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="pointer-events-none absolute rounded-full border"
|
||||||
|
style={{
|
||||||
|
width: 2 * r,
|
||||||
|
height: 2 * r,
|
||||||
|
left: `calc(50% - ${r}px)`,
|
||||||
|
top: `calc(50% - ${r}px)`,
|
||||||
|
borderWidth: RING_STROKE,
|
||||||
|
borderColor: "var(--cal-border-subtle)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Central users icon */}
|
||||||
|
<div
|
||||||
|
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||||
|
style={{
|
||||||
|
width: LARGE.outer,
|
||||||
|
height: LARGE.outer,
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}}>
|
||||||
|
<Icon
|
||||||
|
name="users"
|
||||||
|
className="text-emphasis opacity-70"
|
||||||
|
style={{ width: LARGE.icon, height: LARGE.icon }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Surrounding user icons */}
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ initialDeg: 30, duration: 20, icon: "user" },
|
||||||
|
{ initialDeg: 190, duration: 25, icon: "user" },
|
||||||
|
{ initialDeg: 320, duration: 15, icon: "user" },
|
||||||
|
{ initialDeg: 280, duration: 20, icon: "building" },
|
||||||
|
] as Array<{ initialDeg: number; duration: number; icon: IconName }>
|
||||||
|
).map(({ initialDeg, duration, icon }, index) => {
|
||||||
|
const r = RINGS[index % RINGS.length]; // icon orbit radius - each icon on a different ring (we have more icons than ring so we cycle through them)
|
||||||
|
const steps = 60;
|
||||||
|
const xKeyframes = [];
|
||||||
|
const yKeyframes = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const angle = initialDeg + (360 * i) / steps;
|
||||||
|
xKeyframes.push(r * Math.cos((angle * Math.PI) / 180));
|
||||||
|
yKeyframes.push(r * Math.sin((angle * Math.PI) / 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||||
|
style={{
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
width: SMALL.outer,
|
||||||
|
height: SMALL.outer,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
x: xKeyframes,
|
||||||
|
y: yKeyframes,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
transformTemplate={({ x, y }) =>
|
||||||
|
// Lock the icon to the center of the ring and only translate on x and y
|
||||||
|
`translate(-50%, -50%) translateX(${x}) translateY(${y})`
|
||||||
|
}>
|
||||||
|
<Icon
|
||||||
|
name={icon}
|
||||||
|
className="text-default"
|
||||||
|
style={{ width: SMALL.icon, height: SMALL.icon }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||||
|
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("welcome_to_organizations")}</h2>
|
||||||
|
<p className="text-default text-sm leading-normal">{t("organizations_welcome_description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex flex-col gap-3">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div key={feature} className="flex items-start gap-2">
|
||||||
|
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
href="https://cal.com/docs/organizations"
|
||||||
|
target="_blank"
|
||||||
|
EndIcon="external-link"
|
||||||
|
className="pointer-events-none opacity-0">
|
||||||
|
{t("learn_more")}
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" onClick={closeModal}>
|
||||||
|
{t("continue")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { parseAsBoolean, useQueryState } from "nuqs";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "showNewOrgModal";
|
||||||
|
|
||||||
|
export function useWelcomeModal() {
|
||||||
|
const [newOrganizationModal, setNewOrganizationModal] = useQueryState(
|
||||||
|
"newOrganizationModal",
|
||||||
|
parseAsBoolean.withDefault(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check query param first
|
||||||
|
if (newOrganizationModal) {
|
||||||
|
setIsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
|
||||||
|
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [newOrganizationModal]);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
// Remove the query param from URL
|
||||||
|
setNewOrganizationModal(null);
|
||||||
|
// Also clear sessionStorage
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
closeModal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to set the flag that triggers the welcome modal.
|
||||||
|
* Use this before redirecting to ensure the modal shows after navigation.
|
||||||
|
*/
|
||||||
|
export function setShowNewOrgModalFlag() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,23 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
|
|||||||
const rrResetInterval = team?.rrResetInterval ?? undefined;
|
const rrResetInterval = team?.rrResetInterval ?? undefined;
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const initialResetInterval = rrResetInterval ?? RRResetInterval.MONTH;
|
||||||
|
const initialTimestampBasis = team?.rrTimestampBasis ?? RRTimestampBasis.CREATED_AT;
|
||||||
|
|
||||||
const form = useForm<{ rrResetInterval: RRResetInterval; rrTimestampBasis: RRTimestampBasis }>({
|
const form = useForm<{ rrResetInterval: RRResetInterval; rrTimestampBasis: RRTimestampBasis }>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
rrResetInterval: rrResetInterval ?? RRResetInterval.MONTH,
|
rrResetInterval: initialResetInterval,
|
||||||
rrTimestampBasis: team?.rrTimestampBasis ?? RRTimestampBasis.CREATED_AT,
|
rrTimestampBasis: initialTimestampBasis,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [watchedResetInterval, watchedTimestampBasis] = form.watch([
|
||||||
|
"rrResetInterval",
|
||||||
|
"rrTimestampBasis",
|
||||||
|
]);
|
||||||
|
const hasChanges =
|
||||||
|
watchedResetInterval !== initialResetInterval || watchedTimestampBasis !== initialTimestampBasis;
|
||||||
|
|
||||||
const mutation = trpc.viewer.teams.update.useMutation({
|
const mutation = trpc.viewer.teams.update.useMutation({
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast(err.message, "error");
|
showToast(err.message, "error");
|
||||||
@@ -85,7 +95,7 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SectionBottomActions align="end">
|
<SectionBottomActions align="end">
|
||||||
<Button type="submit" color="primary" loading={mutation.isPending}>
|
<Button type="submit" disabled={!hasChanges} color="primary" loading={mutation.isPending}>
|
||||||
{t("update")}
|
{t("update")}
|
||||||
</Button>
|
</Button>
|
||||||
</SectionBottomActions>
|
</SectionBottomActions>
|
||||||
|
|||||||
@@ -293,6 +293,20 @@ export const MultiplePrivateLinksController = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="ml-2 flex items-center">
|
<div className="ml-2 flex items-center">
|
||||||
|
{!isExpired && (
|
||||||
|
<Tooltip content={t("preview")}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
variant="icon"
|
||||||
|
StartIcon="external-link"
|
||||||
|
data-testid="private-link-preview"
|
||||||
|
href={singleUseURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{!isExpired && (
|
{!isExpired && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container for all query-param driven modals that should appear globally across the app.
|
||||||
|
* This keeps the Shell component clean and provides a centralized place for dynamic modals.
|
||||||
|
*
|
||||||
|
* We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing
|
||||||
|
*
|
||||||
|
* To add a new modal:
|
||||||
|
* 1. Create the modal component with its own useQueryState hook
|
||||||
|
* 2. Import and add it here
|
||||||
|
*/
|
||||||
|
export function DynamicModals() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WelcomeToOrganizationsModal />
|
||||||
|
{/* Add more query-param driven modals here */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
|||||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
|
|
||||||
import { CalAiBanner } from "./CalAiBanner";
|
import { CalAiBanner } from "./CalAiBanner";
|
||||||
|
import { DynamicModals } from "./DynamicModals";
|
||||||
import { SideBarContainer } from "./SideBar";
|
import { SideBarContainer } from "./SideBar";
|
||||||
import { TopNavContainer } from "./TopNav";
|
import { TopNavContainer } from "./TopNav";
|
||||||
import { BannerContainer } from "./banners/LayoutBanner";
|
import { BannerContainer } from "./banners/LayoutBanner";
|
||||||
@@ -38,6 +39,7 @@ const Layout = (props: LayoutProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimezoneChangeDialog />
|
<TimezoneChangeDialog />
|
||||||
|
<DynamicModals />
|
||||||
|
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{banners && !props.isPlatformUser && <BannerContainer banners={banners} />}
|
{banners && !props.isPlatformUser && <BannerContainer banners={banners} />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BookingReportReason } from "@calcom/prisma/enums";
|
import type { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
export interface CreateBookingReportInput {
|
export interface CreateBookingReportInput {
|
||||||
bookingUid: string;
|
bookingUid: string;
|
||||||
@@ -18,8 +18,69 @@ export interface BookingReportSummary {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListBookingReportsFilters {
|
||||||
|
reason?: BookingReportReason[];
|
||||||
|
cancelled?: boolean;
|
||||||
|
hasWatchlist?: boolean;
|
||||||
|
status?: BookingReportStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReportWithDetails {
|
||||||
|
id: string;
|
||||||
|
bookingUid: string;
|
||||||
|
bookerEmail: string;
|
||||||
|
reportedById: number | null;
|
||||||
|
reason: BookingReportReason;
|
||||||
|
description: string | null;
|
||||||
|
cancelled: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
status: BookingReportStatus;
|
||||||
|
watchlistId: string | null;
|
||||||
|
reporter: {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
} | null;
|
||||||
|
booking: {
|
||||||
|
id: number;
|
||||||
|
uid: string;
|
||||||
|
title: string | null;
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date;
|
||||||
|
};
|
||||||
|
watchlist: {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
action: string;
|
||||||
|
description: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IBookingReportRepository {
|
export interface IBookingReportRepository {
|
||||||
createReport(input: CreateBookingReportInput): Promise<{ id: string }>;
|
createReport(input: CreateBookingReportInput): Promise<{ id: string }>;
|
||||||
|
|
||||||
findAllReportedBookings(params: { skip?: number; take?: number }): Promise<BookingReportSummary[]>;
|
findAllReportedBookings(params: {
|
||||||
|
organizationId?: number;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
searchTerm?: string;
|
||||||
|
filters?: ListBookingReportsFilters;
|
||||||
|
}): Promise<{
|
||||||
|
rows: BookingReportWithDetails[];
|
||||||
|
meta: { totalRowCount: number };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
findReportsByIds(params: {
|
||||||
|
reportIds: string[];
|
||||||
|
organizationId?: number;
|
||||||
|
}): Promise<BookingReportWithDetails[]>;
|
||||||
|
|
||||||
|
linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise<void>;
|
||||||
|
|
||||||
|
updateReportStatus(params: {
|
||||||
|
reportId: string;
|
||||||
|
status: BookingReportStatus;
|
||||||
|
organizationId?: number;
|
||||||
|
}): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ describe("PrismaBookingReportRepository", () => {
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
},
|
},
|
||||||
} as unknown as PrismaClient;
|
} as unknown as PrismaClient;
|
||||||
|
|
||||||
@@ -94,36 +96,112 @@ describe("PrismaBookingReportRepository", () => {
|
|||||||
const mockReports = [
|
const mockReports = [
|
||||||
{
|
{
|
||||||
id: "report-1",
|
id: "report-1",
|
||||||
|
bookingUid: "booking-1",
|
||||||
|
bookerEmail: "user1@example.com",
|
||||||
reportedById: 1,
|
reportedById: 1,
|
||||||
reason: BookingReportReason.SPAM,
|
reason: BookingReportReason.SPAM,
|
||||||
description: "Spam",
|
description: "Spam",
|
||||||
|
cancelled: false,
|
||||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||||
|
status: "PENDING",
|
||||||
|
watchlistId: null,
|
||||||
|
reportedBy: null,
|
||||||
|
booking: {
|
||||||
|
id: 1,
|
||||||
|
uid: "booking-1",
|
||||||
|
title: "Test Booking",
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
},
|
||||||
|
watchlist: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "report-2",
|
id: "report-2",
|
||||||
|
bookingUid: "booking-2",
|
||||||
|
bookerEmail: "user2@example.com",
|
||||||
reportedById: 2,
|
reportedById: 2,
|
||||||
reason: BookingReportReason.DONT_KNOW_PERSON,
|
reason: BookingReportReason.DONT_KNOW_PERSON,
|
||||||
description: null,
|
description: null,
|
||||||
|
cancelled: false,
|
||||||
createdAt: new Date("2025-01-01T11:00:00Z"),
|
createdAt: new Date("2025-01-01T11:00:00Z"),
|
||||||
|
status: "PENDING",
|
||||||
|
watchlistId: null,
|
||||||
|
reportedBy: null,
|
||||||
|
booking: {
|
||||||
|
id: 2,
|
||||||
|
uid: "booking-2",
|
||||||
|
title: "Test Booking 2",
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
},
|
||||||
|
watchlist: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
mockPrisma.bookingReport.findMany.mockResolvedValue(mockReports);
|
mockPrisma.bookingReport.findMany.mockResolvedValue(mockReports);
|
||||||
|
mockPrisma.bookingReport.count.mockResolvedValue(2);
|
||||||
|
|
||||||
const result = await repository.findAllReportedBookings({ skip: 0, take: 10 });
|
const result = await repository.findAllReportedBookings({ skip: 0, take: 10 });
|
||||||
|
|
||||||
expect(result).toEqual(mockReports);
|
expect(result).toEqual({
|
||||||
expect(mockPrisma.bookingReport.findMany).toHaveBeenCalledWith({
|
rows: mockReports.map((r) => ({ ...r, reporter: r.reportedBy })),
|
||||||
skip: 0,
|
meta: { totalRowCount: 2 },
|
||||||
take: 10,
|
});
|
||||||
select: {
|
expect(mockPrisma.bookingReport.findMany).toHaveBeenCalled();
|
||||||
id: true,
|
expect(mockPrisma.bookingReport.count).toHaveBeenCalled();
|
||||||
reportedById: true,
|
});
|
||||||
reason: true,
|
});
|
||||||
description: true,
|
|
||||||
createdAt: true,
|
describe("updateReportStatus", () => {
|
||||||
|
it("should update report status with organizationId", async () => {
|
||||||
|
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await repository.updateReportStatus({
|
||||||
|
reportId: "report-123",
|
||||||
|
status: "BLOCKED",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: "report-123",
|
||||||
|
organizationId: 100,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
data: { status: "BLOCKED" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update report status without organizationId", async () => {
|
||||||
|
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await repository.updateReportStatus({
|
||||||
|
reportId: "report-456",
|
||||||
|
status: "DISMISSED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: "report-456",
|
||||||
|
},
|
||||||
|
data: { status: "DISMISSED" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update report status to PENDING", async () => {
|
||||||
|
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await repository.updateReportStatus({
|
||||||
|
reportId: "report-789",
|
||||||
|
status: "PENDING",
|
||||||
|
organizationId: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: "report-789",
|
||||||
|
organizationId: 200,
|
||||||
|
},
|
||||||
|
data: { status: "PENDING" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
import type { Prisma } from "@calcom/prisma/generated/prisma/client";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IBookingReportRepository,
|
IBookingReportRepository,
|
||||||
CreateBookingReportInput,
|
CreateBookingReportInput,
|
||||||
BookingReportSummary,
|
BookingReportWithDetails,
|
||||||
|
ListBookingReportsFilters,
|
||||||
} from "./bookingReport.interface";
|
} from "./bookingReport.interface";
|
||||||
|
|
||||||
export class PrismaBookingReportRepository implements IBookingReportRepository {
|
export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||||
@@ -25,19 +27,189 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
|||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllReportedBookings(params: { skip?: number; take?: number }): Promise<BookingReportSummary[]> {
|
async findAllReportedBookings(params: {
|
||||||
|
organizationId?: number;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
searchTerm?: string;
|
||||||
|
filters?: ListBookingReportsFilters;
|
||||||
|
}): Promise<{
|
||||||
|
rows: BookingReportWithDetails[];
|
||||||
|
meta: { totalRowCount: number };
|
||||||
|
}> {
|
||||||
|
const where: Prisma.BookingReportWhereInput = {};
|
||||||
|
|
||||||
|
if (params.organizationId) {
|
||||||
|
where.organizationId = params.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.searchTerm) {
|
||||||
|
where.OR = [
|
||||||
|
{ bookerEmail: { contains: params.searchTerm, mode: "insensitive" } },
|
||||||
|
{
|
||||||
|
reportedBy: {
|
||||||
|
email: { contains: params.searchTerm, mode: "insensitive" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.hasWatchlist !== undefined) {
|
||||||
|
where.watchlistId = params.filters.hasWatchlist ? { not: null } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.reason && params.filters.reason.length > 0) {
|
||||||
|
where.reason = { in: params.filters.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.cancelled !== undefined) {
|
||||||
|
where.cancelled = params.filters.cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.status && params.filters.status.length > 0) {
|
||||||
|
where.status = { in: params.filters.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reports, totalCount] = await Promise.all([
|
||||||
|
this.prismaClient.bookingReport.findMany({
|
||||||
|
where,
|
||||||
|
skip: params.skip,
|
||||||
|
take: params.take,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bookingUid: true,
|
||||||
|
bookerEmail: true,
|
||||||
|
reportedById: true,
|
||||||
|
reason: true,
|
||||||
|
description: true,
|
||||||
|
cancelled: true,
|
||||||
|
createdAt: true,
|
||||||
|
status: true,
|
||||||
|
watchlistId: true,
|
||||||
|
reportedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uid: true,
|
||||||
|
title: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watchlist: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
value: true,
|
||||||
|
action: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
this.prismaClient.bookingReport.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = reports.map((report) => ({
|
||||||
|
...report,
|
||||||
|
reporter: report.reportedBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
meta: { totalRowCount: totalCount },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findReportsByIds(params: {
|
||||||
|
reportIds: string[];
|
||||||
|
organizationId?: number;
|
||||||
|
}): Promise<BookingReportWithDetails[]> {
|
||||||
|
const where: Prisma.BookingReportWhereInput = {
|
||||||
|
id: { in: params.reportIds },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.organizationId) {
|
||||||
|
where.organizationId = params.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
const reports = await this.prismaClient.bookingReport.findMany({
|
const reports = await this.prismaClient.bookingReport.findMany({
|
||||||
skip: params.skip,
|
where,
|
||||||
take: params.take,
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
bookingUid: true,
|
||||||
|
bookerEmail: true,
|
||||||
reportedById: true,
|
reportedById: true,
|
||||||
reason: true,
|
reason: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
cancelled: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
status: true,
|
||||||
|
watchlistId: true,
|
||||||
|
reportedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uid: true,
|
||||||
|
title: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watchlist: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
value: true,
|
||||||
|
action: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
});
|
||||||
return reports;
|
|
||||||
|
return reports.map((report) => ({
|
||||||
|
...report,
|
||||||
|
reporter: report.reportedBy,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise<void> {
|
||||||
|
await this.prismaClient.bookingReport.update({
|
||||||
|
where: { id: params.reportId },
|
||||||
|
data: { watchlistId: params.watchlistId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateReportStatus(params: {
|
||||||
|
reportId: string;
|
||||||
|
status: "PENDING" | "DISMISSED" | "BLOCKED";
|
||||||
|
organizationId?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const where: Prisma.BookingReportWhereInput = {
|
||||||
|
id: params.reportId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.organizationId) {
|
||||||
|
where.organizationId = params.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prismaClient.bookingReport.updateMany({
|
||||||
|
where,
|
||||||
|
data: { status: params.status },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,18 @@ export interface WatchlistAuditEntry {
|
|||||||
export interface CreateWatchlistInput {
|
export interface CreateWatchlistInput {
|
||||||
type: WatchlistType;
|
type: WatchlistType;
|
||||||
value: string;
|
value: string;
|
||||||
organizationId: number;
|
organizationId: number | null;
|
||||||
action: WatchlistAction;
|
action: WatchlistAction;
|
||||||
description?: string;
|
description?: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
isGlobal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckWatchlistInput {
|
export interface CheckWatchlistInput {
|
||||||
type: WatchlistType;
|
type: WatchlistType;
|
||||||
value: string;
|
value: string;
|
||||||
organizationId: number;
|
organizationId?: number | null;
|
||||||
|
isGlobal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FindAllEntriesInput {
|
export interface FindAllEntriesInput {
|
||||||
@@ -59,8 +61,17 @@ export interface IWatchlistRepository {
|
|||||||
})[];
|
})[];
|
||||||
meta: { totalRowCount: number };
|
meta: { totalRowCount: number };
|
||||||
}>;
|
}>;
|
||||||
findEntryWithAudit(id: string): Promise<{
|
findEntryWithAuditAndReports(id: string): Promise<{
|
||||||
entry: WatchlistEntry | null;
|
entry:
|
||||||
|
| (WatchlistEntry & {
|
||||||
|
bookingReports?: Array<{
|
||||||
|
booking: {
|
||||||
|
uid: string;
|
||||||
|
title: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
})
|
||||||
|
| null;
|
||||||
auditHistory: WatchlistAuditEntry[];
|
auditHistory: WatchlistAuditEntry[];
|
||||||
}>;
|
}>;
|
||||||
deleteEntry(id: string, userId: number): Promise<void>;
|
deleteEntry(id: string, userId: number): Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class WatchlistRepository implements IWatchlistRepository {
|
|||||||
type: params.type,
|
type: params.type,
|
||||||
value: params.value,
|
value: params.value,
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
|
isGlobal: params.isGlobal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -33,7 +34,7 @@ export class WatchlistRepository implements IWatchlistRepository {
|
|||||||
action: params.action,
|
action: params.action,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
source: WatchlistSource.MANUAL,
|
source: WatchlistSource.MANUAL,
|
||||||
isGlobal: false,
|
isGlobal: params.isGlobal ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,17 +56,35 @@ export class WatchlistRepository implements IWatchlistRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null> {
|
async checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null> {
|
||||||
const entry = await this.prismaClient.watchlist.findUnique({
|
if (!params.isGlobal && !params.organizationId) {
|
||||||
where: {
|
throw new Error("Both isGlobal and organizationId are missing");
|
||||||
type_value_organizationId: {
|
}
|
||||||
|
|
||||||
|
if (params.isGlobal) {
|
||||||
|
const entry = await this.prismaClient.watchlist.findFirst({
|
||||||
|
where: {
|
||||||
type: params.type,
|
type: params.type,
|
||||||
value: params.value,
|
value: params.value,
|
||||||
organizationId: params.organizationId,
|
isGlobal: true,
|
||||||
|
organizationId: null,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
return entry;
|
||||||
|
} else if (params.organizationId) {
|
||||||
|
const entry = await this.prismaClient.watchlist.findUnique({
|
||||||
|
where: {
|
||||||
|
type_value_organizationId: {
|
||||||
|
type: params.type,
|
||||||
|
value: params.value,
|
||||||
|
organizationId: params.organizationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllEntries(params: FindAllEntriesInput): Promise<{
|
async findAllEntries(params: FindAllEntriesInput): Promise<{
|
||||||
@@ -123,8 +142,17 @@ export class WatchlistRepository implements IWatchlistRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findEntryWithAudit(id: string): Promise<{
|
async findEntryWithAuditAndReports(id: string): Promise<{
|
||||||
entry: WatchlistEntry | null;
|
entry:
|
||||||
|
| (WatchlistEntry & {
|
||||||
|
bookingReports?: Array<{
|
||||||
|
booking: {
|
||||||
|
uid: string;
|
||||||
|
title: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
})
|
||||||
|
| null;
|
||||||
auditHistory: WatchlistAuditEntry[];
|
auditHistory: WatchlistAuditEntry[];
|
||||||
}> {
|
}> {
|
||||||
const entry = await this.prismaClient.watchlist.findUnique({
|
const entry = await this.prismaClient.watchlist.findUnique({
|
||||||
@@ -139,6 +167,16 @@ export class WatchlistRepository implements IWatchlistRepository {
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
source: true,
|
source: true,
|
||||||
lastUpdatedAt: true,
|
lastUpdatedAt: true,
|
||||||
|
bookingReports: {
|
||||||
|
select: {
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
uid: true,
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
audits: {
|
audits: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -169,6 +207,7 @@ export class WatchlistRepository implements IWatchlistRepository {
|
|||||||
isGlobal: entry.isGlobal,
|
isGlobal: entry.isGlobal,
|
||||||
source: entry.source,
|
source: entry.source,
|
||||||
lastUpdatedAt: entry.lastUpdatedAt,
|
lastUpdatedAt: entry.lastUpdatedAt,
|
||||||
|
bookingReports: entry.bookingReports,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
auditHistory: entry?.audits || [],
|
auditHistory: entry?.audits || [],
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "public"."BookingReportStatus" AS ENUM ('PENDING', 'DISMISSED', 'BLOCKED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."BookingReport" ADD COLUMN "status" "public"."BookingReportStatus" NOT NULL DEFAULT 'PENDING';
|
||||||
@@ -2366,6 +2366,12 @@ enum BookingReportReason {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BookingReportStatus {
|
||||||
|
PENDING // Initial state - awaiting review
|
||||||
|
DISMISSED // User clicked "Don't block"
|
||||||
|
BLOCKED // Added to watchlist
|
||||||
|
}
|
||||||
|
|
||||||
model BookingReport {
|
model BookingReport {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
bookingUid String @unique
|
bookingUid String @unique
|
||||||
@@ -2378,6 +2384,7 @@ model BookingReport {
|
|||||||
cancelled Boolean @default(false)
|
cancelled Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
status BookingReportStatus @default(PENDING)
|
||||||
|
|
||||||
watchlistId String? @db.Uuid
|
watchlistId String? @db.Uuid
|
||||||
|
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ export type FieldType = z.infer<typeof fieldTypeEnum>;
|
|||||||
export const excludeOrRequireEmailSchema = z.string().superRefine((val, ctx) => {
|
export const excludeOrRequireEmailSchema = z.string().superRefine((val, ctx) => {
|
||||||
const allDomains = val.split(",").map((dom) => dom.trim());
|
const allDomains = val.split(",").map((dom) => dom.trim());
|
||||||
|
|
||||||
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/;
|
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/i;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Valid patterns - [ example, example.anything, anyone@example.anything ]
|
Valid patterns - [ example, example.anything, anyone@example.anything ]
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
|
|||||||
import { ZGetUserInput } from "./getUser.schema";
|
import { ZGetUserInput } from "./getUser.schema";
|
||||||
import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema";
|
import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema";
|
||||||
import { ZIntentToCreateOrgInputSchema } from "./intentToCreateOrg.schema";
|
import { ZIntentToCreateOrgInputSchema } from "./intentToCreateOrg.schema";
|
||||||
|
import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema";
|
||||||
|
import { ZDismissBookingReportInputSchema } from "./dismissBookingReport.schema";
|
||||||
|
import { ZListBookingReportsInputSchema } from "./listBookingReports.schema";
|
||||||
import { ZListMembersInputSchema } from "./listMembers.schema";
|
import { ZListMembersInputSchema } from "./listMembers.schema";
|
||||||
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
|
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
|
||||||
import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
|
import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
|
||||||
@@ -191,5 +194,23 @@ export const viewerOrganizationsRouter = router({
|
|||||||
const { default: handler } = await import("./getWatchlistEntryDetails.handler");
|
const { default: handler } = await import("./getWatchlistEntryDetails.handler");
|
||||||
return handler(opts);
|
return handler(opts);
|
||||||
}),
|
}),
|
||||||
|
listBookingReports: authedOrgAdminProcedure
|
||||||
|
.input(ZListBookingReportsInputSchema)
|
||||||
|
.query(async (opts) => {
|
||||||
|
const { default: handler } = await import("./listBookingReports.handler");
|
||||||
|
return handler(opts);
|
||||||
|
}),
|
||||||
|
addToWatchlist: authedOrgAdminProcedure
|
||||||
|
.input(ZAddToWatchlistInputSchema)
|
||||||
|
.mutation(async (opts) => {
|
||||||
|
const { default: handler } = await import("./addToWatchlist.handler");
|
||||||
|
return handler(opts);
|
||||||
|
}),
|
||||||
|
dismissBookingReport: authedOrgAdminProcedure
|
||||||
|
.input(ZDismissBookingReportInputSchema)
|
||||||
|
.mutation(async (opts) => {
|
||||||
|
const { default: handler } = await import("./dismissBookingReport.handler");
|
||||||
|
return handler(opts);
|
||||||
|
}),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import type { WatchlistEntry } from "@calcom/lib/server/repository/watchlist.interface";
|
||||||
|
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||||
|
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||||
|
import {
|
||||||
|
MembershipRole,
|
||||||
|
WatchlistType,
|
||||||
|
WatchlistAction,
|
||||||
|
WatchlistSource,
|
||||||
|
BookingReportReason,
|
||||||
|
} from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { addToWatchlistHandler } from "./addToWatchlist.handler";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||||
|
vi.mock("@calcom/lib/server/repository/bookingReport");
|
||||||
|
vi.mock("@calcom/lib/server/repository/watchlist.repository");
|
||||||
|
|
||||||
|
describe("addToWatchlistHandler (Organization)", () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "admin@example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
profile: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReports = [
|
||||||
|
{
|
||||||
|
id: "report-1",
|
||||||
|
bookingUid: "booking-uid-1",
|
||||||
|
bookerEmail: "Spammer@Example.com",
|
||||||
|
reportedById: 1,
|
||||||
|
reason: BookingReportReason.SPAM,
|
||||||
|
description: null,
|
||||||
|
cancelled: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
watchlistId: null,
|
||||||
|
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||||
|
booking: {
|
||||||
|
id: 1,
|
||||||
|
uid: "booking-uid-1",
|
||||||
|
title: "Test Booking",
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
},
|
||||||
|
watchlist: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "report-2",
|
||||||
|
bookingUid: "booking-uid-2",
|
||||||
|
bookerEmail: " User@Spammer.com ",
|
||||||
|
reportedById: 1,
|
||||||
|
reason: BookingReportReason.SPAM,
|
||||||
|
description: null,
|
||||||
|
cancelled: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
watchlistId: null,
|
||||||
|
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||||
|
booking: {
|
||||||
|
id: 2,
|
||||||
|
uid: "booking-uid-2",
|
||||||
|
title: "Test Booking 2",
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
},
|
||||||
|
watchlist: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockPermissionCheckService = {
|
||||||
|
checkPermission: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReportRepo = {
|
||||||
|
findReportsByIds: vi.fn(),
|
||||||
|
linkWatchlistToReport: vi.fn(),
|
||||||
|
updateReportStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWatchlistRepo = {
|
||||||
|
checkExists: vi.fn(),
|
||||||
|
createEntry: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(PermissionCheckService).mockImplementation(
|
||||||
|
() => mockPermissionCheckService as InstanceType<typeof PermissionCheckService>
|
||||||
|
);
|
||||||
|
vi.mocked(PrismaBookingReportRepository).mockImplementation(
|
||||||
|
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
|
||||||
|
);
|
||||||
|
vi.mocked(WatchlistRepository).mockImplementation(
|
||||||
|
() => mockWatchlistRepo as InstanceType<typeof WatchlistRepository>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("access control", () => {
|
||||||
|
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||||
|
await expect(
|
||||||
|
addToWatchlistHandler({
|
||||||
|
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You must be part of an organization to add to watchlist",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to add entries to the watchlist",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findReportsByIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check permission with correct parameters", async () => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||||
|
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||||
|
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 NOT_FOUND when report IDs don't exist", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1", "report-2"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: expect.stringContaining("Booking report(s) not found"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NOT_FOUND with specific missing IDs", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1", "report-2", "report-3"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: expect.stringContaining("report-2, report-3"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicate prevention", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BAD_REQUEST when all reports are already in watchlist", async () => {
|
||||||
|
const reportsInWatchlist = mockReports.map((r) => ({ ...r, watchlistId: "watchlist-123" }));
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue(reportsInWatchlist);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1", "report-2"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "All selected bookers are already in the watchlist",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("successful addition", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new organization watchlist entry for EMAIL type and link report", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||||
|
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||||
|
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
description: "Spam user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.addedCount).toBe(1);
|
||||||
|
expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
value: "spammer@example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
value: "spammer@example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
action: WatchlistAction.BLOCK,
|
||||||
|
description: "Spam user",
|
||||||
|
userId: 1,
|
||||||
|
});
|
||||||
|
expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({
|
||||||
|
reportId: "report-1",
|
||||||
|
watchlistId: "watchlist-new",
|
||||||
|
});
|
||||||
|
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||||
|
reportId: "report-1",
|
||||||
|
status: "BLOCKED",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create new organization watchlist entry for DOMAIN type and link report", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||||
|
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-domain" });
|
||||||
|
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.DOMAIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||||
|
type: WatchlistType.DOMAIN,
|
||||||
|
value: "example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
action: WatchlistAction.BLOCK,
|
||||||
|
description: undefined,
|
||||||
|
userId: 1,
|
||||||
|
});
|
||||||
|
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||||
|
reportId: "report-1",
|
||||||
|
status: "BLOCKED",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reuse existing organization watchlist entry when value already exists", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
mockWatchlistRepo.checkExists.mockResolvedValue({ id: "existing-watchlist" } as WatchlistEntry);
|
||||||
|
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
|
||||||
|
expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({
|
||||||
|
reportId: "report-1",
|
||||||
|
watchlistId: "existing-watchlist",
|
||||||
|
});
|
||||||
|
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||||
|
reportId: "report-1",
|
||||||
|
status: "BLOCKED",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("organization scoping", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass organizationId to repository when finding reports", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||||
|
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||||
|
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass organizationId when checking for existing watchlist entry", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||||
|
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||||
|
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||||
|
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await addToWatchlistHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({
|
||||||
|
type: WatchlistType.EMAIL,
|
||||||
|
value: "spammer@example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import {
|
||||||
|
normalizeEmail,
|
||||||
|
extractDomainFromEmail,
|
||||||
|
} from "@calcom/features/watchlist/lib/utils/normalization";
|
||||||
|
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||||
|
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 { TAddToWatchlistInputSchema } from "./addToWatchlist.schema";
|
||||||
|
|
||||||
|
type AddToWatchlistOptions = {
|
||||||
|
ctx: {
|
||||||
|
user: NonNullable<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
input: TAddToWatchlistInputSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOptions) => {
|
||||||
|
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 add to watchlist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 add entries to the watchlist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||||
|
const watchlistRepo = new WatchlistRepository(prisma);
|
||||||
|
|
||||||
|
const validReports = await bookingReportRepo.findReportsByIds({
|
||||||
|
reportIds: input.reportIds,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validReports.length !== input.reportIds.length) {
|
||||||
|
const foundIds = validReports.map((r) => r.id);
|
||||||
|
const missingIds = input.reportIds.filter((id) => !foundIds.includes(id));
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `Booking report(s) not found: ${missingIds.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportsToAdd = validReports.filter((report) => !report.watchlistId);
|
||||||
|
|
||||||
|
if (reportsToAdd.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "All selected bookers are already in the watchlist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
reportsToAdd.map(async (report) => {
|
||||||
|
const value =
|
||||||
|
input.type === "EMAIL"
|
||||||
|
? normalizeEmail(report.bookerEmail)
|
||||||
|
: extractDomainFromEmail(report.bookerEmail);
|
||||||
|
|
||||||
|
const existingWatchlist = await watchlistRepo.checkExists({
|
||||||
|
type: input.type,
|
||||||
|
value,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let watchlistId: string;
|
||||||
|
|
||||||
|
if (existingWatchlist) {
|
||||||
|
watchlistId = existingWatchlist.id;
|
||||||
|
} else {
|
||||||
|
const newWatchlist = await watchlistRepo.createEntry({
|
||||||
|
type: input.type,
|
||||||
|
value,
|
||||||
|
organizationId,
|
||||||
|
action: WatchlistAction.BLOCK,
|
||||||
|
description: input.description,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
watchlistId = newWatchlist.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bookingReportRepo.linkWatchlistToReport({
|
||||||
|
reportId: report.id,
|
||||||
|
watchlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bookingReportRepo.updateReportStatus({
|
||||||
|
reportId: report.id,
|
||||||
|
status: "BLOCKED",
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { reportId: report.id, watchlistId, value };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully added ${reportsToAdd.length} report(s) to watchlist`,
|
||||||
|
addedCount: reportsToAdd.length,
|
||||||
|
skippedCount: validReports.length - reportsToAdd.length,
|
||||||
|
results: results.map((r) => ({ reportId: r.reportId, watchlistId: r.watchlistId })),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("already exists")) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "This entry already exists in the watchlist for your organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addToWatchlistHandler;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { WatchlistType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
export const ZAddToWatchlistInputSchema = z.object({
|
||||||
|
reportIds: z.array(z.string().uuid()).min(1),
|
||||||
|
type: z.nativeEnum(WatchlistType),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddToWatchlistInputSchema = z.infer<typeof ZAddToWatchlistInputSchema>;
|
||||||
+8
-8
@@ -33,7 +33,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockWatchlistRepo = {
|
const mockWatchlistRepo = {
|
||||||
findEntryWithAudit: vi.fn(),
|
findEntryWithAuditAndReports: vi.fn(),
|
||||||
deleteEntry: vi.fn(),
|
deleteEntry: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,12 +75,12 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
message: "You are not authorized to delete blocklist entries",
|
message: "You are not authorized to delete blocklist entries",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled();
|
expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should check permission with correct parameters", async () => {
|
it("should check permission with correct parameters", async () => {
|
||||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NOT_FOUND when entry does not exist", async () => {
|
it("should throw NOT_FOUND when entry does not exist", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: null,
|
entry: null,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -129,7 +129,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: { ...mockEntry, organizationId: 999 },
|
entry: { ...mockEntry, organizationId: 999 },
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -156,7 +156,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should successfully delete entry when authorized", async () => {
|
it("should successfully delete entry when authorized", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -175,7 +175,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should pass correct userId to deleteEntry", async () => {
|
it("should pass correct userId to deleteEntry", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -191,4 +191,4 @@ describe("deleteWatchlistEntryHandler", () => {
|
|||||||
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
|
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
|
|||||||
|
|
||||||
const watchlistRepo = new WatchlistRepository(prisma);
|
const watchlistRepo = new WatchlistRepository(prisma);
|
||||||
|
|
||||||
const { entry } = await watchlistRepo.findEntryWithAudit(input.id);
|
const { entry } = await watchlistRepo.findEntryWithAuditAndReports(input.id);
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||||
|
import { MembershipRole, BookingReportReason } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { dismissBookingReportHandler } from "./dismissBookingReport.handler";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||||
|
vi.mock("@calcom/lib/server/repository/bookingReport");
|
||||||
|
|
||||||
|
describe("dismissBookingReportHandler", () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "admin@example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
profile: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReport = {
|
||||||
|
id: "report-1",
|
||||||
|
bookingUid: "booking-uid-1",
|
||||||
|
bookerEmail: "spammer@example.com",
|
||||||
|
reportedById: 1,
|
||||||
|
reason: BookingReportReason.SPAM,
|
||||||
|
description: null,
|
||||||
|
cancelled: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
watchlistId: null,
|
||||||
|
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||||
|
booking: {
|
||||||
|
id: 1,
|
||||||
|
uid: "booking-uid-1",
|
||||||
|
title: "Test Booking",
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
},
|
||||||
|
watchlist: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPermissionCheckService = {
|
||||||
|
checkPermission: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReportRepo = {
|
||||||
|
findReportsByIds: vi.fn(),
|
||||||
|
updateReportStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(PermissionCheckService).mockImplementation(
|
||||||
|
() => mockPermissionCheckService as InstanceType<typeof PermissionCheckService>
|
||||||
|
);
|
||||||
|
vi.mocked(PrismaBookingReportRepository).mockImplementation(
|
||||||
|
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("access control", () => {
|
||||||
|
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||||
|
const userWithoutOrg = { ...mockUser, organizationId: null, profile: null };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dismissBookingReportHandler({
|
||||||
|
ctx: { user: userWithoutOrg },
|
||||||
|
input: { reportId: "report-1" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You must be part of an organization to dismiss booking reports",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dismissBookingReportHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: { reportId: "report-1" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to dismiss booking reports",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check for watchlist.update permission", async () => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await dismissBookingReportHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: { reportId: "report-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||||
|
userId: 1,
|
||||||
|
teamId: 100,
|
||||||
|
permission: "watchlist.update",
|
||||||
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("report verification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NOT_FOUND when report does not exist", async () => {
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dismissBookingReportHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: { reportId: "non-existent-report" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Booking report not found",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
|
||||||
|
reportIds: ["non-existent-report"],
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BAD_REQUEST when report already has a watchlist entry", async () => {
|
||||||
|
const blockedReport = { ...mockReport, watchlistId: "watchlist-123" };
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([blockedReport]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dismissBookingReportHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: { reportId: "report-1" },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot dismiss a report that has already been added to the blocklist",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockReportRepo.updateReportStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("successful dismissal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]);
|
||||||
|
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully dismiss a booking report after verifying it belongs to organization", async () => {
|
||||||
|
const result = await dismissBookingReportHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: { reportId: "report-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
|
||||||
|
reportIds: ["report-1"],
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||||
|
reportId: "report-1",
|
||||||
|
status: "DISMISSED",
|
||||||
|
organizationId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { TrpcSessionUser } from "../../../types";
|
||||||
|
import type { TDismissBookingReportInputSchema } from "./dismissBookingReport.schema";
|
||||||
|
|
||||||
|
type DismissBookingReportOptions = {
|
||||||
|
ctx: {
|
||||||
|
user: NonNullable<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
input: TDismissBookingReportInputSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dismissBookingReportHandler = async ({ ctx, input }: DismissBookingReportOptions) => {
|
||||||
|
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 dismiss booking reports",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionCheckService = new PermissionCheckService();
|
||||||
|
const hasPermission = await permissionCheckService.checkPermission({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: organizationId,
|
||||||
|
permission: "watchlist.update",
|
||||||
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to dismiss booking reports",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||||
|
|
||||||
|
const reports = await bookingReportRepo.findReportsByIds({
|
||||||
|
reportIds: [input.reportId],
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reports.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Booking report not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = reports[0];
|
||||||
|
|
||||||
|
if (report.watchlistId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot dismiss a report that has already been added to the blocklist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await bookingReportRepo.updateReportStatus({
|
||||||
|
reportId: input.reportId,
|
||||||
|
status: "DISMISSED",
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dismissBookingReportHandler;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ZDismissBookingReportInputSchema = z.object({
|
||||||
|
reportId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDismissBookingReportInputSchema = z.infer<typeof ZDismissBookingReportInputSchema>;
|
||||||
+15
-15
@@ -61,7 +61,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockWatchlistRepo = {
|
const mockWatchlistRepo = {
|
||||||
findEntryWithAudit: vi.fn(),
|
findEntryWithAuditAndReports: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserRepo = {
|
const mockUserRepo = {
|
||||||
@@ -94,7 +94,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
|
|
||||||
it("should use profile.organizationId when available", async () => {
|
it("should use profile.organizationId when available", async () => {
|
||||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: { ...mockEntry, organizationId: 200 },
|
entry: { ...mockEntry, organizationId: 200 },
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -135,12 +135,12 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
message: "You are not authorized to view blocklist entries",
|
message: "You are not authorized to view blocklist entries",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled();
|
expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should check permission with correct parameters", async () => {
|
it("should check permission with correct parameters", async () => {
|
||||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -167,7 +167,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NOT_FOUND when entry does not exist", async () => {
|
it("should throw NOT_FOUND when entry does not exist", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: null,
|
entry: null,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -186,7 +186,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: { ...mockEntry, organizationId: 999 },
|
entry: { ...mockEntry, organizationId: 999 },
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -211,7 +211,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return entry and audit history when authorized", async () => {
|
it("should return entry and audit history when authorized", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: mockAuditHistory,
|
auditHistory: mockAuditHistory,
|
||||||
});
|
});
|
||||||
@@ -226,11 +226,11 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
|
|
||||||
expect(result.entry).toEqual(mockEntry);
|
expect(result.entry).toEqual(mockEntry);
|
||||||
expect(result.auditHistory).toHaveLength(2);
|
expect(result.auditHistory).toHaveLength(2);
|
||||||
expect(mockWatchlistRepo.findEntryWithAudit).toHaveBeenCalledWith("entry-123");
|
expect(mockWatchlistRepo.findEntryWithAuditAndReports).toHaveBeenCalledWith("entry-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enrich audit history with user details", async () => {
|
it("should enrich audit history with user details", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: mockAuditHistory,
|
auditHistory: mockAuditHistory,
|
||||||
});
|
});
|
||||||
@@ -254,7 +254,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
{ ...mockAuditHistory[1], changedByUserId: 1 },
|
{ ...mockAuditHistory[1], changedByUserId: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: auditWithDuplicates,
|
auditHistory: auditWithDuplicates,
|
||||||
});
|
});
|
||||||
@@ -278,7 +278,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: auditWithNulls,
|
auditHistory: auditWithNulls,
|
||||||
});
|
});
|
||||||
@@ -302,7 +302,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: auditWithUndefined,
|
auditHistory: auditWithUndefined,
|
||||||
});
|
});
|
||||||
@@ -321,7 +321,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not call findUsersByIds when audit history is empty", async () => {
|
it("should not call findUsersByIds when audit history is empty", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: [],
|
auditHistory: [],
|
||||||
});
|
});
|
||||||
@@ -338,7 +338,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle when user is not found in user map", async () => {
|
it("should handle when user is not found in user map", async () => {
|
||||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||||
entry: mockEntry,
|
entry: mockEntry,
|
||||||
auditHistory: [mockAuditHistory[0]],
|
auditHistory: [mockAuditHistory[0]],
|
||||||
});
|
});
|
||||||
@@ -354,4 +354,4 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
|||||||
expect(result.auditHistory[0].changedByUser).toBeUndefined();
|
expect(result.auditHistory[0].changedByUser).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchli
|
|||||||
const watchlistRepo = new WatchlistRepository(prisma);
|
const watchlistRepo = new WatchlistRepository(prisma);
|
||||||
const userRepo = new UserRepository(prisma);
|
const userRepo = new UserRepository(prisma);
|
||||||
|
|
||||||
const result = await watchlistRepo.findEntryWithAudit(input.id);
|
const result = await watchlistRepo.findEntryWithAuditAndReports(input.id);
|
||||||
|
|
||||||
if (!result.entry) {
|
if (!result.entry) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||||
|
import { MembershipRole, BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { listBookingReportsHandler } from "./listBookingReports.handler";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||||
|
vi.mock("@calcom/lib/server/repository/bookingReport");
|
||||||
|
|
||||||
|
describe("listBookingReportsHandler (Organization)", () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "admin@example.com",
|
||||||
|
organizationId: 100,
|
||||||
|
profile: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReportData = {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "report-1",
|
||||||
|
bookingUid: "uid-1",
|
||||||
|
bookerEmail: "user1@example.com",
|
||||||
|
reportedById: 1,
|
||||||
|
reason: BookingReportReason.SPAM,
|
||||||
|
description: null,
|
||||||
|
cancelled: false,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
status: BookingReportStatus.PENDING,
|
||||||
|
watchlistId: null,
|
||||||
|
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||||
|
booking: {
|
||||||
|
id: 1,
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
title: "Test Booking",
|
||||||
|
uid: "uid-1",
|
||||||
|
},
|
||||||
|
watchlist: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "report-2",
|
||||||
|
bookingUid: "uid-2",
|
||||||
|
bookerEmail: "user2@example.com",
|
||||||
|
reportedById: 1,
|
||||||
|
reason: BookingReportReason.DONT_KNOW_PERSON,
|
||||||
|
description: "Unknown person",
|
||||||
|
cancelled: true,
|
||||||
|
createdAt: new Date("2025-01-02"),
|
||||||
|
status: BookingReportStatus.BLOCKED,
|
||||||
|
watchlistId: "watchlist-123",
|
||||||
|
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||||
|
booking: {
|
||||||
|
id: 2,
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime: new Date(),
|
||||||
|
title: "Test Booking 2",
|
||||||
|
uid: "uid-2",
|
||||||
|
},
|
||||||
|
watchlist: {
|
||||||
|
id: "watchlist-123",
|
||||||
|
type: "EMAIL" as const,
|
||||||
|
value: "user2@example.com",
|
||||||
|
action: "REPORT" as const,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { totalRowCount: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPermissionCheckService = {
|
||||||
|
checkPermission: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReportRepo = {
|
||||||
|
findAllReportedBookings: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(PermissionCheckService).mockImplementation(
|
||||||
|
() => mockPermissionCheckService as InstanceType<typeof PermissionCheckService>
|
||||||
|
);
|
||||||
|
vi.mocked(PrismaBookingReportRepository).mockImplementation(
|
||||||
|
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("access control", () => {
|
||||||
|
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||||
|
await expect(
|
||||||
|
listBookingReportsHandler({
|
||||||
|
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You must be part of an organization to view booking reports",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to view booking reports",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check permission with correct parameters", async () => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||||
|
userId: 1,
|
||||||
|
teamId: 100,
|
||||||
|
permission: "watchlist.read",
|
||||||
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("successful listing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return paginated booking reports for organization", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
const result = await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockReportData);
|
||||||
|
expect(result.rows).toHaveLength(2);
|
||||||
|
expect(result.meta.totalRowCount).toBe(2);
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 0,
|
||||||
|
take: 10,
|
||||||
|
searchTerm: undefined,
|
||||||
|
filters: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass search term to repository", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
searchTerm: "spam@example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 0,
|
||||||
|
take: 10,
|
||||||
|
searchTerm: "spam@example.com",
|
||||||
|
filters: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass filters to repository", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
reason: [BookingReportReason.SPAM, BookingReportReason.DONT_KNOW_PERSON],
|
||||||
|
cancelled: true,
|
||||||
|
hasWatchlist: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 20,
|
||||||
|
offset: 10,
|
||||||
|
filters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 10,
|
||||||
|
take: 20,
|
||||||
|
searchTerm: undefined,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return rows and meta from repository", async () => {
|
||||||
|
const customData = {
|
||||||
|
rows: [mockReportData.rows[0]],
|
||||||
|
meta: { totalRowCount: 1 },
|
||||||
|
};
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(customData);
|
||||||
|
|
||||||
|
const result = await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rows).toHaveLength(1);
|
||||||
|
expect(result.meta.totalRowCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pagination", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should correctly pass limit and offset to repository", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue({
|
||||||
|
rows: [],
|
||||||
|
meta: { totalRowCount: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 50,
|
||||||
|
offset: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 100,
|
||||||
|
take: 50,
|
||||||
|
searchTerm: undefined,
|
||||||
|
filters: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle first page correctly", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 25,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
searchTerm: undefined,
|
||||||
|
filters: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle pagination with search and filters", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 15,
|
||||||
|
offset: 30,
|
||||||
|
searchTerm: "test@example.com",
|
||||||
|
filters: { hasWatchlist: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 30,
|
||||||
|
take: 15,
|
||||||
|
searchTerm: "test@example.com",
|
||||||
|
filters: { hasWatchlist: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("organization scoping", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only return reports from user's organization", async () => {
|
||||||
|
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||||
|
|
||||||
|
await listBookingReportsHandler({
|
||||||
|
ctx: { user: mockUser },
|
||||||
|
input: {
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||||
|
organizationId: 100,
|
||||||
|
skip: 0,
|
||||||
|
take: 10,
|
||||||
|
searchTerm: undefined,
|
||||||
|
filters: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { TrpcSessionUser } from "../../../types";
|
||||||
|
import type { TListBookingReportsInputSchema } from "./listBookingReports.schema";
|
||||||
|
|
||||||
|
type ListBookingReportsOptions = {
|
||||||
|
ctx: {
|
||||||
|
user: NonNullable<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
input: TListBookingReportsInputSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listBookingReportsHandler = async ({ ctx, input }: ListBookingReportsOptions) => {
|
||||||
|
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 booking reports",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 booking reports",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||||
|
|
||||||
|
const result = await bookingReportRepo.findAllReportedBookings({
|
||||||
|
organizationId,
|
||||||
|
skip: input.offset,
|
||||||
|
take: input.limit,
|
||||||
|
searchTerm: input.searchTerm,
|
||||||
|
filters: input.filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default listBookingReportsHandler;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
export const ZListBookingReportsInputSchema = z.object({
|
||||||
|
limit: z.number().min(1).max(100).default(25),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
searchTerm: z.string().optional(),
|
||||||
|
filters: z
|
||||||
|
.object({
|
||||||
|
reason: z.array(z.nativeEnum(BookingReportReason)).optional(),
|
||||||
|
cancelled: z.boolean().optional(),
|
||||||
|
hasWatchlist: z.boolean().optional(),
|
||||||
|
status: z.array(z.nativeEnum(BookingReportStatus)).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TListBookingReportsInputSchema = z.infer<typeof ZListBookingReportsInputSchema>;
|
||||||
+1
-14
@@ -5,7 +5,6 @@
|
|||||||
"ALLOWED_HOSTNAMES",
|
"ALLOWED_HOSTNAMES",
|
||||||
"ANALYZE",
|
"ANALYZE",
|
||||||
"API_KEY_PREFIX",
|
"API_KEY_PREFIX",
|
||||||
"APP_USER_NAME",
|
|
||||||
"ATOMS_E2E_API_URL",
|
"ATOMS_E2E_API_URL",
|
||||||
"ATOMS_E2E_OAUTH_CLIENT_ID",
|
"ATOMS_E2E_OAUTH_CLIENT_ID",
|
||||||
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
|
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
|
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
|
||||||
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
|
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
|
||||||
"CALCOM_CREDENTIAL_SYNC_SECRET",
|
"CALCOM_CREDENTIAL_SYNC_SECRET",
|
||||||
"CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME",
|
|
||||||
"CALCOM_ENV",
|
"CALCOM_ENV",
|
||||||
"CALCOM_LICENSE_KEY",
|
"CALCOM_LICENSE_KEY",
|
||||||
"CALCOM_QA_EMAIL",
|
"CALCOM_QA_EMAIL",
|
||||||
@@ -72,13 +70,11 @@
|
|||||||
"EMAIL_SERVER_PORT",
|
"EMAIL_SERVER_PORT",
|
||||||
"EMAIL_SERVER_USER",
|
"EMAIL_SERVER_USER",
|
||||||
"EMAIL_SERVER",
|
"EMAIL_SERVER",
|
||||||
"ENABLE_EXPANDED_CACHE",
|
|
||||||
"EXCHANGE_DEFAULT_EWS_URL",
|
"EXCHANGE_DEFAULT_EWS_URL",
|
||||||
"FORMBRICKS_FEEDBACK_SURVEY_ID",
|
"FORMBRICKS_FEEDBACK_SURVEY_ID",
|
||||||
"AVATARAPI_USERNAME",
|
"AVATARAPI_USERNAME",
|
||||||
"AVATARAPI_PASSWORD",
|
"AVATARAPI_PASSWORD",
|
||||||
"GIPHY_API_KEY",
|
"GIPHY_API_KEY",
|
||||||
"GITHUB_API_REPO_TOKEN",
|
|
||||||
"GOOGLE_API_CREDENTIALS",
|
"GOOGLE_API_CREDENTIALS",
|
||||||
"GOOGLE_LOGIN_ENABLED",
|
"GOOGLE_LOGIN_ENABLED",
|
||||||
"GOOGLE_WEBHOOK_TOKEN",
|
"GOOGLE_WEBHOOK_TOKEN",
|
||||||
@@ -98,9 +94,7 @@
|
|||||||
"MOCK_PAYMENT_APP_ENABLED",
|
"MOCK_PAYMENT_APP_ENABLED",
|
||||||
"MS_GRAPH_CLIENT_ID",
|
"MS_GRAPH_CLIENT_ID",
|
||||||
"MS_GRAPH_CLIENT_SECRET",
|
"MS_GRAPH_CLIENT_SECRET",
|
||||||
"NEXT_PUBLIC_API_URL",
|
|
||||||
"NEXT_PUBLIC_APP_NAME",
|
"NEXT_PUBLIC_APP_NAME",
|
||||||
"NEXT_PUBLIC_AUTH_URL",
|
|
||||||
"NEXT_PUBLIC_CALCOM_VERSION",
|
"NEXT_PUBLIC_CALCOM_VERSION",
|
||||||
"NEXT_PUBLIC_COMPANY_NAME",
|
"NEXT_PUBLIC_COMPANY_NAME",
|
||||||
"NEXT_PUBLIC_CONSOLE_URL",
|
"NEXT_PUBLIC_CONSOLE_URL",
|
||||||
@@ -117,17 +111,14 @@
|
|||||||
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
|
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
|
||||||
"NEXT_PUBLIC_SENTRY_DSN",
|
"NEXT_PUBLIC_SENTRY_DSN",
|
||||||
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
||||||
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
|
|
||||||
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||||
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
|
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
|
||||||
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
|
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
|
||||||
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
|
||||||
"ORG_MONTHLY_CREDITS",
|
"ORG_MONTHLY_CREDITS",
|
||||||
"NEXT_PUBLIC_PLAIN_CHAT_ID",
|
|
||||||
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS",
|
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS",
|
||||||
"NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD",
|
"NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD",
|
||||||
"NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS",
|
"NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS",
|
||||||
"NEXT_PUBLIC_SWAGGER_DOCS_URL",
|
|
||||||
"NEXT_PUBLIC_TEAM_IMPERSONATION",
|
"NEXT_PUBLIC_TEAM_IMPERSONATION",
|
||||||
"NEXT_PUBLIC_VERCEL_URL",
|
"NEXT_PUBLIC_VERCEL_URL",
|
||||||
"NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE",
|
"NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE",
|
||||||
@@ -184,9 +175,6 @@
|
|||||||
"SLACK_SIGNING_SECRET",
|
"SLACK_SIGNING_SECRET",
|
||||||
"STRIPE_CLIENT_ID",
|
"STRIPE_CLIENT_ID",
|
||||||
"STRIPE_PRIVATE_KEY",
|
"STRIPE_PRIVATE_KEY",
|
||||||
"STRIPE_PRODUCT_ID_ESSENTIALS",
|
|
||||||
"STRIPE_PRODUCT_ID_SCALE",
|
|
||||||
"STRIPE_PRODUCT_ID_STARTER",
|
|
||||||
"STRIPE_WEBHOOK_SECRET",
|
"STRIPE_WEBHOOK_SECRET",
|
||||||
"STRIPE_WEBHOOK_SECRET_APPS",
|
"STRIPE_WEBHOOK_SECRET_APPS",
|
||||||
"STRIPE_WEBHOOK_SECRET_BILLING",
|
"STRIPE_WEBHOOK_SECRET_BILLING",
|
||||||
@@ -359,8 +347,7 @@
|
|||||||
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
||||||
"BUILD_STANDALONE",
|
"BUILD_STANDALONE",
|
||||||
"NEXT_PUBLIC_API_HITPAY_PRODUCTION",
|
"NEXT_PUBLIC_API_HITPAY_PRODUCTION",
|
||||||
"NEXT_PUBLIC_API_HITPAY_SANDBOX",
|
"NEXT_PUBLIC_API_HITPAY_SANDBOX"
|
||||||
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@calcom/web#dx": {
|
"@calcom/web#dx": {
|
||||||
|
|||||||
Reference in New Issue
Block a user