Compare commits

..
Author SHA1 Message Date
emrysal 0cf4ebd544 chore: release v5.8.8
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-11-03 12:03:36 +00:00
Dhairyashil Shinde 805c4d4c09 fix: dont allow email exclusion bypass with capital letters (#24741)
* fix: dont allow email exclusion bypass with capital letters

* trim before lowercasing to filter out empty tokens

* update console message

* use only regex change to implement this

* use only regex change to implement this
2025-11-03 11:59:14 +00:00
sean-brydon 99330b3117 chore: fixes UI nits in onboarding v3 flow (#24863)
* Add teams i18n

* Fix UI nits in invite flow

* chore: update redirect

* Update personal view settings
2025-11-03 11:45:27 +00:00
sean-brydon cc90a62090 feat: add banner for upgrade when using company email (#24829)
* Add banner plus mail icon

* Update MailIcon.tsx

* Update profile-view.tsx

* Fix weird line
2025-11-03 11:16:34 +00:00
sean-brydon 79bcd6dc30 feat: welcome to organizations modal after creation (#24823)
## What does this PR do?

Adds a welcome modal for new organizations that appears after organization creation. The modal showcases key features of the Organizations plan and provides a better onboarding experience.

## Visual Demo (For contributors especially)

#### Image Demo:

![CleanShot 2025-10-31 at 12.19.17.gif](https://app.graphite.dev/user-attachments/assets/4f8c3286-9400-40e6-aeb4-8a012f604c64.gif)

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code.
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. Create a new organization through either:
   - The onboarding flow
   - The settings/organizations/new page
   - The organization creation form

2. After successful creation and redirect, verify the welcome modal appears showing organization features.

3. Verify the modal can be closed by:
   - Clicking the "Continue" button
   - Clicking outside the modal
   - Pressing ESC key

4. Verify the modal doesn't reappear after being closed (query param and session storage should be cleared).

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-11-03 11:11:16 +00:00
chauhan_s c6daa61e5e fix: Maximum update depth exceeded error when entering the 6th digit of the email verification code. (#24857) 2025-11-03 11:07:20 +00:00
Udit TakkarandVolnei Munhoz b693b6898a feat: booking report table backend (#24794)
* feat: report table backend

* fix: types

* fix: types

* fix: bugs

* fix: type error

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-11-03 10:44:09 +00:00
Abir Roy fb55bc2dcd fix(docker): correct build context for calcom-api (#24849) 2025-11-03 10:38:33 +00:00
Udit TakkarandVolnei Munhoz 71bd140fe4 fix: add event type validation (#24429)
* fix: add event type validation

* fix: use isinteger

* fix: use isinteger

* fix: use isNumber

* fix: use Error

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-11-03 10:26:40 +00:00
Dhairyashil Shinde 4c5608147c feat: Add Preview buttons for every private link (#24623) 2025-11-03 08:17:31 +00:00
7d16da5c98 fix: sanitize username input to prevent hyperlinks (#24210)
* fix(username): sanitize input to prevent hyperlinks

* fix(username): sanitize input to prevent hyperlinks

* Implemented CodeRabbits comments

* clean username before saving and keep input consistent

* sanitize initial username on load to avoid mismatch with input

* remove comments

* remove comments

* Used existing slugify lib instead of creating new one

---------

Co-authored-by: Dhairyashil <dhairyashilshinde@xoxo-2.local>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-03 13:16:51 +05:30
spandev 0ae6e92558 fix: round robin settings update only on change (#24853) 2025-11-03 11:54:00 +05:30
Volnei Munhoz 052a38e39b cleanup unused env vars (#24819) 2025-10-31 12:14:54 -03:00
48 changed files with 2280 additions and 110 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
Fix Booker atom verify email dialog infinite re-render when entering verification code
View File
Whitespace-only changes.
@@ -13,6 +13,7 @@ import { fetchUsername } from "@calcom/lib/fetchUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
@@ -81,7 +82,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
useEffect(() => {
// 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]);
useEffect(() => {
@@ -105,7 +106,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
const updateUsername = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation());
await update({ username: inputUsernameValue });
const sanitizedUsername = slugify(inputUsernameValue || "");
await update({ username: sanitizedUsername });
setOpenDialogSaveUsername(false);
},
onError: (error) => {
@@ -171,11 +173,12 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
};
const saveUsername = () => {
const sanitizedUsername = slugify(inputUsernameValue || "");
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
updateUsername.mutate({
username: inputUsernameValue,
username: sanitizedUsername,
});
setCurrentUsername(inputUsernameValue);
setCurrentUsername(sanitizedUsername);
}
};
@@ -234,7 +237,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
if (searchParams?.toString() !== _searchParams.toString()) {
router.replace(`${pathname}?${_searchParams.toString()}`);
}
setInputUsernameValue(event.target.value);
const sanitized = slugify(event.target.value, true);
setInputUsernameValue(sanitized);
}}
data-testid="username-input"
/>
@@ -16,6 +16,7 @@ import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import slugify from "@calcom/lib/slugify";
import type { TRPCClientErrorLike } from "@trpc/client";
@@ -72,10 +73,11 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
const updateUsernameMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async () => {
const sanitizedUsername = slugify(inputUsernameValue || "");
onSuccessMutation && (await onSuccessMutation());
setOpenDialogSaveUsername(false);
setCurrentUsername(inputUsernameValue);
await update({ username: inputUsernameValue });
setCurrentUsername(sanitizedUsername);
await update({ username: sanitizedUsername });
},
onError: (error) => {
onErrorMutation && onErrorMutation(error);
@@ -108,8 +110,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
};
const updateUsername = async () => {
const sanitizedUsername = slugify(inputUsernameValue || "");
updateUsernameMutation.mutate({
username: inputUsernameValue,
username: sanitizedUsername,
});
};
@@ -134,7 +137,8 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
)}
onChange={(event) => {
event.preventDefault();
setInputUsernameValue(event.target.value);
const sanitized = slugify(event.target.value, true);
setInputUsernameValue(sanitized);
}}
data-testid="username-input"
{...rest}
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
@@ -56,7 +57,7 @@ export const UsernameAvailabilityField = ({
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
const formMethods = useForm({
defaultValues: {
username: currentUsername,
username: slugify(currentUsername || user.username || ""),
},
});
@@ -78,7 +79,11 @@ export const UsernameAvailabilityField = ({
setCurrentUsername={setCurrentUsername}
inputUsernameValue={value}
usernameRef={ref}
setInputUsernameValue={onChange}
setInputUsernameValue={(val) => {
const displayValue = slugify(val, true);
formMethods.setValue("username", displayValue);
onChange?.(displayValue);
}}
onSuccessMutation={onSuccessMutation}
onErrorMutation={onErrorMutation}
disabled={!!user.organization?.id}
@@ -35,7 +35,7 @@ export function useCreateTeam() {
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
const gettingStartedPath = flags["onboarding-v3"]
? "/onboarding/getting-started"
? "/onboarding/personal/settings"
: "/getting-started";
router.push(gettingStartedPath);
}
@@ -1,6 +1,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { CreationSource } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -25,14 +26,8 @@ export const useSubmitOnboarding = () => {
setError(null);
try {
const {
selectedPlan,
organizationDetails,
organizationBrand,
teams,
inviteRole,
resetOnboarding,
} = store;
const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
store;
if (selectedPlan !== "organization") {
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)
// Organization has already been created by the backend
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);
} catch (err) {
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">
<div className="flex w-full flex-col gap-4">
{/* Email and Team inputs */}
<div className="flex flex-col">
<div className="grid grid-cols-2 gap-2">
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-2">
<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>
{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 ">
<TextField
labelSrOnly
@@ -113,7 +113,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
form.setValue("name", e.target.value);
}}
placeholder="John Doe"
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
{form.formState.errors.name && (
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
@@ -128,7 +127,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
<TimezoneSelect
value={selectedTimeZone}
onChange={({ value }) => setSelectedTimeZone(value)}
size="sm"
/>
<p className="text-subtle text-xs font-normal leading-3">
{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>
);
};
@@ -0,0 +1,160 @@
export function MailIcon() {
return (
<svg width="124" height="110" viewBox="0 0 124 110" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<style>
{`:root {
--mail-envelope-start: white;
--mail-envelope-end: #F7F7F7;
--mail-border-start: #E5E5E6;
--mail-border-end: #B2B2B2;
--mail-badge-start: #F7F7F7;
--mail-badge-end: #D3D3D3;
--mail-badge-stroke: #E8E8E8;
--mail-badge-text: #6B7280;
--mail-circle-start: white;
--mail-circle-end: #EEEEEE;
--mail-circle-border-start: #E8E8E8;
--mail-circle-border-end: #B2B2B2;
--mail-at-start: #6B7280;
--mail-at-end: #3C3F43;
}
.dark {
--mail-envelope-start: #1c1917;
--mail-envelope-end: #292524;
--mail-border-start: #44403c;
--mail-border-end: #57534e;
--mail-badge-start: #292524;
--mail-badge-end: #1c1917;
--mail-badge-stroke: #44403c;
--mail-badge-text: #a8a29e;
--mail-circle-start: #292524;
--mail-circle-end: #1c1917;
--mail-circle-border-start: #44403c;
--mail-circle-border-end: #57534e;
--mail-at-start: #a8a29e;
--mail-at-end: #78716c;
}`}
</style>
<g>
<g>
<path
d="M17.3 18H91.7C96.815 18 101 22.1625 101 27.25V82.75C101 87.8375 96.815 92 91.7 92H17.3C12.185 92 8 87.8375 8 82.75V27.25C8 22.1625 12.185 18 17.3 18Z"
fill="url(#paint0_linear_3039_74057)"
/>
<path
d="M101 27.25L59.0711 56.4424C56.3238 58.3552 52.6762 58.3552 49.9289 56.4424L8 27.25"
fill="url(#paint0_linear_3039_74057)"
/>
<path
d="M101 27.25C101 22.1625 96.815 18 91.7 18H17.3C12.185 18 8 22.1625 8 27.25M101 27.25V82.75C101 87.8375 96.815 92 91.7 92H17.3C12.185 92 8 87.8375 8 82.75V27.25M101 27.25L59.0711 56.4424C56.3238 58.3552 52.6762 58.3552 49.9289 56.4424L8 27.25"
stroke="url(#paint2_linear_3039_74057)"
strokeWidth="1.63366"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<g opacity="0.6">
<path
d="M11 83C11 79.6863 13.6863 77 17 77H29C32.3137 77 35 79.6863 35 83C35 86.3137 32.3137 89 29 89H17C13.6863 89 11 86.3137 11 83Z"
fill="url(#paint3_linear_3039_74057)"
/>
<path
d="M17 77.25H29C32.1756 77.25 34.75 79.8244 34.75 83C34.75 86.1756 32.1756 88.75 29 88.75H17C13.8244 88.75 11.25 86.1756 11.25 83C11.25 79.8244 13.8244 77.25 17 77.25Z"
stroke="var(--mail-badge-stroke)"
strokeWidth="0.5"
/>
<path
d="M16.3807 85.0724C16.1733 85.0724 15.9858 85.0341 15.8182 84.9574C15.6506 84.8793 15.5178 84.7663 15.4197 84.6186C15.3232 84.4709 15.2749 84.2898 15.2749 84.0753C15.2749 83.8906 15.3104 83.7386 15.3814 83.6193C15.4524 83.5 15.5483 83.4055 15.669 83.3359C15.7898 83.2663 15.9247 83.2138 16.0739 83.1783C16.223 83.1428 16.375 83.1158 16.5298 83.0973C16.7259 83.0746 16.8849 83.0561 17.0071 83.0419C17.1293 83.0263 17.218 83.0014 17.2734 82.9673C17.3288 82.9332 17.3565 82.8778 17.3565 82.8011V82.7862C17.3565 82.6001 17.304 82.456 17.1989 82.3537C17.0952 82.2514 16.9403 82.2003 16.7344 82.2003C16.5199 82.2003 16.3509 82.2479 16.2273 82.343C16.1051 82.4368 16.0206 82.5412 15.9737 82.6562L15.375 82.5199C15.446 82.321 15.5497 82.1605 15.6861 82.0384C15.8239 81.9148 15.9822 81.8253 16.1612 81.7699C16.3402 81.7131 16.5284 81.6847 16.7259 81.6847C16.8565 81.6847 16.995 81.7003 17.1413 81.7315C17.2891 81.7614 17.4268 81.8168 17.5547 81.8977C17.6839 81.9787 17.7898 82.0945 17.8722 82.245C17.9545 82.3942 17.9957 82.5881 17.9957 82.8267V85H17.3736V84.5526H17.348C17.3068 84.6349 17.245 84.7159 17.1626 84.7955C17.0803 84.875 16.9744 84.9411 16.8452 84.9936C16.7159 85.0462 16.5611 85.0724 16.3807 85.0724ZM16.5192 84.5611C16.6953 84.5611 16.8459 84.5263 16.9709 84.4567C17.0973 84.3871 17.1932 84.2962 17.2585 84.1839C17.3253 84.0703 17.3587 83.9489 17.3587 83.8196V83.3977C17.3359 83.4205 17.2919 83.4418 17.2266 83.4616C17.1626 83.4801 17.0895 83.4964 17.0071 83.5107C16.9247 83.5234 16.8445 83.5355 16.7663 83.5469C16.6882 83.5568 16.6229 83.5653 16.5703 83.5724C16.4467 83.5881 16.3338 83.6143 16.2315 83.6513C16.1307 83.6882 16.0497 83.7415 15.9886 83.8111C15.929 83.8793 15.8991 83.9702 15.8991 84.0838C15.8991 84.2415 15.9574 84.3608 16.0739 84.4418C16.1903 84.5213 16.3388 84.5611 16.5192 84.5611ZM20.2228 85.0661C19.9061 85.0661 19.6333 84.9943 19.4047 84.8509C19.1774 84.706 19.0027 84.5064 18.8805 84.2521C18.7583 83.9979 18.6973 83.7067 18.6973 83.3786C18.6973 83.0462 18.7598 82.7528 18.8848 82.4986C19.0098 82.2429 19.1859 82.0433 19.4132 81.8999C19.6404 81.7564 19.9082 81.6847 20.2164 81.6847C20.465 81.6847 20.6866 81.7308 20.8812 81.8232C21.0758 81.9141 21.2328 82.0419 21.3521 82.2067C21.4728 82.3714 21.5446 82.5639 21.5673 82.7841H20.9473C20.9132 82.6307 20.835 82.4986 20.7129 82.3878C20.5922 82.277 20.4302 82.2216 20.2271 82.2216C20.0495 82.2216 19.894 82.2685 19.7605 82.3622C19.6284 82.4545 19.5254 82.5866 19.4515 82.7585C19.3777 82.929 19.3407 83.1307 19.3407 83.3636C19.3407 83.6023 19.377 83.8082 19.4494 83.9815C19.5218 84.1548 19.6241 84.2891 19.7562 84.3842C19.8897 84.4794 20.0467 84.527 20.2271 84.527C20.3478 84.527 20.4572 84.505 20.5552 84.4609C20.6547 84.4155 20.7377 84.3509 20.8045 84.267C20.8727 84.1832 20.9203 84.0824 20.9473 83.9645H21.5673C21.5446 84.1761 21.4757 84.3651 21.3606 84.5312C21.2456 84.6974 21.0914 84.8281 20.8983 84.9233C20.7065 85.0185 20.4814 85.0661 20.2228 85.0661ZM22.2486 85V81.7273H22.8601V82.2599H22.9006C22.9688 82.0795 23.0803 81.9389 23.2351 81.8381C23.3899 81.7358 23.5753 81.6847 23.7912 81.6847C24.0099 81.6847 24.1932 81.7358 24.3409 81.8381C24.4901 81.9403 24.6001 82.081 24.6712 82.2599H24.7053C24.7834 82.0852 24.9077 81.946 25.0781 81.8423C25.2486 81.7372 25.4517 81.6847 25.6875 81.6847C25.9844 81.6847 26.2266 81.7777 26.4141 81.9638C26.603 82.1499 26.6974 82.4304 26.6974 82.8054V85H26.0604V82.8651C26.0604 82.6435 26 82.483 25.8793 82.3835C25.7585 82.2841 25.6143 82.2344 25.4467 82.2344C25.2393 82.2344 25.0781 82.2983 24.9631 82.4261C24.848 82.5526 24.7905 82.7152 24.7905 82.9141V85H24.1555V82.8246C24.1555 82.647 24.1001 82.5043 23.9893 82.3963C23.8786 82.2884 23.7344 82.2344 23.5568 82.2344C23.4361 82.2344 23.3246 82.2663 23.2223 82.3303C23.1214 82.3928 23.0398 82.4801 22.9773 82.5923C22.9162 82.7045 22.8857 82.8345 22.8857 82.9822V85H22.2486ZM28.9618 85.0661C28.6394 85.0661 28.3617 84.9972 28.1287 84.8594C27.8972 84.7202 27.7182 84.5249 27.5918 84.2734C27.4668 84.0206 27.4043 83.7244 27.4043 83.3849C27.4043 83.0497 27.4668 82.7543 27.5918 82.4986C27.7182 82.2429 27.8944 82.0433 28.1202 81.8999C28.3475 81.7564 28.6131 81.6847 28.9171 81.6847C29.1017 81.6847 29.2807 81.7152 29.454 81.7763C29.6273 81.8374 29.7828 81.9332 29.9206 82.0639C30.0584 82.1946 30.1671 82.3643 30.2466 82.5732C30.3262 82.7805 30.3659 83.0327 30.3659 83.3295V83.5554H27.7644V83.0781H29.7417C29.7417 82.9105 29.7076 82.7621 29.6394 82.6328C29.5712 82.5021 29.4753 82.3991 29.3517 82.3239C29.2296 82.2486 29.0861 82.2109 28.9213 82.2109C28.7424 82.2109 28.5861 82.255 28.4526 82.343C28.3205 82.4297 28.2182 82.5433 28.1458 82.6839C28.0748 82.8232 28.0392 82.9744 28.0392 83.1378V83.5107C28.0392 83.7294 28.0776 83.9155 28.1543 84.0689C28.2324 84.2223 28.3411 84.3395 28.4803 84.4205C28.6195 84.5 28.7821 84.5398 28.9682 84.5398C29.089 84.5398 29.199 84.5227 29.2985 84.4886C29.3979 84.4531 29.4838 84.4006 29.5563 84.331C29.6287 84.2614 29.6841 84.1754 29.7225 84.0732L30.3255 84.1818C30.2772 84.3594 30.1905 84.5149Line truncated
fill="var(--mail-badge-text)"
/>
</g>
</g>
<g>
<path
d="M84 23C84 13.0589 92.0589 5 102 5C111.941 5 120 13.0589 120 23C120 32.9411 111.941 41 102 41C92.0589 41 84 32.9411 84 23Z"
fill="white"
/>
<path
d="M84 23C84 13.0589 92.0589 5 102 5C111.941 5 120 13.0589 120 23C120 32.9411 111.941 41 102 41C92.0589 41 84 32.9411 84 23Z"
fill="url(#paint4_linear_3039_74057)"
/>
<path
d="M102 5.81641C111.49 5.81641 119.184 13.51 119.184 23C119.184 32.49 111.49 40.1836 102 40.1836C92.51 40.1836 84.8164 32.49 84.8164 23C84.8164 13.51 92.51 5.81641 102 5.81641Z"
stroke="url(#paint5_linear_3039_74057)"
strokeWidth="1.63366"
/>
<g opacity="0.6">
<path
d="M105.63 19.3697V23.9076C105.63 24.6297 105.917 25.3223 106.428 25.8329C106.938 26.3435 107.631 26.6304 108.353 26.6304C109.075 26.6304 109.768 26.3435 110.278 25.8329C110.789 25.3223 111.076 24.6297 111.076 23.9076V23C111.076 20.9516 110.383 18.9635 109.109 17.3589C107.836 15.7544 106.057 14.6277 104.063 14.1622C102.068 13.6966 99.9742 13.9196 98.1222 14.7948C96.2702 15.67 94.7687 17.146 93.8619 18.9828C92.955 20.8195 92.6962 22.909 93.1275 24.9115C93.5588 26.914 94.6548 28.7117 96.2374 30.0123C97.8199 31.3129 99.7958 32.0399 101.844 32.0752C103.892 32.1104 105.892 31.4517 107.518 30.2063M105.63 23C105.63 25.005 104.005 26.6304 102 26.6304C99.995 26.6304 98.3696 25.005 98.3696 23C98.3696 20.995 99.995 19.3697 102 19.3697C104.005 19.3697 105.63 20.995 105.63 23Z"
stroke="url(#paint6_linear_3039_74057)"
strokeWidth="1.63366"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</g>
<defs>
<linearGradient
id="paint0_linear_3039_74057"
x1="54.5"
y1="18"
x2="54.5"
y2="92"
gradientUnits="userSpaceOnUse">
<stop stopColor="var(--mail-envelope-start)" />
<stop offset="1" stopColor="var(--mail-envelope-end)" />
</linearGradient>
<linearGradient
id="paint2_linear_3039_74057"
x1="54.5"
y1="18"
x2="54.5"
y2="92"
gradientUnits="userSpaceOnUse">
<stop offset="0.195" stopColor="var(--mail-border-start)" />
<stop offset="1" stopColor="var(--mail-border-end)" />
</linearGradient>
<linearGradient
id="paint3_linear_3039_74057"
x1="23"
y1="77"
x2="23"
y2="89"
gradientUnits="userSpaceOnUse">
<stop stopColor="var(--mail-badge-start)" />
<stop offset="1" stopColor="var(--mail-badge-end)" />
</linearGradient>
<linearGradient
id="paint4_linear_3039_74057"
x1="102"
y1="5"
x2="102"
y2="41"
gradientUnits="userSpaceOnUse">
<stop offset="0.592418" stopColor="var(--mail-circle-start)" />
<stop offset="1" stopColor="var(--mail-circle-end)" />
</linearGradient>
<linearGradient
id="paint5_linear_3039_74057"
x1="102"
y1="5"
x2="102"
y2="41"
gradientUnits="userSpaceOnUse">
<stop stopColor="var(--mail-circle-border-start)" />
<stop offset="1" stopColor="var(--mail-circle-border-end)" />
</linearGradient>
<linearGradient
id="paint6_linear_3039_74057"
x1="102"
y1="13.9247"
x2="102"
y2="32.0765"
gradientUnits="userSpaceOnUse">
<stop stopColor="var(--mail-at-start)" />
<stop offset="1" stopColor="var(--mail-at-end)" />
</linearGradient>
</defs>
</svg>
);
}
@@ -12,6 +12,7 @@ import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
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 SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
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 { CompanyEmailOrganizationBanner } from "./components/CompanyEmailOrganizationBanner";
interface DeleteAccountValues {
totpCode: string;
}
@@ -72,7 +75,8 @@ type Props = {
const ProfileView = ({ user }: Props) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { update } = useSession();
const session = useSession();
const { update } = session;
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
@@ -138,6 +142,7 @@ const ProfileView = ({ user }: Props) => {
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const [isCompanyEmailAlertDismissed, setIsCompanyEmailAlertDismissed] = useState(false);
const form = useForm<DeleteAccountValues>();
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 (
<SettingsHeader
title={t("profile")}
@@ -301,6 +314,12 @@ const ProfileView = ({ user }: Props) => {
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">
<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>
@@ -5,6 +5,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
@@ -52,7 +53,9 @@ const useOrgCreation = () => {
await utils.viewer.organizations.listCurrent.invalidate();
await session.update();
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 {
// Unexpected state
setServerErrorMessage("Unexpected response from server");
@@ -28,7 +28,7 @@ const PaymentStatusView = () => {
if (organization) {
setOrganizationCreated(true);
// Organization is created, redirect to next step
router.push(`/settings/organizations`);
router.push(`/settings/organizations?newOrganizationModal=true`);
}
}, [organization, router, useOnboardingStore]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.8.7",
"version": "5.8.8",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -1692,6 +1692,8 @@
"profile_picture": "Profile Picture",
"upload": "Upload",
"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",
"blockchain": "Blockchain",
"old_password": "Old password",
@@ -2494,6 +2496,7 @@
"additional_url_parameters": "Additional URL parameters",
"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.",
"create_your_team": "Create your team",
"create_your_teams": "Create your teams",
"create_your_teams_description": "Start scheduling together by adding your team members to your organization",
"invite_organization_admins": "Invite organization members",
@@ -2527,6 +2530,15 @@
"resolve": "Resolve",
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
"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_team": "The team",
"404_claim_entity_org": "Claim your subdomain for your organization",
@@ -3906,13 +3918,18 @@
"view_previous_week": "View previous week",
"view_next_week": "View next week",
"invite_teammates": "Invite Teammates",
"invite_team_members": "Invite team members",
"organization_brand": "Organization Brand",
"organization_details": "Organization Details",
"organization_teams": "Organization Teams",
"personal_profile": "Personal Profile",
"personal_settings": "Personal Settings",
"customize_team_brand": "Customize your team brand",
"team_brand": "Team Brand",
"team_brand_subtitle": "Customize your team's branding with a logo and color",
"team_details": "Team Details",
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1 -1
View File
@@ -68,7 +68,7 @@ services:
calcom-api:
container_name: calcom-api
build:
context: ./calcom
context: .
dockerfile: apps/api/v2/Dockerfile
args:
DATABASE_URL: ${DATABASE_URL}
@@ -68,6 +68,7 @@ export const VerifyCodeDialog = ({
value,
email,
verifyCodeWithSessionNotRequired,
setVerificationCode,
]);
useEffect(() => {
@@ -77,7 +78,8 @@ export const VerifyCodeDialog = ({
if (hasVerified || error || isPending || !/^\d{6}$/.test(value.trim())) return;
verifyCode();
}, [error, isPending, value, verifyCode, hasVerified]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, isPending, value, hasVerified]);
useEffect(() => setValue(""), [isOpenDialog]);
@@ -33,6 +33,10 @@ export class BotDetectionService {
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
const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({
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 utils = trpc.useUtils();
const initialResetInterval = rrResetInterval ?? RRResetInterval.MONTH;
const initialTimestampBasis = team?.rrTimestampBasis ?? RRTimestampBasis.CREATED_AT;
const form = useForm<{ rrResetInterval: RRResetInterval; rrTimestampBasis: RRTimestampBasis }>({
defaultValues: {
rrResetInterval: rrResetInterval ?? RRResetInterval.MONTH,
rrTimestampBasis: team?.rrTimestampBasis ?? RRTimestampBasis.CREATED_AT,
rrResetInterval: initialResetInterval,
rrTimestampBasis: initialTimestampBasis,
},
});
const [watchedResetInterval, watchedTimestampBasis] = form.watch([
"rrResetInterval",
"rrTimestampBasis",
]);
const hasChanges =
watchedResetInterval !== initialResetInterval || watchedTimestampBasis !== initialTimestampBasis;
const mutation = trpc.viewer.teams.update.useMutation({
onError: (err) => {
showToast(err.message, "error");
@@ -85,7 +95,7 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
</div>
</div>
<SectionBottomActions align="end">
<Button type="submit" color="primary" loading={mutation.isPending}>
<Button type="submit" disabled={!hasChanges} color="primary" loading={mutation.isPending}>
{t("update")}
</Button>
</SectionBottomActions>
@@ -293,6 +293,20 @@ export const MultiplePrivateLinksController = ({
}
/>
<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 && (
<Button
type="button"
+22
View File
@@ -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 */}
</>
);
}
+2
View File
@@ -18,6 +18,7 @@ import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { CalAiBanner } from "./CalAiBanner";
import { DynamicModals } from "./DynamicModals";
import { SideBarContainer } from "./SideBar";
import { TopNavContainer } from "./TopNav";
import { BannerContainer } from "./banners/LayoutBanner";
@@ -38,6 +39,7 @@ const Layout = (props: LayoutProps) => {
</div>
<TimezoneChangeDialog />
<DynamicModals />
<div className="flex min-h-screen flex-col">
{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 {
bookingUid: string;
@@ -18,8 +18,69 @@ export interface BookingReportSummary {
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 {
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(),
findMany: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
} as unknown as PrismaClient;
@@ -94,36 +96,112 @@ describe("PrismaBookingReportRepository", () => {
const mockReports = [
{
id: "report-1",
bookingUid: "booking-1",
bookerEmail: "user1@example.com",
reportedById: 1,
reason: BookingReportReason.SPAM,
description: "Spam",
cancelled: false,
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",
bookingUid: "booking-2",
bookerEmail: "user2@example.com",
reportedById: 2,
reason: BookingReportReason.DONT_KNOW_PERSON,
description: null,
cancelled: false,
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.count.mockResolvedValue(2);
const result = await repository.findAllReportedBookings({ skip: 0, take: 10 });
expect(result).toEqual(mockReports);
expect(mockPrisma.bookingReport.findMany).toHaveBeenCalledWith({
skip: 0,
take: 10,
select: {
id: true,
reportedById: true,
reason: true,
description: true,
createdAt: true,
expect(result).toEqual({
rows: mockReports.map((r) => ({ ...r, reporter: r.reportedBy })),
meta: { totalRowCount: 2 },
});
expect(mockPrisma.bookingReport.findMany).toHaveBeenCalled();
expect(mockPrisma.bookingReport.count).toHaveBeenCalled();
});
});
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" },
});
});
});
+178 -6
View File
@@ -1,9 +1,11 @@
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/generated/prisma/client";
import type {
IBookingReportRepository,
CreateBookingReportInput,
BookingReportSummary,
BookingReportWithDetails,
ListBookingReportsFilters,
} from "./bookingReport.interface";
export class PrismaBookingReportRepository implements IBookingReportRepository {
@@ -25,19 +27,189 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
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({
skip: params.skip,
take: params.take,
where,
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" },
});
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 {
type: WatchlistType;
value: string;
organizationId: number;
organizationId: number | null;
action: WatchlistAction;
description?: string;
userId: number;
isGlobal?: boolean;
}
export interface CheckWatchlistInput {
type: WatchlistType;
value: string;
organizationId: number;
organizationId?: number | null;
isGlobal?: boolean;
}
export interface FindAllEntriesInput {
@@ -59,8 +61,17 @@ export interface IWatchlistRepository {
})[];
meta: { totalRowCount: number };
}>;
findEntryWithAudit(id: string): Promise<{
entry: WatchlistEntry | null;
findEntryWithAuditAndReports(id: string): Promise<{
entry:
| (WatchlistEntry & {
bookingReports?: Array<{
booking: {
uid: string;
title: string | null;
};
}>;
})
| null;
auditHistory: WatchlistAuditEntry[];
}>;
deleteEntry(id: string, userId: number): Promise<void>;
@@ -18,6 +18,7 @@ export class WatchlistRepository implements IWatchlistRepository {
type: params.type,
value: params.value,
organizationId: params.organizationId,
isGlobal: params.isGlobal,
});
if (existing) {
@@ -33,7 +34,7 @@ export class WatchlistRepository implements IWatchlistRepository {
action: params.action,
description: params.description,
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> {
const entry = await this.prismaClient.watchlist.findUnique({
where: {
type_value_organizationId: {
if (!params.isGlobal && !params.organizationId) {
throw new Error("Both isGlobal and organizationId are missing");
}
if (params.isGlobal) {
const entry = await this.prismaClient.watchlist.findFirst({
where: {
type: params.type,
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<{
@@ -123,8 +142,17 @@ export class WatchlistRepository implements IWatchlistRepository {
};
}
async findEntryWithAudit(id: string): Promise<{
entry: WatchlistEntry | null;
async findEntryWithAuditAndReports(id: string): Promise<{
entry:
| (WatchlistEntry & {
bookingReports?: Array<{
booking: {
uid: string;
title: string | null;
};
}>;
})
| null;
auditHistory: WatchlistAuditEntry[];
}> {
const entry = await this.prismaClient.watchlist.findUnique({
@@ -139,6 +167,16 @@ export class WatchlistRepository implements IWatchlistRepository {
isGlobal: true,
source: true,
lastUpdatedAt: true,
bookingReports: {
select: {
booking: {
select: {
uid: true,
title: true,
},
},
},
},
audits: {
select: {
id: true,
@@ -169,6 +207,7 @@ export class WatchlistRepository implements IWatchlistRepository {
isGlobal: entry.isGlobal,
source: entry.source,
lastUpdatedAt: entry.lastUpdatedAt,
bookingReports: entry.bookingReports,
}
: null,
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';
+7
View File
@@ -2366,6 +2366,12 @@ enum BookingReportReason {
OTHER
}
enum BookingReportStatus {
PENDING // Initial state - awaiting review
DISMISSED // User clicked "Don't block"
BLOCKED // Added to watchlist
}
model BookingReport {
id String @id @default(uuid()) @db.Uuid
bookingUid String @unique
@@ -2378,6 +2384,7 @@ model BookingReport {
cancelled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status BookingReportStatus @default(PENDING)
watchlistId String? @db.Uuid
+1 -1
View File
@@ -796,7 +796,7 @@ export type FieldType = z.infer<typeof fieldTypeEnum>;
export const excludeOrRequireEmailSchema = z.string().superRefine((val, ctx) => {
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 ]
@@ -26,6 +26,9 @@ import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
import { ZGetUserInput } from "./getUser.schema";
import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.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 { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
@@ -191,5 +194,23 @@ export const viewerOrganizationsRouter = router({
const { default: handler } = await import("./getWatchlistEntryDetails.handler");
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>;
@@ -33,7 +33,7 @@ describe("deleteWatchlistEntryHandler", () => {
};
const mockWatchlistRepo = {
findEntryWithAudit: vi.fn(),
findEntryWithAuditAndReports: vi.fn(),
deleteEntry: vi.fn(),
};
@@ -75,12 +75,12 @@ describe("deleteWatchlistEntryHandler", () => {
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 () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
@@ -108,7 +108,7 @@ describe("deleteWatchlistEntryHandler", () => {
});
it("should throw NOT_FOUND when entry does not exist", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: null,
auditHistory: [],
});
@@ -129,7 +129,7 @@ describe("deleteWatchlistEntryHandler", () => {
});
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: { ...mockEntry, organizationId: 999 },
auditHistory: [],
});
@@ -156,7 +156,7 @@ describe("deleteWatchlistEntryHandler", () => {
});
it("should successfully delete entry when authorized", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
@@ -175,7 +175,7 @@ describe("deleteWatchlistEntryHandler", () => {
});
it("should pass correct userId to deleteEntry", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
@@ -191,4 +191,4 @@ describe("deleteWatchlistEntryHandler", () => {
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
});
});
});
});
@@ -43,7 +43,7 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
const watchlistRepo = new WatchlistRepository(prisma);
const { entry } = await watchlistRepo.findEntryWithAudit(input.id);
const { entry } = await watchlistRepo.findEntryWithAuditAndReports(input.id);
if (!entry) {
throw new TRPCError({
@@ -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>;
@@ -61,7 +61,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
};
const mockWatchlistRepo = {
findEntryWithAudit: vi.fn(),
findEntryWithAuditAndReports: vi.fn(),
};
const mockUserRepo = {
@@ -94,7 +94,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
it("should use profile.organizationId when available", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: { ...mockEntry, organizationId: 200 },
auditHistory: [],
});
@@ -135,12 +135,12 @@ describe("getWatchlistEntryDetailsHandler", () => {
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 () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
@@ -167,7 +167,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
});
it("should throw NOT_FOUND when entry does not exist", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: null,
auditHistory: [],
});
@@ -186,7 +186,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
});
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: { ...mockEntry, organizationId: 999 },
auditHistory: [],
});
@@ -211,7 +211,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
});
it("should return entry and audit history when authorized", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: mockAuditHistory,
});
@@ -226,11 +226,11 @@ describe("getWatchlistEntryDetailsHandler", () => {
expect(result.entry).toEqual(mockEntry);
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 () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: mockAuditHistory,
});
@@ -254,7 +254,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
{ ...mockAuditHistory[1], changedByUserId: 1 },
];
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: auditWithDuplicates,
});
@@ -278,7 +278,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
{ ...mockAuditHistory[1], changedByUserId: 2 },
];
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: auditWithNulls,
});
@@ -302,7 +302,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
{ ...mockAuditHistory[1], changedByUserId: 2 },
];
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: auditWithUndefined,
});
@@ -321,7 +321,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
});
it("should not call findUsersByIds when audit history is empty", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [],
});
@@ -338,7 +338,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
});
it("should handle when user is not found in user map", async () => {
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
entry: mockEntry,
auditHistory: [mockAuditHistory[0]],
});
@@ -354,4 +354,4 @@ describe("getWatchlistEntryDetailsHandler", () => {
expect(result.auditHistory[0].changedByUser).toBeUndefined();
});
});
});
});
@@ -45,7 +45,7 @@ export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchli
const watchlistRepo = new WatchlistRepository(prisma);
const userRepo = new UserRepository(prisma);
const result = await watchlistRepo.findEntryWithAudit(input.id);
const result = await watchlistRepo.findEntryWithAuditAndReports(input.id);
if (!result.entry) {
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
View File
@@ -5,7 +5,6 @@
"ALLOWED_HOSTNAMES",
"ANALYZE",
"API_KEY_PREFIX",
"APP_USER_NAME",
"ATOMS_E2E_API_URL",
"ATOMS_E2E_OAUTH_CLIENT_ID",
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
@@ -23,7 +22,6 @@
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
"CALCOM_CREDENTIAL_SYNC_SECRET",
"CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME",
"CALCOM_ENV",
"CALCOM_LICENSE_KEY",
"CALCOM_QA_EMAIL",
@@ -72,13 +70,11 @@
"EMAIL_SERVER_PORT",
"EMAIL_SERVER_USER",
"EMAIL_SERVER",
"ENABLE_EXPANDED_CACHE",
"EXCHANGE_DEFAULT_EWS_URL",
"FORMBRICKS_FEEDBACK_SURVEY_ID",
"AVATARAPI_USERNAME",
"AVATARAPI_PASSWORD",
"GIPHY_API_KEY",
"GITHUB_API_REPO_TOKEN",
"GOOGLE_API_CREDENTIALS",
"GOOGLE_LOGIN_ENABLED",
"GOOGLE_WEBHOOK_TOKEN",
@@ -98,9 +94,7 @@
"MOCK_PAYMENT_APP_ENABLED",
"MS_GRAPH_CLIENT_ID",
"MS_GRAPH_CLIENT_SECRET",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_APP_NAME",
"NEXT_PUBLIC_AUTH_URL",
"NEXT_PUBLIC_CALCOM_VERSION",
"NEXT_PUBLIC_COMPANY_NAME",
"NEXT_PUBLIC_CONSOLE_URL",
@@ -117,17 +111,14 @@
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
"ORG_MONTHLY_CREDITS",
"NEXT_PUBLIC_PLAIN_CHAT_ID",
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS",
"NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD",
"NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS",
"NEXT_PUBLIC_SWAGGER_DOCS_URL",
"NEXT_PUBLIC_TEAM_IMPERSONATION",
"NEXT_PUBLIC_VERCEL_URL",
"NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE",
@@ -184,9 +175,6 @@
"SLACK_SIGNING_SECRET",
"STRIPE_CLIENT_ID",
"STRIPE_PRIVATE_KEY",
"STRIPE_PRODUCT_ID_ESSENTIALS",
"STRIPE_PRODUCT_ID_SCALE",
"STRIPE_PRODUCT_ID_STARTER",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET_APPS",
"STRIPE_WEBHOOK_SECRET_BILLING",
@@ -359,8 +347,7 @@
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
"BUILD_STANDALONE",
"NEXT_PUBLIC_API_HITPAY_PRODUCTION",
"NEXT_PUBLIC_API_HITPAY_SANDBOX",
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS"
"NEXT_PUBLIC_API_HITPAY_SANDBOX"
]
},
"@calcom/web#dx": {