From 06d6c180d1ebb49820a64ec12d2afae1a479bf08 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:53:31 +0530 Subject: [PATCH] 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 --- .../components/booking/BookingListItem.tsx | 75 ++++ apps/web/components/booking/bookingActions.ts | 23 +- .../components/dialog/ReportBookingDialog.tsx | 139 +++++++ apps/web/public/static/locales/en/common.json | 12 + .../bookings/lib/handleCancelBooking.ts | 3 +- .../repositories/BookingRepository.ts | 112 ++++++ .../bookings/services/BookingAccessService.ts | 98 +++++ .../users/repositories/UserRepository.ts | 13 + .../repository/bookingReport.interface.ts | 25 ++ .../server/repository/bookingReport.test.ts | 130 +++++++ .../lib/server/repository/bookingReport.ts | 43 +++ .../migration.sql | 49 +++ packages/prisma/schema.prisma | 37 ++ packages/prisma/zod-utils.ts | 1 + .../routers/viewer/bookings/_router.tsx | 11 + .../routers/viewer/bookings/get.handler.ts | 13 + .../bookings/reportBooking.handler.test.ts | 344 ++++++++++++++++++ .../viewer/bookings/reportBooking.handler.ts | 137 +++++++ .../viewer/bookings/reportBooking.schema.ts | 11 + 19 files changed, 1272 insertions(+), 4 deletions(-) create mode 100644 apps/web/components/dialog/ReportBookingDialog.tsx create mode 100644 packages/features/bookings/services/BookingAccessService.ts create mode 100644 packages/lib/server/repository/bookingReport.interface.ts create mode 100644 packages/lib/server/repository/bookingReport.test.ts create mode 100644 packages/lib/server/repository/bookingReport.ts create mode 100644 packages/prisma/migrations/20251013185902_add_booking_report/migration.sql create mode 100644 packages/trpc/server/routers/viewer/bookings/reportBooking.handler.test.ts 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 f673aa9f64..72ae646473 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,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 ( <> + {booking.paid && booking.payment[0] && ( ))} + <> + + + + {reportActionWithHandler.label} + + + )} {shouldShowRecurringCancelAction(actionContext) && } + {shouldShowIndividualReportButton(actionContext) && ( +
+
+ )} {isRejected &&
{t("rejected")}
} {isCancelled && booking.rescheduled && (
@@ -776,6 +833,24 @@ const BookingItemBadges = ({ {booking?.assignmentReason.length > 0 && ( )} + {booking.report && ( + + {(() => { + const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`; + const reasonText = t(reasonKey); + return booking.report.description + ? `${reasonText}: ${booking.report.description}` + : reasonText; + })()} +
+ }> + + {t("reported")} + + + )} {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 7d50942ebf..96457a65da 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -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": diff --git a/apps/web/components/dialog/ReportBookingDialog.tsx b/apps/web/components/dialog/ReportBookingDialog.tsx new file mode 100644 index 0000000000..b926e546ea --- /dev/null +++ b/apps/web/components/dialog/ReportBookingDialog.tsx @@ -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>; + 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({ + 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 ( + + +
+
+
+ +
+ + ( +