Skip to content

Commit 06d6c18

Browse files
Udit-takkarCarinaWolli
andauthored
feat: report booking (calcom#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>
1 parent a2bee76 commit 06d6c18

19 files changed

Lines changed: 1272 additions & 4 deletions

File tree

apps/web/components/booking/BookingListItem.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
5252
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
5353
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
5454
import { ReassignDialog } from "@components/dialog/ReassignDialog";
55+
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
5556
import { RerouteDialog } from "@components/dialog/RerouteDialog";
5657
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
5758

@@ -60,9 +61,11 @@ import {
6061
getCancelEventAction,
6162
getEditEventActions,
6263
getAfterEventActions,
64+
getReportAction,
6365
shouldShowPendingActions,
6466
shouldShowEditActions,
6567
shouldShowRecurringCancelAction,
68+
shouldShowIndividualReportButton,
6669
type BookingActionContext,
6770
} from "./bookingActions";
6871

@@ -181,6 +184,13 @@ function BookingListItem(booking: BookingItemProps) {
181184
const isPending = booking.status === BookingStatus.PENDING;
182185
const isRescheduled = booking.fromReschedule !== null;
183186
const isRecurring = booking.recurringEventId !== null;
187+
188+
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
189+
if (isCancelled) return "cancelled";
190+
if (isRejected) return "rejected";
191+
if (isBookingInPast) return "past";
192+
return "upcoming";
193+
};
184194
const isTabRecurring = booking.listingStatus === "recurring";
185195
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
186196
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
@@ -292,6 +302,7 @@ function BookingListItem(booking: BookingItemProps) {
292302
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
293303
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
294304
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
305+
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
295306
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
296307
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
297308
onSuccess: () => {
@@ -402,6 +413,12 @@ function BookingListItem(booking: BookingItemProps) {
402413
(action.id === "view_recordings" && !booking.isRecorded),
403414
})) as ActionType[];
404415

416+
const reportAction = getReportAction(actionContext);
417+
const reportActionWithHandler = {
418+
...reportAction,
419+
onClick: () => setIsOpenReportDialog(true),
420+
};
421+
405422
return (
406423
<>
407424
<RescheduleDialog
@@ -430,6 +447,13 @@ function BookingListItem(booking: BookingItemProps) {
430447
setIsOpenDialog={setIsOpenAddGuestsDialog}
431448
bookingId={booking.id}
432449
/>
450+
<ReportBookingDialog
451+
isOpenDialog={isOpenReportDialog}
452+
setIsOpenDialog={setIsOpenReportDialog}
453+
bookingUid={booking.uid}
454+
isRecurring={isRecurring}
455+
status={getBookingStatus()}
456+
/>
433457
{booking.paid && booking.payment[0] && (
434458
<ChargeCardDialog
435459
isOpenDialog={chargeCardDialogIsOpen}
@@ -685,6 +709,24 @@ function BookingListItem(booking: BookingItemProps) {
685709
</DropdownItem>
686710
</DropdownMenuItem>
687711
))}
712+
<>
713+
<DropdownMenuSeparator />
714+
<DropdownMenuItem
715+
className="rounded-lg"
716+
key={reportActionWithHandler.id}
717+
disabled={reportActionWithHandler.disabled}>
718+
<DropdownItem
719+
type="button"
720+
color={reportActionWithHandler.color}
721+
StartIcon={reportActionWithHandler.icon}
722+
onClick={reportActionWithHandler.onClick}
723+
disabled={reportActionWithHandler.disabled}
724+
data-testid={reportActionWithHandler.id}
725+
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
726+
{reportActionWithHandler.label}
727+
</DropdownItem>
728+
</DropdownMenuItem>
729+
</>
688730
<DropdownMenuSeparator />
689731
<DropdownMenuItem
690732
className="rounded-lg"
@@ -708,6 +750,21 @@ function BookingListItem(booking: BookingItemProps) {
708750
</Dropdown>
709751
)}
710752
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
753+
{shouldShowIndividualReportButton(actionContext) && (
754+
<div className="flex items-center space-x-2">
755+
<Button
756+
type="button"
757+
variant="icon"
758+
color="destructive"
759+
StartIcon={reportActionWithHandler.icon}
760+
onClick={reportActionWithHandler.onClick}
761+
disabled={reportActionWithHandler.disabled}
762+
data-testid={reportActionWithHandler.id}
763+
className="h-8 w-8"
764+
tooltip={reportActionWithHandler.label}
765+
/>
766+
</div>
767+
)}
711768
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
712769
{isCancelled && booking.rescheduled && (
713770
<div className="hidden h-full items-center md:flex">
@@ -776,6 +833,24 @@ const BookingItemBadges = ({
776833
{booking?.assignmentReason.length > 0 && (
777834
<AssignmentReasonTooltip assignmentReason={booking.assignmentReason[0]} />
778835
)}
836+
{booking.report && (
837+
<Tooltip
838+
content={
839+
<div className="text-xs">
840+
{(() => {
841+
const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`;
842+
const reasonText = t(reasonKey);
843+
return booking.report.description
844+
? `${reasonText}: ${booking.report.description}`
845+
: reasonText;
846+
})()}
847+
</div>
848+
}>
849+
<Badge className="ltr:mr-2 rtl:ml-2" variant="red">
850+
{t("reported")}
851+
</Badge>
852+
</Tooltip>
853+
)}
779854
{booking.paid && !booking.payment[0] ? (
780855
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
781856
{t("error_collecting_card")}

apps/web/components/booking/bookingActions.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
165165
return actions.filter(Boolean) as ActionType[];
166166
}
167167

168+
export function getReportAction(context: BookingActionContext): ActionType {
169+
const { booking, t } = context;
170+
171+
return {
172+
id: "report",
173+
label: t("report_booking"),
174+
icon: "flag",
175+
color: "destructive",
176+
disabled: !!booking.report,
177+
};
178+
}
179+
168180
export function getAfterEventActions(context: BookingActionContext): ActionType[] {
169181
const { booking, cardCharged, attendeeList, t } = context;
170182

@@ -205,9 +217,14 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext):
205217
return isTabRecurring && isRecurring;
206218
}
207219

220+
export function shouldShowIndividualReportButton(context: BookingActionContext): boolean {
221+
const { booking, isPending, isUpcoming, isCancelled, isRejected } = context;
222+
const hasDropdown = shouldShowEditActions(context);
223+
return !booking.report && !hasDropdown && (isCancelled || isRejected || (isPending && isUpcoming));
224+
}
225+
208226
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
209-
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
210-
context;
227+
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
211228

212229
switch (actionId) {
213230
case "reschedule":
@@ -227,7 +244,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
227244
}
228245

229246
export function getActionLabel(actionId: string, context: BookingActionContext): string {
230-
const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
247+
const { isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
231248

232249
switch (actionId) {
233250
case "reject":
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { Dispatch, SetStateAction } from "react";
2+
import { Controller, useForm } from "react-hook-form";
3+
4+
import { Dialog } from "@calcom/features/components/controlled-dialog";
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { BookingReportReason } from "@calcom/prisma/enums";
7+
import { trpc } from "@calcom/trpc/react";
8+
import { Alert } from "@calcom/ui/components/alert";
9+
import { Button } from "@calcom/ui/components/button";
10+
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
11+
import { Select, Label } from "@calcom/ui/components/form";
12+
import { TextArea } from "@calcom/ui/components/form";
13+
import { showToast } from "@calcom/ui/components/toast";
14+
15+
type BookingReportStatus = "upcoming" | "past" | "cancelled" | "rejected";
16+
17+
interface IReportBookingDialog {
18+
isOpenDialog: boolean;
19+
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
20+
bookingUid: string;
21+
isRecurring: boolean;
22+
status: BookingReportStatus;
23+
}
24+
25+
interface FormValues {
26+
reason: BookingReportReason;
27+
description: string;
28+
}
29+
30+
export const ReportBookingDialog = (props: IReportBookingDialog) => {
31+
const { t } = useLocale();
32+
const utils = trpc.useUtils();
33+
const { isOpenDialog, setIsOpenDialog, bookingUid, status } = props;
34+
35+
const willBeCancelled = status === "upcoming";
36+
37+
const {
38+
control,
39+
handleSubmit,
40+
formState: { errors },
41+
} = useForm<FormValues>({
42+
defaultValues: {
43+
reason: BookingReportReason.SPAM,
44+
description: "",
45+
},
46+
});
47+
48+
const { mutate: reportBooking, isPending } = trpc.viewer.bookings.reportBooking.useMutation({
49+
async onSuccess(data) {
50+
showToast(data.message, "success");
51+
setIsOpenDialog(false);
52+
await utils.viewer.bookings.invalidate();
53+
},
54+
onError(error) {
55+
showToast(error.message || t("unexpected_error_try_again"), "error");
56+
},
57+
});
58+
59+
const onSubmit = (data: FormValues) => {
60+
reportBooking({
61+
bookingUid,
62+
reason: data.reason,
63+
description: data.description || undefined,
64+
});
65+
};
66+
67+
const reasonOptions = [
68+
{ label: t("report_reason_spam"), value: BookingReportReason.SPAM },
69+
{ label: t("report_reason_dont_know_person"), value: BookingReportReason.DONT_KNOW_PERSON },
70+
{ label: t("report_reason_other"), value: BookingReportReason.OTHER },
71+
];
72+
73+
return (
74+
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
75+
<DialogContent enableOverflow>
76+
<form onSubmit={handleSubmit(onSubmit)}>
77+
<div className="flex flex-row space-x-3">
78+
<div className="w-full">
79+
<DialogHeader title={t("report_booking")} subtitle={t("report_booking_description")} />
80+
<div className="mb-4">
81+
<Label htmlFor="reason" className="text-emphasis mb-2 block text-sm font-medium">
82+
{t("reason")} <span className="text-destructive">*</span>
83+
</Label>
84+
<Controller
85+
name="reason"
86+
control={control}
87+
rules={{ required: t("field_required") }}
88+
render={({ field }) => (
89+
<Select
90+
{...field}
91+
options={reasonOptions}
92+
onChange={(option) => {
93+
if (option) field.onChange(option.value);
94+
}}
95+
value={reasonOptions.find((opt) => opt.value === field.value)}
96+
/>
97+
)}
98+
/>
99+
{errors.reason && <p className="text-destructive mt-1 text-sm">{errors.reason.message}</p>}
100+
</div>
101+
102+
<div className="mb-4">
103+
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
104+
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
105+
</Label>
106+
<Controller
107+
name="description"
108+
control={control}
109+
render={({ field }) => (
110+
<TextArea {...field} placeholder={t("report_booking_description_placeholder")} rows={3} />
111+
)}
112+
/>
113+
</div>
114+
115+
{willBeCancelled && (
116+
<div className="mb-4">
117+
<Alert severity="warning" title={t("report_booking_will_cancel_description")} />
118+
</div>
119+
)}
120+
</div>
121+
</div>
122+
123+
<DialogFooter showDivider className="mt-8">
124+
<Button
125+
type="button"
126+
color="secondary"
127+
onClick={() => setIsOpenDialog(false)}
128+
disabled={isPending}>
129+
{t("cancel")}
130+
</Button>
131+
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
132+
{t("submit_report")}
133+
</Button>
134+
</DialogFooter>
135+
</form>
136+
</DialogContent>
137+
</Dialog>
138+
);
139+
};

apps/web/public/static/locales/en/common.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@
8686
"rejection_reason_title": "Reject the booking request?",
8787
"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.",
8888
"rejection_confirmation": "Reject the booking",
89+
"report_booking_description": "Report this booking as suspicious. This helps us identify and prevent spam or unwanted bookings.",
90+
"report_reason_spam": "Spam or unwanted booking",
91+
"report_reason_dont_know_person": "I don't know this person",
92+
"report_reason_other": "Other",
93+
"cancel_booking_when_reporting": "Cancel this booking",
94+
"report_all_remaining_bookings": "Report all remaining bookings in series",
95+
"submit_report": "Submit Report",
96+
"report_booking_description_placeholder": "Please provide additional details about why you're reporting this booking (optional)",
8997
"manage_this_event": "Manage this event",
9098
"invite_team_member": "Invite team member",
9199
"invite_team_individual_segment": "Invite individual",
@@ -759,6 +767,10 @@
759767
"cancel_all_remaining": "Cancel all remaining",
760768
"apply": "Apply",
761769
"cancel_event": "Cancel event",
770+
"report_booking": "Report booking",
771+
"report_booking_will_cancel_description": "Reporting this booking will automatically cancel it",
772+
"add_to_report": "Add to report",
773+
"reported": "Reported",
762774
"continue": "Continue",
763775
"confirm": "Confirm",
764776
"confirm_all": "Confirm all",

packages/features/bookings/lib/handleCancelBooking.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ async function handler(input: CancelBookingInput) {
7979
cancelledBy,
8080
cancelSubsequentBookings,
8181
internalNote,
82+
skipCancellationReasonValidation = false,
8283
} = bookingCancelInput.parse(body);
8384
const bookingToDelete = await getBookingToDelete(id, uid);
8485
const {
@@ -117,7 +118,7 @@ async function handler(input: CancelBookingInput) {
117118
const isCancellationUserHost =
118119
bookingToDelete.userId == userId || bookingToDelete.user.email === cancelledBy;
119120

120-
if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost) {
121+
if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost && !skipCancellationReasonValidation) {
121122
throw new HttpError({
122123
statusCode: 400,
123124
message: "Cancellation reason is required when you are the host",

0 commit comments

Comments
 (0)