From 6f7c8bdd739faa9305c0b24638aefbb70624e1b0 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 22 Jan 2026 17:44:29 +0100 Subject: [PATCH] feat: add wrong assignment report dialog and webhook for routing form bookings (#25839) Co-authored-by: peer@cal.com Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 42 +- .../actions/BookingActionsDropdown.tsx | 34 ++ apps/web/components/booking/actions/store.ts | 8 + .../dialog/WrongAssignmentDialog.tsx | 330 ++++++++++++++++ .../webhooks/components/WebhookForm.tsx | 1 + apps/web/public/static/locales/en/common.json | 17 +- .../developing/guides/automation/webhooks.mdx | 50 ++- .../repositories/BookingRepository.ts | 43 +++ packages/features/webhooks/lib/constants.ts | 1 + .../versioned/PayloadBuilderFactory.ts | 3 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../routers/viewer/bookings/_router.tsx | 11 + .../reportWrongAssignment.handler.test.ts | 360 ++++++++++++++++++ .../bookings/reportWrongAssignment.handler.ts | 122 ++++++ .../bookings/reportWrongAssignment.schema.ts | 9 + 16 files changed, 1027 insertions(+), 7 deletions(-) create mode 100644 apps/web/components/dialog/WrongAssignmentDialog.tsx create mode 100644 packages/prisma/migrations/20260122133147_add_wrong_assignment_webhook/migration.sql create mode 100644 packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/reportWrongAssignment.schema.ts diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index c617ae115f..b11f673cb7 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -40,6 +40,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip"; import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap"; +import { WrongAssignmentDialog } from "../dialog/WrongAssignmentDialog"; import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink"; import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore"; import type { BookingAttendee } from "../../modules/bookings/types"; @@ -273,6 +274,12 @@ function BookingListItem(booking: BookingItemProps) { const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog); const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen); + const isOpenWrongAssignmentDialog = useBookingActionsStoreContext( + (state) => state.isOpenWrongAssignmentDialog + ); + const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext( + (state) => state.setIsOpenWrongAssignmentDialog + ); const reportAction = getReportAction(actionContext); const reportActionWithHandler = { @@ -512,7 +519,22 @@ function BookingListItem(booking: BookingItemProps) { userTimeFormat={userTimeFormat} userTimeZone={userTimeZone} isRescheduled={isRescheduled} + onAssignmentReasonClick={ + isBookingFromRoutingForm ? () => setIsOpenWrongAssignmentDialog(true) : undefined + } /> + {isBookingFromRoutingForm && ( + + )} ); } @@ -525,6 +547,7 @@ const BookingItemBadges = ({ userTimeFormat, userTimeZone, isRescheduled, + onAssignmentReasonClick, }: { booking: BookingItemProps; isPending: boolean; @@ -533,6 +556,7 @@ const BookingItemBadges = ({ userTimeFormat: number | null | undefined; userTimeZone: string | undefined; isRescheduled: boolean; + onAssignmentReasonClick?: () => void; }) => { const { t } = useLocale(); @@ -561,7 +585,10 @@ const BookingItemBadges = ({ )} {booking?.assignmentReason.length > 0 && ( - + )} {booking.report && ( { +const AssignmentReasonTooltip = ({ + assignmentReason, + onClick, +}: { + assignmentReason: AssignmentReason; + onClick?: () => void; +}) => { const { t } = useLocale(); const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum); return ( {assignmentReason.reasonString}

}> - + {t(badgeTitle)}
diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index c883685481..651dc48816 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -31,6 +31,7 @@ import { RejectionReasonDialog } from "@components/dialog/RejectionReasonDialog" import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; +import { WrongAssignmentDialog } from "@components/dialog/WrongAssignmentDialog"; import { useBookingConfirmation } from "../hooks/useBookingConfirmation"; import type { BookingItemProps } from "../types"; @@ -118,6 +119,12 @@ export function BookingActionsDropdown({ const setIsOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.setIsOpenAddGuestsDialog); const isOpenReportDialog = useBookingActionsStoreContext((state) => state.isOpenReportDialog); const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog); + const isOpenWrongAssignmentDialog = useBookingActionsStoreContext( + (state) => state.isOpenWrongAssignmentDialog + ); + const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext( + (state) => state.setIsOpenWrongAssignmentDialog + ); const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen); const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen); const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen); @@ -440,6 +447,18 @@ export function BookingActionsDropdown({ isRecurring={isRecurring} status={getBookingStatus()} /> + {isBookingFromRoutingForm && ( + + )} {booking.paid && booking.payment[0] && ( + {isBookingFromRoutingForm && ( + + { + e.stopPropagation(); + setIsOpenWrongAssignmentDialog(true); + }} + data-testid="report_wrong_assignment"> + {t("report_wrong_assignment")} + + + )} >; @@ -31,6 +32,7 @@ export type BookingActionsStore = { setIsOpenReportDialog: React.Dispatch>; setRerouteDialogIsOpen: React.Dispatch>; setIsCancelDialogOpen: React.Dispatch>; + setIsOpenWrongAssignmentDialog: React.Dispatch>; }; export const createBookingActionsStore = () => { @@ -48,6 +50,7 @@ export const createBookingActionsStore = () => { isOpenReportDialog: false, rerouteDialogIsOpen: false, isCancelDialogOpen: false, + isOpenWrongAssignmentDialog: false, // Dialog setters setRejectionDialogIsOpen: (isOpen) => @@ -101,5 +104,10 @@ export const createBookingActionsStore = () => { set((state) => ({ isCancelDialogOpen: typeof isOpen === "function" ? isOpen(state.isCancelDialogOpen) : isOpen, })), + setIsOpenWrongAssignmentDialog: (isOpen) => + set((state) => ({ + isOpenWrongAssignmentDialog: + typeof isOpen === "function" ? isOpen(state.isOpenWrongAssignmentDialog) : isOpen, + })), })); }; diff --git a/apps/web/components/dialog/WrongAssignmentDialog.tsx b/apps/web/components/dialog/WrongAssignmentDialog.tsx new file mode 100644 index 0000000000..bb780e87dd --- /dev/null +++ b/apps/web/components/dialog/WrongAssignmentDialog.tsx @@ -0,0 +1,330 @@ +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +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 { Label, Select, TextArea } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; +import { showToast } from "@calcom/ui/components/toast"; +import type { Dispatch, SetStateAction } from "react"; +import type { Control, ControllerRenderProps } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; + +interface IWrongAssignmentDialog { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + bookingUid: string; + routingReason: string | null; + guestEmail: string; + hostEmail: string; + hostName: string | null; + teamId: number | null; +} + +interface FormValues { + correctAssignee: string; + additionalNotes: string; +} + +interface TeamMemberOption { + label: string; + value: string; + email: string; +} + +interface RoutingInfoSectionProps { + routingReason: string | null; + noRoutingReasonText: string; + routingReasonLabel: string; + guestEmail: string; + whoBookedItLabel: string; + hostEmail: string; + hostName: string | null; + whoReceivedItLabel: string; + copyToClipboard: (text: string) => void; + isCopied: boolean; +} + +function RoutingInfoSection(props: RoutingInfoSectionProps): JSX.Element { + const { + routingReason, + noRoutingReasonText, + routingReasonLabel, + guestEmail, + whoBookedItLabel, + hostEmail, + hostName, + whoReceivedItLabel, + copyToClipboard, + isCopied, + } = props; + + let copyIconName: "check" | "copy" = "copy"; + if (isCopied) { + copyIconName = "check"; + } + + let hostDisplay = hostEmail; + if (hostName) { + hostDisplay = `${hostName} (${hostEmail})`; + } + + const handleCopyClick = (): void => { + copyToClipboard(guestEmail); + }; + + return ( +
+
+ +

+ {routingReason || noRoutingReasonText} +

+
+ +
+ +
+ {guestEmail} + +
+
+ +
+ +

{hostDisplay}

+
+
+ ); +} + +interface AssigneeSectionProps { + teamId: number | null; + teamMemberOptions: TeamMemberOption[]; + control: Control; + whoShouldHaveReceivedItLabel: string; + optionalLabel: string; + selectTeamMemberPlaceholder: string; + enterEmailPlaceholder: string; +} + +interface AdditionalNotesSectionProps { + control: Control; + additionalNotesLabel: string; + placeholder: string; + fieldRequiredText: string; + errorMessage: string | undefined; +} + +function AdditionalNotesSection(props: AdditionalNotesSectionProps): JSX.Element { + const { control, additionalNotesLabel, placeholder, fieldRequiredText, errorMessage } = props; + + const renderTextArea = ({ + field, + }: { + field: ControllerRenderProps; + }): JSX.Element =>