fix: give reason to reassign - allow team members to reassign even if busy (#17328)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
sean-brydon
2024-10-25 19:13:26 +00:00
committed by GitHub
co-authored by CarinaWolli Omar López
parent b53abe93bc
commit c5402d8bd7
17 changed files with 134 additions and 67 deletions
+33 -3
View File
@@ -1,8 +1,10 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { components } from "react-select";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { ErrorCode } from "@calcom/lib/errorCodes";
@@ -20,6 +22,7 @@ import {
showToast,
Select,
RadioGroup as RadioArea,
TextAreaField,
} from "@calcom/ui";
type ReassignDialog = {
@@ -32,8 +35,20 @@ type ReassignDialog = {
type FormValues = {
reassignType: "round_robin" | "team_member";
teamMemberId?: number;
reassignReason?: string;
};
const formSchema = z.object({
reassignType: z.enum(["round_robin", "team_member"]),
teamMemberId: z.number().optional(),
reassignReason: z
.string()
.optional()
.refine((val) => !val || val.length >= 10, {
message: "Reassign reason must be at least 10 characters long if provided",
}),
});
export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -65,6 +80,7 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
}, [teamMembers]);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
reassignType: "round_robin",
},
@@ -111,7 +127,11 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
if (selectedMember && selectedMember.status === "unavailable") {
setShowConfirmation(true);
} else {
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId: values.teamMemberId,
reassignReason: values.reassignReason,
});
}
}
}
@@ -207,10 +227,20 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
if (!teamMemberId) {
return;
}
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId });
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
setShowConfirmation(false);
}}>
{t("reassign_unavailable_team_member_description")}
<p className="mb-4">{t("reassign_unavailable_team_member_description")}</p>
<TextAreaField
name="reassignReason"
label={t("reassign_reason")}
onChange={(e) => form.setValue("reassignReason", e.target.value)}
required
/>
</ConfirmationDialogContent>
</Dialog>
</>
@@ -2696,9 +2696,11 @@
"add_new_field": "Add new field",
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
"form_response_not_found": "Form response not found",
"confirm_reassign_unavailable": "Manual Reassign to unavailable host",
"confirm_reassign_unavailable": "Host unavailable",
"reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?",
"yes_reassign": "Yes, reassign",
"reassign_reason": "Reason for reassigning",
"reassigned_by": "Reassigned by",
"row": "row",
"rows": "rows",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
+16 -8
View File
@@ -144,11 +144,17 @@ export const sendScheduledEmailsAndSMS = async (
};
// for rescheduled round robin booking that assigned new members
export const sendRoundRobinScheduledEmailsAndSMS = async (
calEvent: CalendarEvent,
members: Person[],
eventTypeMetadata?: EventTypeMetadata
) => {
export const sendRoundRobinScheduledEmailsAndSMS = async ({
calEvent,
members,
eventTypeMetadata,
reassigned,
}: {
calEvent: CalendarEvent;
members: Person[];
eventTypeMetadata?: EventTypeMetadata;
reassigned?: { name: string | null; email: string; reason?: string; byUser?: string };
}) => {
if (eventTypeDisableHostEmail(eventTypeMetadata)) return;
const formattedCalEvent = formatCalEvent(calEvent);
const emailsAndSMSToSend: Promise<unknown>[] = [];
@@ -156,7 +162,7 @@ export const sendRoundRobinScheduledEmailsAndSMS = async (
for (const teamMember of members) {
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember }))
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember, reassigned }))
);
if (teamMember.phoneNumber) {
emailsAndSMSToSend.push(eventScheduledSMS.sendSMSToAttendee(teamMember));
@@ -199,7 +205,6 @@ export const sendRoundRobinCancelledEmailsAndSMS = async (
const calendarEvent = formatCalEvent(calEvent);
const emailsAndSMSToSend: Promise<unknown>[] = [];
const successfullyReScheduledSMS = new EventCancelledSMS(calEvent);
for (const teamMember of members) {
if (!reassignedTo) {
emailsAndSMSToSend.push(
@@ -207,7 +212,10 @@ export const sendRoundRobinCancelledEmailsAndSMS = async (
);
} else {
emailsAndSMSToSend.push(
sendEmail(() => new OrganizerReassignedEmail({ calEvent: calendarEvent, teamMember, reassignedTo }))
sendEmail(
() =>
new OrganizerReassignedEmail({ calEvent: calendarEvent, teamMember, reassigned: reassignedTo })
)
);
}
@@ -27,7 +27,7 @@ export const BaseScheduledEmail = (
locale: string;
timeFormat: TimeFormat | undefined;
isOrganizer?: boolean;
reassignedTo?: { name: string | null; email: string };
reassigned?: { name: string | null; email: string; reason?: string; byUser?: string };
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { t, timeZone, locale, timeFormat: timeFormat_ } = props;
@@ -81,14 +81,24 @@ export const BaseScheduledEmail = (
withSpacer
/>
)}
{props.reassignedTo && (
<Info
label={t("reassigned_to")}
description={
<PersonInfo name={props.reassignedTo.name || undefined} email={props.reassignedTo.email} />
}
withSpacer
/>
{props.reassigned && !props.reassigned.byUser && (
<>
<Info
label={t("reassigned_to")}
description={
<PersonInfo name={props.reassigned.name || undefined} email={props.reassigned.email} />
}
withSpacer
/>
</>
)}
{props.reassigned && props.reassigned.byUser && (
<>
<Info label={t("reassigned_by")} description={props.reassigned.byUser} withSpacer />
{props.reassigned?.reason && (
<Info label={t("reason")} description={props.reassigned.reason} withSpacer />
)}
</>
)}
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo timeFormat={timeFormat} calEvent={props.calEvent} t={t} timeZone={timeZone} locale={locale} />
@@ -9,7 +9,7 @@ export const OrganizerReassignedEmail = (props: React.ComponentProps<typeof Orga
subject="event_reassigned_subject"
subtitle={<>{t("event_reassigned_subtitle")}</>}
callToAction={null}
reassignedTo={props.reassignedTo}
reassigned={props.reassigned}
{...props}
/>
);
@@ -10,7 +10,7 @@ export const OrganizerScheduledEmail = (
newSeat?: boolean;
attendeeCancelled?: boolean;
teamMember?: Person;
reassignedTo?: { email: string; name: string | null };
reassigned?: { name: string | null; email: string; reason?: string; byUser?: string };
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
let subject;
@@ -60,7 +60,7 @@ export const OrganizerScheduledEmail = (
</>
)
}
reassignedTo={props.reassignedTo}
reassigned={props.reassigned}
{...props}
attendee={attendee}
/>
@@ -23,7 +23,7 @@ export default class OrganizerReassignedEmail extends OrganizerScheduledEmail {
html: await renderEmail("OrganizerReassignedEmail", {
attendee: this.calEvent.organizer,
calEvent: this.calEvent,
reassignedTo: this.reassignedTo,
reassigned: this.reassigned,
}),
text: this.getTextBody("event_request_reassigned"),
};
@@ -17,13 +17,13 @@ export default class OrganizerScheduledEmail extends BaseEmail {
t: TFunction;
newSeat?: boolean;
teamMember?: Person;
reassignedTo?: { name: string | null; email: string };
reassigned?: { name: string | null; email: string; reason?: string; byUser?: string };
constructor(input: {
calEvent: CalendarEvent;
newSeat?: boolean;
teamMember?: Person;
reassignedTo?: { email: string; name: string | null };
reassigned?: { name: string | null; email: string; reason?: string; byUser?: string };
}) {
super();
this.name = "SEND_BOOKING_CONFIRMATION";
@@ -31,7 +31,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
this.t = this.calEvent.organizer.language.translate;
this.newSeat = input.newSeat;
this.teamMember = input.teamMember;
this.reassignedTo = input.reassignedTo;
this.reassigned = input.reassigned;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
@@ -53,6 +53,7 @@ export default class OrganizerScheduledEmail extends BaseEmail {
attendee: this.calEvent.organizer,
teamMember: this.teamMember,
newSeat: this.newSeat,
reassigned: this.reassigned,
}),
text: this.getTextBody(),
};
@@ -1231,7 +1231,11 @@ async function handler(
rescheduledMembers,
eventType.metadata
);
sendRoundRobinScheduledEmailsAndSMS(copyEventAdditionalInfo, newBookedMembers, eventType.metadata);
sendRoundRobinScheduledEmailsAndSMS({
calEvent: copyEventAdditionalInfo,
members: newBookedMembers,
eventTypeMetadata: eventType.metadata,
});
sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata);
} else {
// send normal rescheduled emails (non round robin event, where organizers stay the same)
@@ -42,10 +42,12 @@ export const roundRobinManualReassignment = async ({
bookingId,
newUserId,
orgId,
reassignReason,
}: {
bookingId: number;
newUserId: number;
orgId: number | null;
reassignReason?: string;
}) => {
const roundRobinReassignLogger = logger.getSubLogger({
prefix: ["roundRobinManualReassign", `${bookingId}`],
@@ -162,6 +164,7 @@ export const roundRobinManualReassignment = async ({
userId: newUserId,
title: newBookingTitle,
userPrimaryEmail: newUser.email,
reassignReason,
},
select: bookingSelect,
});
@@ -277,15 +280,24 @@ export const roundRobinManualReassignment = async ({
const { cancellationReason, ...evtWithoutCancellationReason } = evt;
// Send emails
await sendRoundRobinScheduledEmailsAndSMS(evtWithoutCancellationReason, [
{
...newUser,
name: newUser.name || "",
username: newUser.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
language: { translate: newUserT, locale: newUser.locale || "en" },
await sendRoundRobinScheduledEmailsAndSMS({
calEvent: evtWithoutCancellationReason,
members: [
{
...newUser,
name: newUser.name || "",
username: newUser.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
language: { translate: newUserT, locale: newUser.locale || "en" },
},
],
reassigned: {
name: newUser.name,
email: newUser.email,
reason: reassignReason,
byUser: originalOrganizer.name || undefined,
},
]);
});
// Send cancellation email to previous RR host
const cancelledEvt = cloneDeep(evt);
@@ -324,15 +324,18 @@ export const roundRobinReassignment = async ({
});
// Send to new RR host
await sendRoundRobinScheduledEmailsAndSMS(evt, [
{
...reassignedRRHost,
name: reassignedRRHost.name || "",
username: reassignedRRHost.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(reassignedRRHost.timeFormat),
language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale || "en" },
},
]);
await sendRoundRobinScheduledEmailsAndSMS({
calEvent: evt,
members: [
{
...reassignedRRHost,
name: reassignedRRHost.name || "",
username: reassignedRRHost.username || "",
timeFormat: getTimeFormatStringFromUserTimeFormat(reassignedRRHost.timeFormat),
language: { translate: reassignedRRHostT, locale: reassignedRRHost.locale || "en" },
},
],
});
if (previousRRHost) {
// Send to cancelled RR host
+1
View File
@@ -56,6 +56,7 @@ export const buildBooking = (
cancelledBy: null,
rescheduledBy: null,
cancellationReason: null,
reassignReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "reassignReason" TEXT;
+1
View File
@@ -607,6 +607,7 @@ model Booking {
destinationCalendarId Int?
cancellationReason String?
rejectionReason String?
reassignReason String?
dynamicEventSlugRef String?
dynamicGroupSlugRef String?
rescheduled Boolean?
@@ -5,7 +5,6 @@ import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBo
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TGetRoundRobinHostsToReassignInputSchema } from "./getRoundRobinHostsToReasign.schema";
@@ -18,16 +17,8 @@ type GetRoundRobinHostsToReassignOptions = {
input: TGetRoundRobinHostsToReassignInputSchema;
};
async function isTeamAdmin(prisma: PrismaClient, userId: number, teamId: number) {
const membership = await prisma.membership.findFirst({
where: { userId, teamId, role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] } },
});
return membership?.role ?? false;
}
export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobinHostsToReassignOptions) => {
const { prisma } = ctx;
const { isOrgAdmin } = ctx.user.organization;
const gettingRoundRobinHostsToReassignLogger = logger.getSubLogger({
prefix: ["gettingRoundRobinHostsToReassign", `${input.bookingId}`],
@@ -100,18 +91,14 @@ export const getRoundRobinHostsToReassign = async ({ ctx, input }: GetRoundRobin
const availableUserIds = new Set(availableUsers.map((u) => u.id));
const canViewReassignBusyUsers = isOrgAdmin || (await isTeamAdmin(prisma, ctx.user.id, eventType.teamId));
const roundRobinHostsToReassign = availableEventTypeUsers.reduce((acc, host) => {
const status = availableUserIds.has(host.id) ? "available" : "unavailable";
if (canViewReassignBusyUsers || status === "available") {
acc.push({
id: host.id,
name: host.name,
email: host.email,
status,
});
}
acc.push({
id: host.id,
name: host.name,
email: host.email,
status,
});
return acc;
}, [] as { id: number; name: string | null; email: string; status: string }[]);
@@ -14,7 +14,7 @@ type RoundRobinManualReassignOptions = {
};
export const roundRobinManualReassignHandler = async ({ ctx, input }: RoundRobinManualReassignOptions) => {
const { bookingId, teamMemberId } = input;
const { bookingId, teamMemberId, reassignReason } = input;
// Check if user has access to change booking
const isAllowed = await BookingRepository.doesUserIdHaveAccessToBooking({ userId: ctx.user.id, bookingId });
@@ -23,7 +23,12 @@ export const roundRobinManualReassignHandler = async ({ ctx, input }: RoundRobin
throw new TRPCError({ code: "FORBIDDEN", message: "You do not have permission" });
}
await roundRobinManualReassignment({ bookingId, newUserId: teamMemberId, orgId: ctx.user.organizationId });
await roundRobinManualReassignment({
bookingId,
newUserId: teamMemberId,
orgId: ctx.user.organizationId,
reassignReason,
});
return { success: true };
};
@@ -3,6 +3,7 @@ import { z } from "zod";
export const ZRoundRobinManualReassignInputSchema = z.object({
bookingId: z.number(),
teamMemberId: z.number(),
reassignReason: z.string().optional(),
});
export type TRoundRobinManualReassignInputSchema = z.infer<typeof ZRoundRobinManualReassignInputSchema>;