feat: report booking (#24324)

* feat: report booking

* refactor: improvements

* test: add unit test

* test: add unit test

* chore

* refactor: feedback

* refactor: feedback

* refactor: feedback

* fix: use string

* fix: schema

* chore: improvements

* refactor: create service

* fix: udate test

* fix: feedback

* fix: schema

* fix: type

* fix: type

* refactor: address feedback

* refactor: move to new file

* refactor: move to new file

* chor: remove

* fix UserRepository import

* fix type of bookingUid

* fix: import path

* fix: remove cancellation

* chore: duplicate

* fix: tests

* refactor: feedback

* chore: remove table from here

* fix: types

* fix: types

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Udit Takkar
2025-10-14 09:23:31 +01:00
committed by GitHub
co-authored by CarinaWolli
parent a2bee76da6
commit 06d6c180d1
19 changed files with 1272 additions and 4 deletions
@@ -52,6 +52,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
@@ -60,9 +61,11 @@ import {
getCancelEventAction,
getEditEventActions,
getAfterEventActions,
getReportAction,
shouldShowPendingActions,
shouldShowEditActions,
shouldShowRecurringCancelAction,
shouldShowIndividualReportButton,
type BookingActionContext,
} from "./bookingActions";
@@ -181,6 +184,13 @@ function BookingListItem(booking: BookingItemProps) {
const isPending = booking.status === BookingStatus.PENDING;
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
if (isCancelled) return "cancelled";
if (isRejected) return "rejected";
if (isBookingInPast) return "past";
return "upcoming";
};
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
@@ -292,6 +302,7 @@ function BookingListItem(booking: BookingItemProps) {
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
@@ -402,6 +413,12 @@ function BookingListItem(booking: BookingItemProps) {
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
...reportAction,
onClick: () => setIsOpenReportDialog(true),
};
return (
<>
<RescheduleDialog
@@ -430,6 +447,13 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
<ReportBookingDialog
isOpenDialog={isOpenReportDialog}
setIsOpenDialog={setIsOpenReportDialog}
bookingUid={booking.uid}
isRecurring={isRecurring}
status={getBookingStatus()}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
@@ -685,6 +709,24 @@ function BookingListItem(booking: BookingItemProps) {
</DropdownItem>
</DropdownMenuItem>
))}
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={reportActionWithHandler.id}
disabled={reportActionWithHandler.disabled}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
@@ -708,6 +750,21 @@ function BookingListItem(booking: BookingItemProps) {
</Dropdown>
)}
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
<Button
type="button"
variant="icon"
color="destructive"
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="h-8 w-8"
tooltip={reportActionWithHandler.label}
/>
</div>
)}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
@@ -776,6 +833,24 @@ const BookingItemBadges = ({
{booking?.assignmentReason.length > 0 && (
<AssignmentReasonTooltip assignmentReason={booking.assignmentReason[0]} />
)}
{booking.report && (
<Tooltip
content={
<div className="text-xs">
{(() => {
const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`;
const reasonText = t(reasonKey);
return booking.report.description
? `${reasonText}: ${booking.report.description}`
: reasonText;
})()}
</div>
}>
<Badge className="ltr:mr-2 rtl:ml-2" variant="red">
{t("reported")}
</Badge>
</Tooltip>
)}
{booking.paid && !booking.payment[0] ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("error_collecting_card")}
+20 -3
View File
@@ -165,6 +165,18 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
return actions.filter(Boolean) as ActionType[];
}
export function getReportAction(context: BookingActionContext): ActionType {
const { booking, t } = context;
return {
id: "report",
label: t("report_booking"),
icon: "flag",
color: "destructive",
disabled: !!booking.report,
};
}
export function getAfterEventActions(context: BookingActionContext): ActionType[] {
const { booking, cardCharged, attendeeList, t } = context;
@@ -205,9 +217,14 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext):
return isTabRecurring && isRecurring;
}
export function shouldShowIndividualReportButton(context: BookingActionContext): boolean {
const { booking, isPending, isUpcoming, isCancelled, isRejected } = context;
const hasDropdown = shouldShowEditActions(context);
return !booking.report && !hasDropdown && (isCancelled || isRejected || (isPending && isUpcoming));
}
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
context;
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
switch (actionId) {
case "reschedule":
@@ -227,7 +244,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
}
export function getActionLabel(actionId: string, context: BookingActionContext): string {
const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
const { isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
switch (actionId) {
case "reject":
@@ -0,0 +1,139 @@
import type { Dispatch, SetStateAction } from "react";
import { Controller, useForm } from "react-hook-form";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingReportReason } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Select, Label } from "@calcom/ui/components/form";
import { TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
type BookingReportStatus = "upcoming" | "past" | "cancelled" | "rejected";
interface IReportBookingDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
bookingUid: string;
isRecurring: boolean;
status: BookingReportStatus;
}
interface FormValues {
reason: BookingReportReason;
description: string;
}
export const ReportBookingDialog = (props: IReportBookingDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { isOpenDialog, setIsOpenDialog, bookingUid, status } = props;
const willBeCancelled = status === "upcoming";
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
reason: BookingReportReason.SPAM,
description: "",
},
});
const { mutate: reportBooking, isPending } = trpc.viewer.bookings.reportBooking.useMutation({
async onSuccess(data) {
showToast(data.message, "success");
setIsOpenDialog(false);
await utils.viewer.bookings.invalidate();
},
onError(error) {
showToast(error.message || t("unexpected_error_try_again"), "error");
},
});
const onSubmit = (data: FormValues) => {
reportBooking({
bookingUid,
reason: data.reason,
description: data.description || undefined,
});
};
const reasonOptions = [
{ label: t("report_reason_spam"), value: BookingReportReason.SPAM },
{ label: t("report_reason_dont_know_person"), value: BookingReportReason.DONT_KNOW_PERSON },
{ label: t("report_reason_other"), value: BookingReportReason.OTHER },
];
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-row space-x-3">
<div className="w-full">
<DialogHeader title={t("report_booking")} subtitle={t("report_booking_description")} />
<div className="mb-4">
<Label htmlFor="reason" className="text-emphasis mb-2 block text-sm font-medium">
{t("reason")} <span className="text-destructive">*</span>
</Label>
<Controller
name="reason"
control={control}
rules={{ required: t("field_required") }}
render={({ field }) => (
<Select
{...field}
options={reasonOptions}
onChange={(option) => {
if (option) field.onChange(option.value);
}}
value={reasonOptions.find((opt) => opt.value === field.value)}
/>
)}
/>
{errors.reason && <p className="text-destructive mt-1 text-sm">{errors.reason.message}</p>}
</div>
<div className="mb-4">
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
</Label>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextArea {...field} placeholder={t("report_booking_description_placeholder")} rows={3} />
)}
/>
</div>
{willBeCancelled && (
<div className="mb-4">
<Alert severity="warning" title={t("report_booking_will_cancel_description")} />
</div>
)}
</div>
</div>
<DialogFooter showDivider className="mt-8">
<Button
type="button"
color="secondary"
onClick={() => setIsOpenDialog(false)}
disabled={isPending}>
{t("cancel")}
</Button>
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
{t("submit_report")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -86,6 +86,14 @@
"rejection_reason_title": "Reject the booking request?",
"rejection_reason_description": "Are you sure you want to reject the booking? We'll let the person who tried to book know. You can provide a reason below.",
"rejection_confirmation": "Reject the booking",
"report_booking_description": "Report this booking as suspicious. This helps us identify and prevent spam or unwanted bookings.",
"report_reason_spam": "Spam or unwanted booking",
"report_reason_dont_know_person": "I don't know this person",
"report_reason_other": "Other",
"cancel_booking_when_reporting": "Cancel this booking",
"report_all_remaining_bookings": "Report all remaining bookings in series",
"submit_report": "Submit Report",
"report_booking_description_placeholder": "Please provide additional details about why you're reporting this booking (optional)",
"manage_this_event": "Manage this event",
"invite_team_member": "Invite team member",
"invite_team_individual_segment": "Invite individual",
@@ -759,6 +767,10 @@
"cancel_all_remaining": "Cancel all remaining",
"apply": "Apply",
"cancel_event": "Cancel event",
"report_booking": "Report booking",
"report_booking_will_cancel_description": "Reporting this booking will automatically cancel it",
"add_to_report": "Add to report",
"reported": "Reported",
"continue": "Continue",
"confirm": "Confirm",
"confirm_all": "Confirm all",
@@ -79,6 +79,7 @@ async function handler(input: CancelBookingInput) {
cancelledBy,
cancelSubsequentBookings,
internalNote,
skipCancellationReasonValidation = false,
} = bookingCancelInput.parse(body);
const bookingToDelete = await getBookingToDelete(id, uid);
const {
@@ -117,7 +118,7 @@ async function handler(input: CancelBookingInput) {
const isCancellationUserHost =
bookingToDelete.userId == userId || bookingToDelete.user.email === cancelledBy;
if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost) {
if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost && !skipCancellationReasonValidation) {
throw new HttpError({
statusCode: 400,
message: "Cancellation reason is required when you are the host",
@@ -131,6 +131,65 @@ export class BookingRepository {
});
}
async getBookingWithEventTypeTeamId({ bookingId }: { bookingId: number }) {
return await this.prismaClient.booking.findUnique({
where: {
id: bookingId,
},
select: {
userId: true,
eventType: {
select: {
teamId: true,
},
},
},
});
}
async findByUidIncludeEventType({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
select: {
userId: true,
user: {
select: {
id: true,
email: true,
},
},
attendees: {
select: {
email: true,
},
},
eventType: {
select: {
teamId: true,
hosts: {
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
},
users: {
select: {
id: true,
email: true,
},
},
},
},
},
});
}
/** Determines if the user is the organizer, team admin, or org admin that the booking was created under */
async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) {
const booking = await this.prismaClient.booking.findUnique({
@@ -187,6 +246,59 @@ export class BookingRepository {
});
}
async findByUidIncludeReport({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
select: {
id: true,
uid: true,
startTime: true,
status: true,
recurringEventId: true,
attendees: {
select: {
email: true,
},
},
seatsReferences: {
select: {
referenceUid: true,
attendee: {
select: {
email: true,
},
},
},
},
report: {
select: {
id: true,
},
},
},
});
}
async getActiveRecurringBookingsFromDate({
recurringEventId,
fromDate,
}: {
recurringEventId: string;
fromDate: Date;
}) {
return await this.prismaClient.booking.findMany({
where: {
recurringEventId,
startTime: { gte: fromDate },
status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] },
},
select: { id: true, uid: true },
orderBy: { startTime: "asc" },
});
}
private async _findAllExistingBookingsForEventTypeBetween({
eventTypeId,
seatedEvent = false,
@@ -0,0 +1,98 @@
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { PrismaClient } from "@calcom/prisma";
import { BookingRepository } from "../repositories/BookingRepository";
type BookingForAccessCheck = NonNullable<Awaited<ReturnType<BookingRepository["findByUidIncludeEventType"]>>>;
export class BookingAccessService {
constructor(private prismaClient: PrismaClient) {}
private isUserAHost(userId: number, booking: BookingForAccessCheck): boolean {
const hostMap = new Map<number, { id: number; email: string }>();
const addHost = (id: number, email: string) => {
if (!hostMap.has(id)) {
hostMap.set(id, { id, email });
}
};
booking?.eventType?.hosts?.forEach((host: { userId: number; user: { email: string } }) =>
addHost(host.userId, host.user.email)
);
booking?.eventType?.users?.forEach((user: { id: number; email: string }) => addHost(user.id, user.email));
if (booking?.user?.id && booking?.user?.email) {
addHost(booking.user.id, booking.user.email);
}
const attendeeEmails = new Set(booking.attendees?.map((attendee: { email: string }) => attendee.email));
const filteredHosts = Array.from(hostMap.values()).filter(
(host) => attendeeEmails.has(host.email) || host.id === booking.user?.id
);
return filteredHosts.some((host) => host.id === userId);
}
/**
* Determines if a user has access to a booking based on:
* 1. Being the booking organizer
* 2. Being one of the hosts in a multi-host booking
* 3. Being a team/org admin where the event type belongs
* 4. Being an org admin where the booking organizer belongs (for personal bookings)
* 5. Being a team admin of any team the booking organizer belongs to (for personal bookings)
*/
async doesUserIdHaveAccessToBooking({
userId,
bookingUid,
}: {
userId: number;
bookingUid: string;
}): Promise<boolean> {
const bookingRepo = new BookingRepository(this.prismaClient);
const userRepo = new UserRepository(this.prismaClient);
const booking = await bookingRepo.findByUidIncludeEventType({ bookingUid });
if (!booking) return false;
if (userId === booking.userId) return true;
if (this.isUserAHost(userId, booking)) return true;
// If booking has a teamId, check if user is admin of that team/org
if (booking.eventType?.teamId) {
const isAdminOrUser = await userRepo.isAdminOfTeamOrParentOrg({
userId,
teamId: booking.eventType.teamId,
});
return isAdminOrUser;
}
if (!booking.userId) return false;
const bookingOwner = await userRepo.getUserOrganizationAndTeams({ userId: booking.userId });
if (!bookingOwner) return false;
// Check if user is admin of booking organizer's organization
if (bookingOwner.organizationId) {
const isOrgAdmin = await userRepo.isAdminOfTeamOrParentOrg({
userId,
teamId: bookingOwner.organizationId,
});
if (isOrgAdmin) return true;
}
// Check if user is admin of any team the booking organizer belongs to
for (const membership of bookingOwner.teams) {
const isTeamAdmin = await userRepo.isAdminOfTeamOrParentOrg({
userId,
teamId: membership.teamId,
});
if (isTeamAdmin) return true;
}
return false;
}
}
@@ -770,6 +770,19 @@ export class UserRepository {
});
return !!isAdminOrOwnerOfTeam;
}
async getUserOrganizationAndTeams({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: { id: userId },
select: {
organizationId: true,
teams: {
where: { accepted: true },
select: { teamId: true },
},
},
});
}
async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: {
@@ -0,0 +1,25 @@
import type { BookingReportReason } from "@calcom/prisma/enums";
export interface CreateBookingReportInput {
bookingUid: string;
bookerEmail: string;
reportedById: number;
reason: BookingReportReason;
description?: string;
cancelled: boolean;
organizationId?: number | null;
}
export interface BookingReportSummary {
id: string;
reportedById: number | null;
reason: BookingReportReason;
description: string | null;
createdAt: Date;
}
export interface IBookingReportRepository {
createReport(input: CreateBookingReportInput): Promise<{ id: string }>;
findAllReportedBookings(params: { skip?: number; take?: number }): Promise<BookingReportSummary[]>;
}
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrismaClient } from "@calcom/prisma";
import { BookingReportReason } from "@calcom/prisma/enums";
import { PrismaBookingReportRepository } from "./bookingReport";
import type { CreateBookingReportInput } from "./bookingReport.interface";
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
describe("PrismaBookingReportRepository", () => {
let repository: PrismaBookingReportRepository;
const mockPrisma = {
bookingReport: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
},
} as unknown as PrismaClient;
beforeEach(() => {
vi.clearAllMocks();
repository = new PrismaBookingReportRepository(mockPrisma);
});
describe("createReport", () => {
it("should create a booking report with all fields", async () => {
const input: CreateBookingReportInput = {
bookingUid: "test-booking-uid-1",
bookerEmail: "booker@example.com",
reportedById: 1,
reason: BookingReportReason.SPAM,
description: "This is spam",
cancelled: true,
organizationId: 10,
};
const mockCreatedReport = { id: "report-123" };
mockPrisma.bookingReport.create.mockResolvedValue(mockCreatedReport);
const result = await repository.createReport(input);
expect(result).toEqual({ id: "report-123" });
expect(mockPrisma.bookingReport.create).toHaveBeenCalledWith({
data: {
bookingUid: "test-booking-uid-1",
bookerEmail: "booker@example.com",
reportedById: 1,
reason: BookingReportReason.SPAM,
description: "This is spam",
cancelled: true,
organizationId: 10,
},
select: { id: true },
});
});
it("should create a booking report without optional fields", async () => {
const input: CreateBookingReportInput = {
bookingUid: "test-booking-uid-2",
bookerEmail: "user@example.com",
reportedById: 2,
reason: BookingReportReason.DONT_KNOW_PERSON,
cancelled: false,
};
const mockCreatedReport = { id: "report-456" };
mockPrisma.bookingReport.create.mockResolvedValue(mockCreatedReport);
const result = await repository.createReport(input);
expect(result).toEqual({ id: "report-456" });
expect(mockPrisma.bookingReport.create).toHaveBeenCalledWith({
data: {
bookingUid: "test-booking-uid-2",
bookerEmail: "user@example.com",
reportedById: 2,
reason: BookingReportReason.DONT_KNOW_PERSON,
description: undefined,
cancelled: false,
organizationId: undefined,
},
select: { id: true },
});
});
});
describe("findAllReportedBookings", () => {
it("should find all reported bookings with pagination", async () => {
const mockReports = [
{
id: "report-1",
reportedById: 1,
reason: BookingReportReason.SPAM,
description: "Spam",
createdAt: new Date("2025-01-01T10:00:00Z"),
},
{
id: "report-2",
reportedById: 2,
reason: BookingReportReason.DONT_KNOW_PERSON,
description: null,
createdAt: new Date("2025-01-01T11:00:00Z"),
},
];
mockPrisma.bookingReport.findMany.mockResolvedValue(mockReports);
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,
},
orderBy: { createdAt: "desc" },
});
});
});
});
@@ -0,0 +1,43 @@
import type { PrismaClient } from "@calcom/prisma";
import type {
IBookingReportRepository,
CreateBookingReportInput,
BookingReportSummary,
} from "./bookingReport.interface";
export class PrismaBookingReportRepository implements IBookingReportRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async createReport(input: CreateBookingReportInput): Promise<{ id: string }> {
const report = await this.prismaClient.bookingReport.create({
data: {
bookingUid: input.bookingUid,
bookerEmail: input.bookerEmail,
reportedById: input.reportedById,
reason: input.reason,
description: input.description,
cancelled: input.cancelled,
organizationId: input.organizationId,
},
select: { id: true },
});
return report;
}
async findAllReportedBookings(params: { skip?: number; take?: number }): Promise<BookingReportSummary[]> {
const reports = await this.prismaClient.bookingReport.findMany({
skip: params.skip,
take: params.take,
select: {
id: true,
reportedById: true,
reason: true,
description: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
return reports;
}
}
@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "BookingReportReason" AS ENUM ('SPAM', 'dont_know_person', 'OTHER');
-- CreateTable
CREATE TABLE "BookingReport" (
"id" UUID NOT NULL,
"bookingUid" TEXT NOT NULL,
"bookerEmail" TEXT NOT NULL,
"reportedById" INTEGER,
"organizationId" INTEGER,
"reason" "BookingReportReason" NOT NULL,
"description" TEXT,
"cancelled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"watchlistId" UUID,
CONSTRAINT "BookingReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BookingReport_bookingUid_key" ON "BookingReport"("bookingUid");
-- CreateIndex
CREATE INDEX "BookingReport_bookerEmail_idx" ON "BookingReport"("bookerEmail");
-- CreateIndex
CREATE INDEX "BookingReport_reportedById_idx" ON "BookingReport"("reportedById");
-- CreateIndex
CREATE INDEX "BookingReport_organizationId_idx" ON "BookingReport"("organizationId");
-- CreateIndex
CREATE INDEX "BookingReport_watchlistId_idx" ON "BookingReport"("watchlistId");
-- CreateIndex
CREATE INDEX "BookingReport_createdAt_idx" ON "BookingReport"("createdAt");
-- AddForeignKey
ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_reportedById_fkey" FOREIGN KEY ("reportedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingReport" ADD CONSTRAINT "BookingReport_watchlistId_fkey" FOREIGN KEY ("watchlistId") REFERENCES "Watchlist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+37
View File
@@ -451,6 +451,7 @@ model User {
updatedAttributeToUsers AttributeToUser[] @relation("updatedBy")
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
reportedBookings BookingReport[] @relation("ReportedBy")
BookingInternalNote BookingInternalNote[]
creationSource CreationSource?
createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings")
@@ -572,6 +573,7 @@ model Team {
roles Role[] // Added for Role relation
calAiPhoneNumbers CalAiPhoneNumber[]
agents Agent[]
bookingReports BookingReport[]
features TeamFeatures[]
@@ -848,6 +850,7 @@ model Booking {
tracking Tracking?
routingFormResponses RoutingFormResponseDenormalized[]
expenseLogs CreditExpenseLog[]
report BookingReport?
@@index([eventTypeId])
@@index([userId])
@@ -2317,6 +2320,8 @@ model Watchlist {
source WatchlistSource @default(MANUAL)
lastUpdatedAt DateTime @default(now())
bookingReports BookingReport[]
@@unique([type, value, organizationId])
@@index([type, value, organizationId, action])
}
@@ -2345,6 +2350,38 @@ model WatchlistEventAudit {
timestamp DateTime @default(now())
}
enum BookingReportReason {
SPAM
DONT_KNOW_PERSON @map("dont_know_person")
OTHER
}
model BookingReport {
id String @id @default(uuid()) @db.Uuid
bookingUid String @unique
booking Booking @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
bookerEmail String
reportedById Int?
organizationId Int?
reason BookingReportReason
description String?
cancelled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
watchlistId String? @db.Uuid
reportedBy User? @relation("ReportedBy", fields: [reportedById], references: [id], onDelete: SetNull)
organization Team? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
watchlist Watchlist? @relation(fields: [watchlistId], references: [id], onDelete: SetNull)
@@index([bookerEmail])
@@index([reportedById])
@@index([organizationId])
@@index([watchlistId])
@@index([createdAt])
}
enum BillingPeriod {
MONTHLY
ANNUALLY
+1
View File
@@ -275,6 +275,7 @@ export const bookingCancelSchema = z.object({
// note(Lauris): cancelSubsequentBookings will cancel all bookings after one specified by id or uid.
cancelSubsequentBookings: z.boolean().optional(),
cancellationReason: z.string().optional(),
skipCancellationReasonValidation: z.boolean().optional(),
seatReferenceUid: z.string().optional(),
cancelledBy: z.string().email({ message: "Invalid email" }).optional(),
internalNote: z
@@ -8,6 +8,7 @@ import { ZFindInputSchema } from "./find.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
import { ZReportBookingInputSchema } from "./reportBooking.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
import { bookingsProcedure } from "./util";
@@ -20,6 +21,7 @@ type BookingsRouterHandlerCache = {
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
find?: typeof import("./find.handler").getHandler;
getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler;
reportBooking?: typeof import("./reportBooking.handler").reportBookingHandler;
};
export const bookingsRouter = router({
@@ -98,4 +100,13 @@ export const bookingsRouter = router({
input,
});
}),
reportBooking: authedProcedure.input(ZReportBookingInputSchema).mutation(async ({ input, ctx }) => {
const { reportBookingHandler } = await import("./reportBooking.handler");
return reportBookingHandler({
ctx,
input,
});
}),
});
@@ -590,6 +590,18 @@ export async function getBookings({
.orderBy("AssignmentReason.createdAt", "desc")
.limit(1)
).as("assignmentReason"),
jsonObjectFrom(
eb
.selectFrom("BookingReport")
.select([
"BookingReport.id",
"BookingReport.reportedById",
"BookingReport.reason",
"BookingReport.description",
"BookingReport.createdAt",
])
.whereRef("BookingReport.bookingUid", "=", "Booking.uid")
).as("report"),
])
.orderBy(orderBy.key, orderBy.order)
.execute()
@@ -688,6 +700,7 @@ export async function getBookings({
return hostUser?.id === userId && attendeeEmails.has(hostUser.email);
});
};
const bookings = await Promise.all(
plainBookings.map(async (booking) => {
// If seats are enabled, the event is not set to show attendees, and the current user is not the host, filter out attendees who are not the current user
@@ -0,0 +1,344 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
import { BookingStatus, BookingReportReason } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { reportBookingHandler } from "./reportBooking.handler";
vi.mock("@calcom/lib/server/repository/bookingReport");
vi.mock("@calcom/features/bookings/lib/handleCancelBooking");
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/bookings/services/BookingAccessService");
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: () => ({
warn: vi.fn(),
error: vi.fn(),
}),
},
}));
describe("reportBookingHandler", () => {
const mockUser = {
id: 1,
email: "user@example.com",
name: "Test User",
};
const mockBooking = {
id: 100,
uid: "test-booking-uid",
startTime: new Date("2025-12-01T10:00:00Z"),
status: BookingStatus.ACCEPTED,
recurringEventId: null,
attendees: [{ email: "booker@example.com" }],
seatsReferences: [],
report: null,
};
const mockBookingRepo = {
findByUidIncludeReport: vi.fn(),
getActiveRecurringBookingsFromDate: vi.fn(),
};
const mockReportRepo = {
createReport: vi.fn(),
findAllReportedBookings: vi.fn(),
};
const mockBookingAccessService = {
doesUserIdHaveAccessToBooking: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(BookingRepository).mockImplementation(() => mockBookingRepo);
vi.mocked(PrismaBookingReportRepository).mockImplementation(() => mockReportRepo);
vi.mocked(BookingAccessService).mockImplementation(() => mockBookingAccessService);
mockReportRepo.createReport.mockResolvedValue({ id: "new-report" });
});
describe("access control", () => {
it("should throw FORBIDDEN when user doesn't have access to booking", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(false);
await expect(
reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
})
).rejects.toThrow(TRPCError);
await expect(
reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "You don't have access to this booking",
});
});
it("should throw NOT_FOUND when booking doesn't exist", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue(null);
await expect(
reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
})
).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Booking not found",
});
});
});
describe("duplicate report prevention", () => {
it("should throw BAD_REQUEST when booking already has a report", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
report: { id: "existing-report" },
});
await expect(
reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
})
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "This booking has already been reported",
});
});
});
describe("successful report creation", () => {
it("should successfully create a single booking report and auto-cancel upcoming booking", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
startTime: new Date(Date.now() + 86400000), // Tomorrow
});
vi.mocked(handleCancelBooking).mockResolvedValue({
success: true,
message: "Cancelled",
onlyRemovedAttendee: false,
bookingUid: "test-booking-uid",
});
const result = await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
description: "This is spam",
},
});
expect(result.success).toBe(true);
expect(result.message).toBe("Booking reported and cancelled successfully");
expect(result.reportedCount).toBe(1);
expect(mockReportRepo.createReport).toHaveBeenCalledWith({
bookingUid: "test-booking-uid",
bookerEmail: "booker@example.com",
reportedById: mockUser.id,
reason: BookingReportReason.SPAM,
description: "This is spam",
cancelled: true,
organizationId: undefined,
});
});
it("should create report without description for past booking", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
startTime: new Date(Date.now() - 86400000), // Yesterday
});
const result = await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.DONT_KNOW_PERSON,
},
});
expect(result.message).toBe("Booking reported successfully");
expect(mockReportRepo.createReport).toHaveBeenCalledWith({
bookingUid: "test-booking-uid",
bookerEmail: "booker@example.com",
reportedById: mockUser.id,
reason: BookingReportReason.DONT_KNOW_PERSON,
description: undefined,
cancelled: false,
organizationId: undefined,
});
});
});
describe("cancellation integration", () => {
it("should cancel booking when cancelBooking is true", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
startTime: new Date(Date.now() + 86400000), // Tomorrow
});
vi.mocked(handleCancelBooking).mockResolvedValue({
success: true,
message: "Cancelled",
onlyRemovedAttendee: false,
bookingUid: "test-booking-uid",
});
const result = await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
description: "Spam booking",
},
});
expect(result.message).toBe("Booking reported and cancelled successfully");
expect(handleCancelBooking).toHaveBeenCalledWith({
bookingData: {
uid: "test-booking-uid",
cancelledBy: mockUser.email,
skipCancellationReasonValidation: true,
},
userId: mockUser.id,
});
});
it("should not cancel booking if it's in the past", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
startTime: new Date(Date.now() - 86400000),
});
const result = await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
});
expect(handleCancelBooking).not.toHaveBeenCalled();
expect(result.message).toBe("Booking reported successfully");
});
it("should handle cancellation failure gracefully", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
startTime: new Date(Date.now() + 86400000),
});
vi.mocked(handleCancelBooking).mockRejectedValue(new Error("Cancellation failed"));
const result = await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
});
expect(result.success).toBe(true);
expect(result.message).toBe("Booking reported successfully, but cancellation failed");
expect(result).not.toHaveProperty("cancellationError");
});
it("should use cancelSubsequentBookings for recurring events", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
recurringEventId: "recurring-123",
startTime: new Date(Date.now() + 86400000),
});
mockBookingRepo.getActiveRecurringBookingsFromDate.mockResolvedValue([
{ uid: "100" },
{ uid: "101" },
{ uid: "102" },
]);
vi.mocked(handleCancelBooking).mockResolvedValue({
success: true,
message: "Cancelled",
onlyRemovedAttendee: false,
bookingUid: "test-booking-uid",
});
await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
});
expect(handleCancelBooking).toHaveBeenCalledWith({
bookingData: expect.objectContaining({
cancelSubsequentBookings: true,
}),
userId: mockUser.id,
});
});
it("should pass seatReferenceUid for seated events", async () => {
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
mockBookingRepo.findByUidIncludeReport.mockResolvedValue({
...mockBooking,
startTime: new Date(Date.now() + 86400000),
seatsReferences: [
{ referenceUid: "seat-123", attendee: { email: mockUser.email } },
{ referenceUid: "seat-456", attendee: { email: "other@example.com" } },
],
});
vi.mocked(handleCancelBooking).mockResolvedValue({
success: true,
message: "Cancelled",
onlyRemovedAttendee: false,
bookingUid: "test-booking-uid",
});
await reportBookingHandler({
ctx: { user: mockUser },
input: {
bookingUid: "test-booking-uid",
reason: BookingReportReason.SPAM,
},
});
expect(handleCancelBooking).toHaveBeenCalledWith({
bookingData: expect.objectContaining({
seatReferenceUid: "seat-123",
}),
userId: mockUser.id,
});
});
});
});
@@ -0,0 +1,137 @@
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import logger from "@calcom/lib/logger";
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TReportBookingInputSchema } from "./reportBooking.schema";
type ReportBookingOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TReportBookingInputSchema;
};
const log = logger.getSubLogger({ prefix: ["reportBookingHandler"] });
export const reportBookingHandler = async ({ ctx, input }: ReportBookingOptions) => {
const { user } = ctx;
const { bookingUid, reason, description } = input;
const bookingRepo = new BookingRepository(prisma);
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
const bookingAccessService = new BookingAccessService(prisma);
const hasAccess = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: user.id,
bookingUid,
});
if (!hasAccess) {
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have access to this booking" });
}
const booking = await bookingRepo.findByUidIncludeReport({ bookingUid });
if (!booking) {
throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" });
}
if (booking.report) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This booking has already been reported" });
}
const bookerEmail = booking.attendees[0]?.email;
if (!bookerEmail) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Booking has no attendees" });
}
let reportedBookingUids: string[] = [bookingUid];
if (booking.recurringEventId) {
const remainingBookings = await bookingRepo.getActiveRecurringBookingsFromDate({
recurringEventId: booking.recurringEventId,
fromDate: booking.startTime,
});
reportedBookingUids = remainingBookings.map((b: { uid: string }) => b.uid);
}
let cancellationError: unknown = null;
let cancellationAttempted = false;
let didCancel = false;
const isUpcoming =
(booking.status === BookingStatus.ACCEPTED ||
booking.status === BookingStatus.PENDING ||
booking.status === BookingStatus.AWAITING_HOST) &&
new Date(booking.startTime) > new Date();
if (isUpcoming) {
cancellationAttempted = true;
try {
const normalizedEmail = user.email.trim().toLowerCase();
const userSeat = booking.seatsReferences.find(
(seat: { attendee?: { email: string }; referenceUid?: string }) =>
seat.attendee?.email && seat.attendee.email.trim().toLowerCase() === normalizedEmail
);
const seatReferenceUid = userSeat?.referenceUid;
await handleCancelBooking({
bookingData: {
uid: booking.uid,
cancelledBy: user.email,
skipCancellationReasonValidation: true,
...(booking.recurringEventId ? { cancelSubsequentBookings: true } : {}),
...(seatReferenceUid ? { seatReferenceUid } : {}),
},
userId: user.id,
});
didCancel = true;
} catch (error) {
cancellationError = error;
log.error("Failed to cancel booking after reporting:", error);
}
}
let createdCount = 0;
for (const uid of reportedBookingUids) {
try {
await bookingReportRepo.createReport({
bookingUid: uid,
bookerEmail,
reportedById: user.id,
reason,
description,
cancelled: didCancel,
organizationId: user?.organizationId,
});
createdCount++;
} catch (error) {
log.warn(`Failed to create report for booking ${uid}:`, error);
}
}
const reportedBooking = { uid: reportedBookingUids[0] };
const isRecurring = Boolean(booking.recurringEventId) && createdCount > 1;
const baseMessage = isRecurring ? `${createdCount} recurring bookings reported` : "Booking reported";
const success = createdCount > 0;
return {
success,
message: success
? cancellationAttempted
? cancellationError
? `${baseMessage} successfully, but cancellation failed`
: `${baseMessage} and cancelled successfully`
: `${baseMessage} successfully`
: "No reports created",
bookingUid: reportedBooking.uid,
reportedCount: createdCount,
};
};
@@ -0,0 +1,11 @@
import { z } from "zod";
import { BookingReportReason } from "@calcom/prisma/enums";
export const ZReportBookingInputSchema = z.object({
bookingUid: z.string(),
reason: z.nativeEnum(BookingReportReason),
description: z.string().optional(),
});
export type TReportBookingInputSchema = z.infer<typeof ZReportBookingInputSchema>;