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:
co-authored by
CarinaWolli
Omar López
parent
b53abe93bc
commit
c5402d8bd7
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -607,6 +607,7 @@ model Booking {
|
||||
destinationCalendarId Int?
|
||||
cancellationReason String?
|
||||
rejectionReason String?
|
||||
reassignReason String?
|
||||
dynamicEventSlugRef String?
|
||||
dynamicGroupSlugRef String?
|
||||
rescheduled Boolean?
|
||||
|
||||
+6
-19
@@ -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 }[]);
|
||||
|
||||
|
||||
+7
-2
@@ -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 };
|
||||
};
|
||||
|
||||
+1
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user