feat: display assignment reason in organizer emails and booking single view (#27192)

This commit is contained in:
Joe Au-Yeung authored and GitHub committed 2026-01-23 20:50:07 -03:00
1 parent d9658a5a27
commit 3355fab4fb
12 files changed
+273 -29

No files matched your search

@@ -42,7 +42,9 @@ import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/t
import { getTimeShiftFlags, getFirstShiftFlags } from "@calcom/lib/timeShift";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { localStorage } from "@calcom/lib/webstorage";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { AssignmentReasonEnum, BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import assignmentReasonBadgeTitleMap from "@calcom/web/lib/booking/assignmentReasonBadgeTitleMap";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
@@ -764,6 +766,28 @@ export default function Success(props: PageProps) {
</div>
</>
)}
{canViewHiddenData &&
bookingInfo.assignmentReason &&
bookingInfo.assignmentReason.length > 0 &&
bookingInfo.assignmentReason[0].reasonEnum && (
<>
<div className="mt-9 font-medium">{t("assignment_reason")}</div>
<div className="col-span-2 mb-2 mt-9">
<Badge variant="gray" className="mb-2">
{t(
assignmentReasonBadgeTitleMap(
bookingInfo.assignmentReason[0].reasonEnum as AssignmentReasonEnum
)
)}
</Badge>
{bookingInfo.assignmentReason[0].reasonString && (
<p className="text-muted wrap-break-word text-sm">
{bookingInfo.assignmentReason[0].reasonString}
</p>
)}
</div>
</>
)}
</div>
<div className="text-bookingdark dark:border-darkgray-200 mt-8 text-left dark:text-gray-300">
{eventType.bookingFields.map((field) => {
@@ -129,6 +129,13 @@ export const BaseScheduledEmail = (
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer formatted />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
{props.isOrganizer && props.calEvent.assignmentReason && (
<Info
label={t("assignment_reason")}
description={`${t(props.calEvent.assignmentReason.category)}${props.calEvent.assignmentReason.details ? `: ${props.calEvent.assignmentReason.details}` : ""}`}
withSpacer
/>
)}
<UserFieldsResponses t={t} calEvent={props.calEvent} isOrganizer={props.isOrganizer} />
{props.calEvent.paymentInfo?.amount && (
<Info
@@ -617,6 +617,81 @@ describe("CalendarEventBuilder", () => {
}
});
it("should create an event with assignment reason", () => {
const event = new CalendarEventBuilder()
.withBasicDetails({
bookerUrl: "https://cal.com/user/test-slug",
title: "Test Event",
startTime: mockStartTime,
endTime: mockEndTime,
})
.withEventType({
slug: "test-slug",
id: 123,
})
.withAssignmentReason({
category: "routed",
details: "Language: English, Region: US",
})
.build();
expect(event).not.toBeNull();
if (event) {
expect(event.assignmentReason).toEqual({
category: "routed",
details: "Language: English, Region: US",
});
}
});
it("should create an event with assignment reason without details", () => {
const event = new CalendarEventBuilder()
.withBasicDetails({
bookerUrl: "https://cal.com/user/test-slug",
title: "Test Event",
startTime: mockStartTime,
endTime: mockEndTime,
})
.withEventType({
slug: "test-slug",
id: 123,
})
.withAssignmentReason({
category: "reassigned",
details: null,
})
.build();
expect(event).not.toBeNull();
if (event) {
expect(event.assignmentReason).toEqual({
category: "reassigned",
details: null,
});
}
});
it("should create an event with null assignment reason", () => {
const event = new CalendarEventBuilder()
.withBasicDetails({
bookerUrl: "https://cal.com/user/test-slug",
title: "Test Event",
startTime: mockStartTime,
endTime: mockEndTime,
})
.withEventType({
slug: "test-slug",
id: 123,
})
.withAssignmentReason(null)
.build();
expect(event).not.toBeNull();
if (event) {
expect(event.assignmentReason).toBeNull();
}
});
it("should create a complete calendar event with all properties", () => {
const event = new CalendarEventBuilder()
.withBasicDetails({
+45 -27
View File
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { ALL_APPS } from "@calcom/app-store/utils";
import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
@@ -85,21 +86,22 @@ export class CalendarEventBuilder {
if (!eventType) throw new Error(`Booking ${uid} is missing eventType — it may have been deleted.`);
const builder = new CalendarEventBuilder();
const {
description,
attendees,
references,
title,
startTime,
endTime,
location,
responses,
customInputs,
iCalUID,
iCalSequence,
oneTimePassword,
seatsReferences,
} = booking;
const {
description,
attendees,
references,
title,
startTime,
endTime,
location,
responses,
customInputs,
iCalUID,
iCalSequence,
oneTimePassword,
seatsReferences,
assignmentReason,
} = booking;
const {
conferenceCredentialId,
@@ -185,10 +187,18 @@ export class CalendarEventBuilder {
platformCancelUrl,
platformBookingUrl,
})
.withRecurring(recurring)
.withUid(uid)
.withOneTimePassword(oneTimePassword)
.withOrganization(organizationId);
.withRecurring(recurring)
.withUid(uid)
.withOneTimePassword(oneTimePassword)
.withOrganization(organizationId)
.withAssignmentReason(
assignmentReason?.[0]?.reasonEnum
? {
category: getAssignmentReasonCategory(assignmentReason[0].reasonEnum),
details: assignmentReason[0].reasonString ?? null,
}
: null
);
// Seats
if (seatsReferences?.length && bookingResponses) {
@@ -523,15 +533,23 @@ export class CalendarEventBuilder {
return this;
}
withHashedLink(hashedLink?: string | null) {
this.event = {
...this.event,
hashedLink,
};
return this;
}
withHashedLink(hashedLink?: string | null) {
this.event = {
...this.event,
hashedLink,
};
return this;
}
build(): CalendarEvent | null {
withAssignmentReason(assignmentReason?: { category: string; details?: string | null } | null) {
this.event = {
...this.event,
assignmentReason,
};
return this;
}
build(): CalendarEvent | null {
// Validate required fields
if (
!this.event.startTime ||
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { getAssignmentReasonCategory } from "./getAssignmentReasonCategory";
describe("getAssignmentReasonCategory", () => {
it("returns 'routed' for ROUTING_FORM_ROUTING", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.ROUTING_FORM_ROUTING)).toBe("routed");
});
it("returns 'routed' for ROUTING_FORM_ROUTING_FALLBACK", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK)).toBe("routed");
});
it("returns 'reassigned' for REASSIGNED", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.REASSIGNED)).toBe("reassigned");
});
it("returns 'reassigned' for RR_REASSIGNED", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.RR_REASSIGNED)).toBe("reassigned");
});
it("returns 'rerouted' for REROUTED", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.REROUTED)).toBe("rerouted");
});
it("returns 'salesforce_assigned' for SALESFORCE_ASSIGNMENT", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT)).toBe("salesforce_assigned");
});
it("returns 'routed' for unknown enum values (default case)", () => {
const unknownValue = "UNKNOWN_VALUE" as AssignmentReasonEnum;
expect(getAssignmentReasonCategory(unknownValue)).toBe("routed");
});
});
@@ -0,0 +1,23 @@
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
/**
* Maps an AssignmentReasonEnum to a translation key for the category label.
* Returns translation keys like "routed", "reassigned", "rerouted", "salesforce_assigned"
* that can be used with the t() function to get translated labels.
*/
export const getAssignmentReasonCategory = (assignmentReason: AssignmentReasonEnum): string => {
switch (assignmentReason) {
case AssignmentReasonEnum.ROUTING_FORM_ROUTING:
case AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK:
return "routed";
case AssignmentReasonEnum.REASSIGNED:
case AssignmentReasonEnum.RR_REASSIGNED:
return "reassigned";
case AssignmentReasonEnum.REROUTED:
return "rerouted";
case AssignmentReasonEnum.SALESFORCE_ASSIGNMENT:
return "salesforce_assigned";
default:
return "routed";
}
};
@@ -71,6 +71,16 @@ const getUserBooking = async (uid: string) => {
utm_content: true,
},
},
assignmentReason: {
select: {
reasonEnum: true,
reasonString: true,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
@@ -28,6 +28,7 @@ import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/sc
import getICalUID from "@calcom/emails/lib/getICalUID";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeUnAuthenticated";
import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory";
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types";
import type {
@@ -1992,6 +1993,20 @@ async function handler(
evt = updatedEvtWithPassword;
// Add assignment reason to evt for emails
if (assignmentReason) {
const updatedEvtWithAssignmentReason = CalendarEventBuilder.fromEvent(evt)
?.withAssignmentReason({
category: getAssignmentReasonCategory(assignmentReason.reasonEnum),
details: assignmentReason.reasonString ?? null,
})
.build();
if (updatedEvtWithAssignmentReason) {
evt = updatedEvtWithAssignmentReason;
}
}
if (booking && booking.id && eventType.seatsPerTimeSlot) {
const currentAttendee = booking.attendees.find(
(attendee) =>
@@ -342,6 +342,16 @@ const selectStatementToGetBookingForCalEventBuilder = {
attendee: { select: { id: true, email: true, phoneNumber: true } },
},
},
assignmentReason: {
select: {
reasonEnum: true,
reasonString: true,
},
orderBy: {
createdAt: "desc" as const,
},
take: 1,
},
};
export class BookingRepository implements IBookingRepository {
@@ -79,7 +79,7 @@ export type OOOEntryPayloadType = {
};
};
export type EventPayloadType = CalendarEvent &
export type EventPayloadType = Omit<CalendarEvent, "assignmentReason"> &
TranscriptionGeneratedPayload &
EventTypeInfo & {
uid?: string | null;
@@ -97,6 +97,11 @@ export type EventPayloadType = CalendarEvent &
cancelledBy?: string;
paymentData?: PaymentData;
requestReschedule?: boolean;
assignmentReason?:
| string
| { reasonEnum: string; reasonString: string }[]
| { category: string; details?: string | null }
| null;
};
export type WebhookPayloadType =
@@ -4,6 +4,7 @@ import { getLocationValueForDB } from "@calcom/app-store/locations";
import { sendDeclinedEmailsAndSMS } from "@calcom/emails/email-manager";
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
@@ -197,6 +198,16 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
recurringEventId: true,
status: true,
smsReminderNumber: true,
assignmentReason: {
select: {
reasonEnum: true,
reasonString: true,
},
orderBy: {
createdAt: "desc" as const,
},
take: 1,
},
},
});
@@ -321,6 +332,12 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
...(platformClientParams ? platformClientParams : {}),
organizationId: organizerOrganizationId ?? booking.eventType?.team?.parentId ?? null,
additionalNotes: booking.description,
assignmentReason: booking.assignmentReason?.[0]?.reasonEnum
? {
category: getAssignmentReasonCategory(booking.assignmentReason[0].reasonEnum),
details: booking.assignmentReason[0].reasonString ?? null,
}
: null,
};
const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent);
+4
View File
@@ -226,6 +226,10 @@ export interface CalendarEvent {
rescheduledBy?: string;
organizationId?: number | null;
hasOrganizerChanged?: boolean;
assignmentReason?: {
category: string; // Translated label like "Routed", "Reassigned", etc.
details?: string | null; // The detailed reason string
} | null;
}
export interface EntryPoint {