feat: display assignment reason in organizer emails and booking single view (#27192)
This commit is contained in:
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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+4
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user