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:
co-authored by
CarinaWolli
parent
a2bee76da6
commit
06d6c180d1
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user