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:
authored and GitHub committed 2026-01-22 16:44:29 +00:00
1 parent 89ae748681
commit 6f7c8bdd73
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" },
+15 -2
View File
@@ -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",
+48 -2
View File
@@ -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';
+1
View File
@@ -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>;