From c5402d8bd72a7323651b1faeda3e25bb150e9423 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 25 Oct 2024 20:13:26 +0100 Subject: [PATCH] fix: give reason to reassign - allow team members to reassign even if busy (#17328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: CarinaWolli Co-authored-by: Omar López --- apps/web/components/dialog/ReassignDialog.tsx | 36 +++++++++++++++++-- apps/web/public/static/locales/en/common.json | 4 ++- packages/emails/email-manager.ts | 24 ++++++++----- .../src/templates/BaseScheduledEmail.tsx | 28 ++++++++++----- .../templates/OrganizerReassignedEmail.tsx | 2 +- .../src/templates/OrganizerScheduledEmail.tsx | 4 +-- .../templates/organizer-reassigned-email.ts | 2 +- .../templates/organizer-scheduled-email.ts | 7 ++-- .../features/bookings/lib/handleNewBooking.ts | 6 +++- .../roundRobinManualReassignment.ts | 28 ++++++++++----- .../ee/round-robin/roundRobinReassignment.ts | 21 ++++++----- packages/lib/test/builder.ts | 1 + .../migration.sql | 2 ++ packages/prisma/schema.prisma | 1 + .../getRoundRobinHostsToReasign.handler.ts | 25 ++++--------- .../roundRobinManualReassign.handler.ts | 9 +++-- .../roundRobinManualReassign.schema.ts | 1 + 17 files changed, 134 insertions(+), 67 deletions(-) create mode 100644 packages/prisma/migrations/20241025143558_add_reassign_reason_to_booking/migration.sql diff --git a/apps/web/components/dialog/ReassignDialog.tsx b/apps/web/components/dialog/ReassignDialog.tsx index acf7296eb0..eba09c7af1 100644 --- a/apps/web/components/dialog/ReassignDialog.tsx +++ b/apps/web/components/dialog/ReassignDialog.tsx @@ -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({ + 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")} +

{t("reassign_unavailable_team_member_description")}

+ form.setValue("reassignReason", e.target.value)} + required + /> diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index df788d17cf..c70dbcdf20 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 96a16f49aa..5e18e8e3d7 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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[] = []; @@ -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[] = []; 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 }) + ) ); } diff --git a/packages/emails/src/templates/BaseScheduledEmail.tsx b/packages/emails/src/templates/BaseScheduledEmail.tsx index aa3e01bebe..492317757e 100644 --- a/packages/emails/src/templates/BaseScheduledEmail.tsx +++ b/packages/emails/src/templates/BaseScheduledEmail.tsx @@ -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> ) => { const { t, timeZone, locale, timeFormat: timeFormat_ } = props; @@ -81,14 +81,24 @@ export const BaseScheduledEmail = ( withSpacer /> )} - {props.reassignedTo && ( - - } - withSpacer - /> + {props.reassigned && !props.reassigned.byUser && ( + <> + + } + withSpacer + /> + + )} + {props.reassigned && props.reassigned.byUser && ( + <> + + {props.reassigned?.reason && ( + + )} + )} diff --git a/packages/emails/src/templates/OrganizerReassignedEmail.tsx b/packages/emails/src/templates/OrganizerReassignedEmail.tsx index b46ef76a0e..07a4121abf 100644 --- a/packages/emails/src/templates/OrganizerReassignedEmail.tsx +++ b/packages/emails/src/templates/OrganizerReassignedEmail.tsx @@ -9,7 +9,7 @@ export const OrganizerReassignedEmail = (props: React.ComponentProps{t("event_reassigned_subtitle")}} callToAction={null} - reassignedTo={props.reassignedTo} + reassigned={props.reassigned} {...props} /> ); diff --git a/packages/emails/src/templates/OrganizerScheduledEmail.tsx b/packages/emails/src/templates/OrganizerScheduledEmail.tsx index 523089c7a8..9d6e5c1831 100644 --- a/packages/emails/src/templates/OrganizerScheduledEmail.tsx +++ b/packages/emails/src/templates/OrganizerScheduledEmail.tsx @@ -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> ) => { let subject; @@ -60,7 +60,7 @@ export const OrganizerScheduledEmail = ( ) } - reassignedTo={props.reassignedTo} + reassigned={props.reassigned} {...props} attendee={attendee} /> diff --git a/packages/emails/templates/organizer-reassigned-email.ts b/packages/emails/templates/organizer-reassigned-email.ts index f509fe00be..7daee7b76a 100644 --- a/packages/emails/templates/organizer-reassigned-email.ts +++ b/packages/emails/templates/organizer-reassigned-email.ts @@ -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"), }; diff --git a/packages/emails/templates/organizer-scheduled-email.ts b/packages/emails/templates/organizer-scheduled-email.ts index 3fe48e0af8..9637b00d00 100644 --- a/packages/emails/templates/organizer-scheduled-email.ts +++ b/packages/emails/templates/organizer-scheduled-email.ts @@ -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> { @@ -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(), }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f735084a9f..67d924cef5 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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) diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index f18f9970e0..fada127c42 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -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); diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index 6650de3bf5..eaf0367224 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -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 diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index c9abad3f27..b040bc001e 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -56,6 +56,7 @@ export const buildBooking = ( cancelledBy: null, rescheduledBy: null, cancellationReason: null, + reassignReason: null, rejectionReason: null, dynamicEventSlugRef: null, dynamicGroupSlugRef: null, diff --git a/packages/prisma/migrations/20241025143558_add_reassign_reason_to_booking/migration.sql b/packages/prisma/migrations/20241025143558_add_reassign_reason_to_booking/migration.sql new file mode 100644 index 0000000000..aa084e7f42 --- /dev/null +++ b/packages/prisma/migrations/20241025143558_add_reassign_reason_to_booking/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "reassignReason" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 810336f11a..b22feb3781 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -607,6 +607,7 @@ model Booking { destinationCalendarId Int? cancellationReason String? rejectionReason String? + reassignReason String? dynamicEventSlugRef String? dynamicGroupSlugRef String? rescheduled Boolean? diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts index d220c8d0a0..efdc0c0b59 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/getRoundRobinHostsToReasign.handler.ts @@ -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 }[]); diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts index 35c11f4aaa..f0d794bfe1 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.handler.ts @@ -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 }; }; diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.schema.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.schema.ts index db85d02420..c6840cd2f2 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinManualReassign.schema.ts @@ -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;