From 69c383214282252723d2815490419b5fd67640c6 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 7 Oct 2025 17:26:58 +0530 Subject: [PATCH 01/54] feat: report booking --- .../components/booking/BookingListItem.tsx | 39 ++++ apps/web/components/booking/bookingActions.ts | 22 ++ .../components/dialog/ReportBookingDialog.tsx | 191 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 13 ++ .../booking-report.repository.interface.ts | 39 ++++ .../bookings/lib/booking-report.repository.ts | 115 +++++++++++ .../bookings/lib/booking-report.utils.ts | 7 + .../repositories/BookingRepository.ts | 26 +++ .../migration.sql | 44 ++++ packages/prisma/schema.prisma | 34 ++++ .../routers/viewer/bookings/_router.tsx | 11 + .../routers/viewer/bookings/get.handler.ts | 11 + .../viewer/bookings/reportBooking.handler.ts | 150 ++++++++++++++ .../viewer/bookings/reportBooking.schema.ts | 13 ++ 14 files changed, 715 insertions(+) create mode 100644 apps/web/components/dialog/ReportBookingDialog.tsx create mode 100644 packages/features/bookings/lib/booking-report.repository.interface.ts create mode 100644 packages/features/bookings/lib/booking-report.repository.ts create mode 100644 packages/features/bookings/lib/booking-report.utils.ts create mode 100644 packages/prisma/migrations/20251007112732_add_booking_report/migration.sql create mode 100644 packages/trpc/server/routers/viewer/bookings/reportBooking.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/reportBooking.schema.ts diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f673aa9f6440fb..fd83c668a2b46b 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -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,6 +61,7 @@ import { getCancelEventAction, getEditEventActions, getAfterEventActions, + getReportAction, shouldShowPendingActions, shouldShowEditActions, shouldShowRecurringCancelAction, @@ -292,6 +294,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 +405,14 @@ function BookingListItem(booking: BookingItemProps) { (action.id === "view_recordings" && !booking.isRecorded), })) as ActionType[]; + const reportAction = getReportAction(actionContext); + const reportActionWithHandler = reportAction + ? { + ...reportAction, + onClick: () => setIsOpenReportDialog(true), + } + : null; + return ( <> + {booking.paid && booking.payment[0] && ( ))} + {reportActionWithHandler && ( + <> + + + + {reportActionWithHandler.label} + + + + )} 0 && ( )} + {booking.reports && booking.reports.length > 0 && ( + + {booking.reportedByCurrentUser + ? t("reported_by_you") + : t("reported_count", { count: booking.reports.length })} + + )} {booking.paid && !booking.payment[0] ? ( {t("error_collecting_card")} diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts index 7d50942ebf450a..d202acb71ebe5a 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -165,6 +165,28 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] return actions.filter(Boolean) as ActionType[]; } +export function getReportAction(context: BookingActionContext): ActionType | null { + const { booking, isCancelled, isRejected, t } = context; + + // Don't show if current user already reported + if (booking.reportedByCurrentUser) { + return null; + } + + // For cancelled/rejected: only show if others already reported + if ((isCancelled || isRejected) && (!booking.reports || booking.reports.length === 0)) { + return null; + } + + return { + id: "report", + label: booking.reports && booking.reports.length > 0 ? t("add_to_report") : t("report_booking"), + icon: "flag", + color: "destructive", + disabled: false, + }; +} + export function getAfterEventActions(context: BookingActionContext): ActionType[] { const { booking, cardCharged, attendeeList, t } = context; diff --git a/apps/web/components/dialog/ReportBookingDialog.tsx b/apps/web/components/dialog/ReportBookingDialog.tsx new file mode 100644 index 00000000000000..04b0826e4f8158 --- /dev/null +++ b/apps/web/components/dialog/ReportBookingDialog.tsx @@ -0,0 +1,191 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useState } 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 { ReportReason } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Checkbox } from "@calcom/ui/components/form"; +import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Icon } from "@calcom/ui/components/icon"; +import { Label } from "@calcom/ui/components/label"; +import { Select } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { TextArea } from "@calcom/ui/components/form"; + +interface IReportBookingDialog { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + bookingId: number; + isRecurring: boolean; +} + +interface FormValues { + reason: ReportReason; + description: string; + cancelBooking: boolean; + allRemainingBookings: boolean; +} + +export const ReportBookingDialog = (props: IReportBookingDialog) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const { isOpenDialog, setIsOpenDialog, bookingId, isRecurring } = props; + + const { + control, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + defaultValues: { + reason: ReportReason.SPAM, + description: "", + cancelBooking: false, + allRemainingBookings: false, + }, + }); + + const cancelBooking = watch("cancelBooking"); + + 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({ + bookingId, + reason: data.reason, + description: data.description || undefined, + cancelBooking: data.cancelBooking, + allRemainingBookings: data.allRemainingBookings, + }); + }; + + const reasonOptions = [ + { label: t("report_reason_spam"), value: ReportReason.SPAM }, + { label: t("report_reason_dont_know_person"), value: ReportReason.DONT_KNOW_PERSON }, + { label: t("report_reason_other"), value: ReportReason.OTHER }, + ]; + + return ( + + +
+
+
+ +
+
+ +

{t("report_booking_description")}

+ +
+ + ( +