feat: add wrong assignment report dialog and webhook for routing form bookings (#25839)
Co-authored-by: peer@cal.com <peer@cal.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
16 files changed
+1027
-7
No files matched your search
@@ -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 && (
|
||||
<WrongAssignmentDialog
|
||||
isOpenDialog={isOpenWrongAssignmentDialog}
|
||||
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
|
||||
bookingUid={booking.uid}
|
||||
routingReason={booking.assignmentReason[0]?.reasonString ?? null}
|
||||
guestEmail={booking.attendees[0]?.email ?? ""}
|
||||
hostEmail={booking.user?.email ?? ""}
|
||||
hostName={booking.user?.name ?? null}
|
||||
teamId={booking.eventType?.team?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = ({
|
||||
</Badge>
|
||||
)}
|
||||
{booking?.assignmentReason.length > 0 && (
|
||||
<AssignmentReasonTooltip assignmentReason={booking.assignmentReason[0]} />
|
||||
<AssignmentReasonTooltip
|
||||
assignmentReason={booking.assignmentReason[0]}
|
||||
onClick={onAssignmentReasonClick}
|
||||
/>
|
||||
)}
|
||||
{booking.report && (
|
||||
<Tooltip
|
||||
@@ -1092,12 +1119,21 @@ const DisplayAttendees = ({
|
||||
);
|
||||
};
|
||||
|
||||
const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: AssignmentReason }) => {
|
||||
const AssignmentReasonTooltip = ({
|
||||
assignmentReason,
|
||||
onClick,
|
||||
}: {
|
||||
assignmentReason: AssignmentReason;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum);
|
||||
return (
|
||||
<Tooltip content={<p>{assignmentReason.reasonString}</p>}>
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
<Badge
|
||||
className={classNames("ltr:mr-2 rtl:ml-2", onClick && "cursor-pointer hover:opacity-80")}
|
||||
variant="gray"
|
||||
onClick={onClick}>
|
||||
{t(badgeTitle)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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 && (
|
||||
<WrongAssignmentDialog
|
||||
isOpenDialog={isOpenWrongAssignmentDialog}
|
||||
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
|
||||
bookingUid={booking.uid}
|
||||
routingReason={booking.assignmentReason[0]?.reasonString ?? null}
|
||||
guestEmail={booking.attendees[0]?.email ?? ""}
|
||||
hostEmail={booking.user?.email ?? ""}
|
||||
hostName={booking.user?.name ?? null}
|
||||
teamId={booking.eventType?.team?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
@@ -683,6 +702,21 @@ export function BookingActionsDropdown({
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{isBookingFromRoutingForm && (
|
||||
<DropdownMenuItem className="rounded-lg" key="report_wrong_assignment">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon="user-x"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpenWrongAssignmentDialog(true);
|
||||
}}
|
||||
data-testid="report_wrong_assignment">
|
||||
{t("report_wrong_assignment")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -17,6 +17,7 @@ export type BookingActionsStore = {
|
||||
isOpenReportDialog: boolean;
|
||||
rerouteDialogIsOpen: boolean;
|
||||
isCancelDialogOpen: boolean;
|
||||
isOpenWrongAssignmentDialog: boolean;
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -31,6 +32,7 @@ export type BookingActionsStore = {
|
||||
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenWrongAssignmentDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
@@ -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<SetStateAction<boolean>>;
|
||||
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 (
|
||||
<div className="-mt-2 mb-4 space-y-3">
|
||||
<div>
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{routingReasonLabel}</Label>
|
||||
<p className="text-default bg-muted rounded-md px-3 py-2 text-sm">
|
||||
{routingReason || noRoutingReasonText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{whoBookedItLabel}</Label>
|
||||
<div className="text-default bg-muted flex items-center justify-between rounded-md px-3 py-2 text-sm">
|
||||
<span>{guestEmail}</span>
|
||||
<button type="button" className="text-subtle hover:text-emphasis ml-2" onClick={handleCopyClick}>
|
||||
<Icon name={copyIconName} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-emphasis mb-1 block text-sm font-medium">{whoReceivedItLabel}</Label>
|
||||
<p className="text-default bg-muted rounded-md px-3 py-2 text-sm">{hostDisplay}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssigneeSectionProps {
|
||||
teamId: number | null;
|
||||
teamMemberOptions: TeamMemberOption[];
|
||||
control: Control<FormValues>;
|
||||
whoShouldHaveReceivedItLabel: string;
|
||||
optionalLabel: string;
|
||||
selectTeamMemberPlaceholder: string;
|
||||
enterEmailPlaceholder: string;
|
||||
}
|
||||
|
||||
interface AdditionalNotesSectionProps {
|
||||
control: Control<FormValues>;
|
||||
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<FormValues, "additionalNotes">;
|
||||
}): JSX.Element => <TextArea {...field} placeholder={placeholder} rows={3} />;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="additionalNotes" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{additionalNotesLabel} <span className="text-error">*</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="additionalNotes"
|
||||
control={control}
|
||||
rules={{ required: fieldRequiredText }}
|
||||
render={renderTextArea}
|
||||
/>
|
||||
{errorMessage && <p className="text-error mt-1 text-sm">{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeSection(props: AssigneeSectionProps): JSX.Element {
|
||||
const {
|
||||
teamId,
|
||||
teamMemberOptions,
|
||||
control,
|
||||
whoShouldHaveReceivedItLabel,
|
||||
optionalLabel,
|
||||
selectTeamMemberPlaceholder,
|
||||
enterEmailPlaceholder,
|
||||
} = props;
|
||||
|
||||
const showTeamSelector = teamId && teamMemberOptions.length > 0;
|
||||
|
||||
const renderTeamSelect = ({
|
||||
field,
|
||||
}: {
|
||||
field: ControllerRenderProps<FormValues, "correctAssignee">;
|
||||
}): JSX.Element => {
|
||||
const handleChange = (option: TeamMemberOption | null): void => {
|
||||
if (option) field.onChange(option.value);
|
||||
};
|
||||
return (
|
||||
<Select
|
||||
options={teamMemberOptions}
|
||||
placeholder={selectTeamMemberPlaceholder}
|
||||
onChange={handleChange}
|
||||
value={teamMemberOptions.find((opt) => opt.value === field.value) || null}
|
||||
isClearable
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmailInput = ({
|
||||
field,
|
||||
}: {
|
||||
field: ControllerRenderProps<FormValues, "correctAssignee">;
|
||||
}): JSX.Element => (
|
||||
<input
|
||||
{...field}
|
||||
type="email"
|
||||
placeholder={enterEmailPlaceholder}
|
||||
className="border-default bg-default text-emphasis placeholder:text-muted focus:border-emphasis focus:ring-emphasis block w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-1"
|
||||
/>
|
||||
);
|
||||
|
||||
let assigneeField: JSX.Element;
|
||||
if (showTeamSelector) {
|
||||
assigneeField = <Controller name="correctAssignee" control={control} render={renderTeamSelect} />;
|
||||
} else {
|
||||
assigneeField = <Controller name="correctAssignee" control={control} render={renderEmailInput} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="correctAssignee" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{whoShouldHaveReceivedItLabel} <span className="text-subtle font-normal">({optionalLabel})</span>
|
||||
</Label>
|
||||
{assigneeField}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: Dialog component with hooks and state management - already split into 3 sub-components
|
||||
export function WrongAssignmentDialog(props: IWrongAssignmentDialog): JSX.Element {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const {
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
bookingUid,
|
||||
routingReason,
|
||||
guestEmail,
|
||||
hostEmail,
|
||||
hostName,
|
||||
teamId,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
correctAssignee: "",
|
||||
additionalNotes: "",
|
||||
},
|
||||
});
|
||||
|
||||
const teamIdForQuery = teamId ?? 0;
|
||||
const { data: teamMembersData } = trpc.viewer.teams.listMembers.useQuery(
|
||||
{ teamId: teamIdForQuery, limit: 100 },
|
||||
{ enabled: !!teamId && isOpenDialog }
|
||||
);
|
||||
|
||||
const teamMemberOptions: TeamMemberOption[] =
|
||||
teamMembersData?.members.map((member) => ({
|
||||
label: member.name || member.email,
|
||||
value: member.email,
|
||||
email: member.email,
|
||||
})) ?? [];
|
||||
|
||||
const { mutate: reportWrongAssignment, isPending } = trpc.viewer.bookings.reportWrongAssignment.useMutation(
|
||||
{
|
||||
async onSuccess(): Promise<void> {
|
||||
showToast(t("wrong_assignment_reported"), "success");
|
||||
setIsOpenDialog(false);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError(error: { message?: string }): void {
|
||||
showToast(error.message || t("unexpected_error_try_again"), "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const onSubmit = (data: FormValues): void => {
|
||||
reportWrongAssignment({
|
||||
bookingUid,
|
||||
correctAssignee: data.correctAssignee || undefined,
|
||||
additionalNotes: data.additionalNotes,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseClick = (): void => {
|
||||
setIsOpenDialog(false);
|
||||
};
|
||||
|
||||
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("wrong_assignment")} />
|
||||
|
||||
<RoutingInfoSection
|
||||
routingReason={routingReason}
|
||||
noRoutingReasonText={t("no_routing_reason")}
|
||||
routingReasonLabel={t("routing_reason")}
|
||||
guestEmail={guestEmail}
|
||||
whoBookedItLabel={t("who_booked_it")}
|
||||
hostEmail={hostEmail}
|
||||
hostName={hostName}
|
||||
whoReceivedItLabel={t("who_received_it")}
|
||||
copyToClipboard={copyToClipboard}
|
||||
isCopied={isCopied}
|
||||
/>
|
||||
|
||||
<AssigneeSection
|
||||
teamId={teamId}
|
||||
teamMemberOptions={teamMemberOptions}
|
||||
control={control}
|
||||
whoShouldHaveReceivedItLabel={t("who_should_have_received_it")}
|
||||
optionalLabel={t("optional")}
|
||||
selectTeamMemberPlaceholder={t("select_team_member")}
|
||||
enterEmailPlaceholder={t("enter_email")}
|
||||
/>
|
||||
|
||||
<AdditionalNotesSection
|
||||
control={control}
|
||||
additionalNotesLabel={t("additional_notes")}
|
||||
placeholder={t("wrong_assignment_notes_placeholder")}
|
||||
fieldRequiredText={t("field_required")}
|
||||
errorMessage={errors.additionalNotes?.message}
|
||||
/>
|
||||
|
||||
<Alert severity="info" title={t("did_you_know")} message={t("wrong_assignment_crm_info")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<Button type="button" color="secondary" onClick={handleCloseClick} disabled={isPending}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
|
||||
value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
label: "after_guests_cal_video_no_show",
|
||||
},
|
||||
{ value: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT, label: "wrong_assignment_report" },
|
||||
],
|
||||
"routing-forms": [
|
||||
{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" },
|
||||
|
||||
@@ -823,6 +823,18 @@
|
||||
"apply": "Apply",
|
||||
"cancel_event": "Cancel event",
|
||||
"report_booking": "Report booking",
|
||||
"report_wrong_assignment": "Report wrong assignment",
|
||||
"wrong_assignment": "Wrong Assignment",
|
||||
"wrong_assignment_reported": "Wrong assignment reported successfully",
|
||||
"routing_reason": "Routing Reason",
|
||||
"no_routing_reason": "No routing reason available",
|
||||
"who_booked_it": "Who booked it?",
|
||||
"who_received_it": "Who received it?",
|
||||
"who_should_have_received_it": "Who should have received it?",
|
||||
"wrong_assignment_notes_placeholder": "Any additional context about why this assignment was incorrect...",
|
||||
"did_you_know": "Did you know?",
|
||||
"wrong_assignment_crm_info": "Routing can only be as good as the provided customer data. There is a high chance this assignment was wrong because the record in the CRM is incorrect, not because of an invalid routing configuration.",
|
||||
"field_required": "This field is required",
|
||||
"report_booking_will_cancel_description": "Reporting this booking will automatically cancel it",
|
||||
"add_to_report": "Add to report",
|
||||
"reported": "Reported",
|
||||
@@ -1593,6 +1605,7 @@
|
||||
"event_cancelled_trigger": "when event is canceled",
|
||||
"after_hosts_cal_video_no_show": "After hosts didn't join cal video",
|
||||
"after_guests_cal_video_no_show": "After guests didn't join cal video",
|
||||
"wrong_assignment_report": "Wrong assignment report",
|
||||
"join_video_call": "Join the video call",
|
||||
"sign_in_to_cal_com": "Sign in to Cal.com",
|
||||
"log_in_to_cal_com": "Log in to Cal.com",
|
||||
@@ -2152,8 +2165,8 @@
|
||||
"member_already_invited": "Member has already been invited",
|
||||
"already_in_use_error": "Username already in use",
|
||||
"enter_email_or_username": "Enter an email or username",
|
||||
"enter_email": "Enter an email",
|
||||
"enter_emails": "Enter emails",
|
||||
"enter_email": "Enter an email",
|
||||
"enter_emails": "Enter emails",
|
||||
"too_many_invites": "You are limited to inviting a maximum of {{nbUsers}} users at once.",
|
||||
"team_name_taken": "This name is already taken",
|
||||
"must_enter_team_name": "Must enter a team name",
|
||||
|
||||
@@ -38,6 +38,7 @@ To create a new webhook subscription, visit `/settings/developer/webhooks` and p
|
||||
- `Booking No-show Updated`
|
||||
- `After Hosts Didn't Join Cal Video`
|
||||
- `After Guests Didn't Join Cal Video`
|
||||
- `Wrong Assignment Report`
|
||||
</Step>
|
||||
<Step title="Secret">
|
||||
You can provide a secret key with this webhook to [verify it](/docs/core-features/webhooks#verifying-the-authenticity-of-the-received-payload) on the subscriber URL when receiving a payload. This helps confirm whether the payload is authentic or has been tampered with. You can leave it blank if you don't wish to secure the webhook with a secret key.
|
||||
@@ -1460,6 +1461,51 @@ Select a version and trigger event to view the example payload:
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="WRONG_ASSIGNMENT_REPORT">
|
||||
This webhook is triggered when a team member reports that a booking from a routing form was assigned to the wrong person. This is useful for tracking routing accuracy and improving CRM data quality.
|
||||
|
||||
<Note>
|
||||
This webhook only fires for bookings that came through a routing form and have an assignment reason.
|
||||
</Note>
|
||||
|
||||
```json
|
||||
{
|
||||
"triggerEvent": "WRONG_ASSIGNMENT_REPORT",
|
||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||
"payload": {
|
||||
"booking": {
|
||||
"uid": "abc123def456",
|
||||
"id": 12345,
|
||||
"title": "Sales Call between Team and Lead",
|
||||
"startTime": "2025-01-15T14:00:00.000Z",
|
||||
"endTime": "2025-01-15T14:30:00.000Z",
|
||||
"status": "ACCEPTED",
|
||||
"eventType": {
|
||||
"id": 100,
|
||||
"title": "Sales Call",
|
||||
"slug": "sales-call",
|
||||
"teamId": 50
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"reportedBy": {
|
||||
"id": 1,
|
||||
"email": "reporter@example.com",
|
||||
"name": "Reporter Name"
|
||||
},
|
||||
"routingReason": "Routed based on company size: Enterprise",
|
||||
"guest": "lead@company.com",
|
||||
"host": {
|
||||
"email": "assigned-rep@example.com",
|
||||
"name": "Assigned Rep"
|
||||
},
|
||||
"correctAssignee": "correct-rep@example.com",
|
||||
"additionalNotes": "This lead should have been routed to the Enterprise team based on their company size."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -1652,7 +1698,7 @@ where `{{type}}` represents the event type slug and `{{title}}` represents the t
|
||||
|
||||
| Variable | Type | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| triggerEvent | String | The name of the trigger event [BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, MEETING_ENDED, BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, BOOKING_PAID, MEETING_STARTED, RECORDING_READY, FORM_SUBMITTED, BOOKING_NO_SHOW_UPDATED, AFTER_HOSTS_CAL_VIDEO_NO_SHOW, AFTER_GUESTS_CAL_VIDEO_NO_SHOW] |
|
||||
| triggerEvent | String | The name of the trigger event [BOOKING_CREATED, BOOKING_RESCHEDULED, BOOKING_CANCELLED, MEETING_ENDED, BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, BOOKING_PAID, MEETING_STARTED, RECORDING_READY, FORM_SUBMITTED, BOOKING_NO_SHOW_UPDATED, AFTER_HOSTS_CAL_VIDEO_NO_SHOW, AFTER_GUESTS_CAL_VIDEO_NO_SHOW, WRONG_ASSIGNMENT_REPORT] |
|
||||
| createdAt | Datetime | The time of the webhook |
|
||||
| type | String | The event type slug |
|
||||
| title | String | The event type name |
|
||||
@@ -1677,4 +1723,4 @@ where `{{type}}` represents the event type slug and `{{title}}` represents the t
|
||||
| name | String | Name of the individual |
|
||||
| email | Email | Email of the individual |
|
||||
| timezone | String | Timezone of the individual (e.g., "America/New_York", "Asia/Kolkata") |
|
||||
| language?.locale | String | Locale of the individual (e.g., "en", "fr") |
|
||||
| language?.locale | String | Locale of the individual (e.g., "en", "fr") |
|
||||
@@ -2002,6 +2002,49 @@ async updateMany({ where, data }: { where: BookingWhereInput; data: BookingUpdat
|
||||
});
|
||||
}
|
||||
|
||||
async findByUidIncludeEventTypeAndTeamAndAssignmentReason({ bookingUid }: { bookingUid: string }) {
|
||||
return await this.prismaClient.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
status: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
assignmentReason: {
|
||||
select: {
|
||||
reasonString: true,
|
||||
reasonEnum: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateRecordedStatus({
|
||||
bookingUid,
|
||||
isRecorded,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
||||
WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
|
||||
WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT,
|
||||
] as const,
|
||||
"routing-forms": [
|
||||
WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
|
||||
@@ -110,6 +110,9 @@ const TRIGGER_TO_BUILDER_CATEGORY: Record<WebhookTriggerEvents, BuilderCategory>
|
||||
|
||||
// Delegation events
|
||||
[WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR]: "delegation",
|
||||
|
||||
// Wrong assignment report events
|
||||
[WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT]: "booking",
|
||||
};
|
||||
|
||||
export type BookingTriggerEvents =
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."WebhookTriggerEvents" ADD VALUE 'WRONG_ASSIGNMENT_REPORT';
|
||||
@@ -1119,6 +1119,7 @@ enum WebhookTriggerEvents {
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW
|
||||
FORM_SUBMITTED_NO_EVENT
|
||||
DELEGATION_CREDENTIAL_ERROR
|
||||
WRONG_ASSIGNMENT_REPORT
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ZGetBookingDetailsInputSchema } from "./getBookingDetails.schema";
|
||||
import { ZGetBookingHistoryInputSchema } from "./getBookingHistory.schema";
|
||||
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
|
||||
import { ZReportBookingInputSchema } from "./reportBooking.schema";
|
||||
import { ZReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema";
|
||||
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
|
||||
import { bookingsProcedure } from "./util";
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
@@ -117,6 +118,16 @@ export const bookingsRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
reportWrongAssignment: authedProcedure
|
||||
.input(ZReportWrongAssignmentInputSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { reportWrongAssignmentHandler } = await import("./reportWrongAssignment.handler");
|
||||
|
||||
return reportWrongAssignmentHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
getBookingHistory: authedProcedure.input(ZGetBookingHistoryInputSchema).query(async ({ input, ctx }) => {
|
||||
const { getBookingHistoryHandler } = await import("./getBookingHistory.handler");
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reportWrongAssignmentHandler } from "./reportWrongAssignment.handler";
|
||||
|
||||
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
|
||||
vi.mock("@calcom/features/bookings/services/BookingAccessService");
|
||||
vi.mock("@calcom/features/webhooks/lib/getWebhooks");
|
||||
vi.mock("@calcom/features/webhooks/lib/sendPayload");
|
||||
vi.mock("@calcom/prisma", () => ({ default: {} }));
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("reportWrongAssignmentHandler", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: "reporter@example.com",
|
||||
name: "Reporter User",
|
||||
};
|
||||
|
||||
const mockBooking = {
|
||||
id: 100,
|
||||
uid: "test-booking-uid",
|
||||
title: "Test Booking",
|
||||
startTime: new Date("2025-12-01T10:00:00Z"),
|
||||
endTime: new Date("2025-12-01T11:00:00Z"),
|
||||
status: BookingStatus.ACCEPTED,
|
||||
user: {
|
||||
id: 2,
|
||||
email: "host@example.com",
|
||||
name: "Host User",
|
||||
},
|
||||
attendees: [{ email: "guest@example.com" }],
|
||||
eventType: {
|
||||
id: 10,
|
||||
title: "Test Event",
|
||||
slug: "test-event",
|
||||
teamId: 5,
|
||||
},
|
||||
assignmentReason: [{ reasonString: "Matched by round-robin" }],
|
||||
};
|
||||
|
||||
const mockBookingRepo = {
|
||||
findByUidIncludeEventTypeAndTeamAndAssignmentReason: vi.fn(),
|
||||
};
|
||||
|
||||
const mockBookingAccessService = {
|
||||
doesUserIdHaveAccessToBooking: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(BookingRepository).mockImplementation(function () {
|
||||
return mockBookingRepo;
|
||||
});
|
||||
vi.mocked(BookingAccessService).mockImplementation(function () {
|
||||
return mockBookingAccessService;
|
||||
});
|
||||
vi.mocked(getWebhooks).mockResolvedValue([]);
|
||||
vi.mocked(sendGenericWebhookPayload).mockResolvedValue({ ok: true, status: 200 });
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
it("should throw FORBIDDEN when user doesn't have access to booking", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "This booking was assigned to me incorrectly",
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(TRPCError);
|
||||
|
||||
await expect(
|
||||
reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "This booking was assigned to me incorrectly",
|
||||
},
|
||||
})
|
||||
).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.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "This booking was assigned to me incorrectly",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Booking not found",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful report", () => {
|
||||
it("should successfully report with all fields", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
|
||||
|
||||
const result = await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
correctAssignee: "correct@example.com",
|
||||
additionalNotes: "This booking should have gone to the sales team",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe("Wrong assignment reported successfully");
|
||||
});
|
||||
|
||||
it("should successfully report with only required fields (no correctAssignee)", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
|
||||
|
||||
const result = await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Wrong person received this booking",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe("Wrong assignment reported successfully");
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook integration", () => {
|
||||
it("should send webhook with correct payload structure", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
|
||||
vi.mocked(getWebhooks).mockResolvedValue([
|
||||
{ id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" },
|
||||
] as never);
|
||||
|
||||
await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
correctAssignee: "correct@example.com",
|
||||
additionalNotes: "Wrong assignment notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(getWebhooks).toHaveBeenCalledWith({
|
||||
userId: mockBooking.user.id,
|
||||
teamId: mockBooking.eventType.teamId,
|
||||
triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT,
|
||||
});
|
||||
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT,
|
||||
data: expect.objectContaining({
|
||||
booking: expect.objectContaining({
|
||||
uid: mockBooking.uid,
|
||||
id: mockBooking.id,
|
||||
title: mockBooking.title,
|
||||
}),
|
||||
report: expect.objectContaining({
|
||||
reportedBy: expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
}),
|
||||
routingReason: "Matched by round-robin",
|
||||
guest: "guest@example.com",
|
||||
host: expect.objectContaining({
|
||||
email: "host@example.com",
|
||||
}),
|
||||
correctAssignee: "correct@example.com",
|
||||
additionalNotes: "Wrong assignment notes",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle webhook failures gracefully", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
|
||||
vi.mocked(getWebhooks).mockResolvedValue([
|
||||
{ id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" },
|
||||
] as never);
|
||||
vi.mocked(sendGenericWebhookPayload).mockRejectedValue(new Error("Webhook delivery failed"));
|
||||
|
||||
const result = await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe("Wrong assignment reported successfully");
|
||||
});
|
||||
|
||||
it("should not fail when getWebhooks throws", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue(mockBooking);
|
||||
vi.mocked(getWebhooks).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle booking without assignmentReason", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({
|
||||
...mockBooking,
|
||||
assignmentReason: [],
|
||||
});
|
||||
vi.mocked(getWebhooks).mockResolvedValue([
|
||||
{ id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" },
|
||||
] as never);
|
||||
|
||||
await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
report: expect.objectContaining({
|
||||
routingReason: null,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle booking without user", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({
|
||||
...mockBooking,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const result = await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(getWebhooks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle booking without eventType", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({
|
||||
...mockBooking,
|
||||
eventType: null,
|
||||
});
|
||||
vi.mocked(getWebhooks).mockResolvedValue([
|
||||
{ id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" },
|
||||
] as never);
|
||||
|
||||
await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(getWebhooks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: null,
|
||||
})
|
||||
);
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
booking: expect.objectContaining({
|
||||
eventType: null,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle booking without attendees", async () => {
|
||||
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
|
||||
mockBookingRepo.findByUidIncludeEventTypeAndTeamAndAssignmentReason.mockResolvedValue({
|
||||
...mockBooking,
|
||||
attendees: [],
|
||||
});
|
||||
vi.mocked(getWebhooks).mockResolvedValue([
|
||||
{ id: "webhook-1", subscriberUrl: "https://example.com/webhook", secret: "secret123" },
|
||||
] as never);
|
||||
|
||||
await reportWrongAssignmentHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
bookingUid: "test-booking-uid",
|
||||
additionalNotes: "Notes",
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
report: expect.objectContaining({
|
||||
guest: "",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema";
|
||||
|
||||
type ReportWrongAssignmentOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TReportWrongAssignmentInputSchema;
|
||||
};
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["reportWrongAssignmentHandler"] });
|
||||
|
||||
export const reportWrongAssignmentHandler = async ({ ctx, input }: ReportWrongAssignmentOptions) => {
|
||||
const { user } = ctx;
|
||||
const { bookingUid, correctAssignee, additionalNotes } = input;
|
||||
|
||||
const bookingRepo = new BookingRepository(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.findByUidIncludeEventTypeAndTeamAndAssignmentReason({ bookingUid });
|
||||
|
||||
if (!booking) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Booking not found" });
|
||||
}
|
||||
|
||||
const teamId = booking.eventType?.teamId || null;
|
||||
const bookingUserId = booking.user?.id || null;
|
||||
|
||||
const assignmentReason = booking.assignmentReason?.[0];
|
||||
const guestEmail = booking.attendees[0]?.email || "";
|
||||
const hostEmail = booking.user?.email || "";
|
||||
const hostName = booking.user?.name || null;
|
||||
|
||||
const webhookPayload = {
|
||||
booking: {
|
||||
uid: booking.uid,
|
||||
id: booking.id,
|
||||
title: booking.title,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
status: booking.status,
|
||||
eventType: booking.eventType
|
||||
? {
|
||||
id: booking.eventType.id,
|
||||
title: booking.eventType.title,
|
||||
slug: booking.eventType.slug,
|
||||
teamId: booking.eventType.teamId,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
report: {
|
||||
reportedBy: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
routingReason: assignmentReason?.reasonString || null,
|
||||
guest: guestEmail,
|
||||
host: {
|
||||
email: hostEmail,
|
||||
name: hostName,
|
||||
},
|
||||
correctAssignee: correctAssignee || null,
|
||||
additionalNotes: additionalNotes || null,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const webhooks = await getWebhooks({
|
||||
userId: bookingUserId,
|
||||
teamId,
|
||||
triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT,
|
||||
});
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
sendGenericWebhookPayload({
|
||||
secretKey: webhook.secret,
|
||||
triggerEvent: WebhookTriggerEvents.WRONG_ASSIGNMENT_REPORT,
|
||||
createdAt: new Date().toISOString(),
|
||||
webhook,
|
||||
data: webhookPayload,
|
||||
}).catch((error) => {
|
||||
log.error(`Failed to send webhook to ${webhook.subscriberUrl}:`, error);
|
||||
return { ok: false, status: 0 };
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.allSettled(webhookPromises);
|
||||
|
||||
log.info(`Wrong assignment report sent for booking ${bookingUid}`, {
|
||||
teamId,
|
||||
userId: bookingUserId,
|
||||
webhookCount: webhooks.length,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to send wrong assignment webhooks:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Wrong assignment reported successfully",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZReportWrongAssignmentInputSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
correctAssignee: z.string().email().optional(),
|
||||
additionalNotes: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TReportWrongAssignmentInputSchema = z.infer<typeof ZReportWrongAssignmentInputSchema>;
|
||||
Reference in new issue
Block a user