feat: add setting to allow booking through a reschedule link (#21652)
* feat: add setting to disable rescheduling cancelled bookings Co-Authored-By: [email protected] <[email protected]> * fix: resolve type errors for disableReschedulingCancelledBookings field Co-Authored-By: [email protected] <[email protected]> * fix: update test expectations and builder to include disableReschedulingCancelledBookings field Co-Authored-By: [email protected] <[email protected]> * fix: add disableReschedulingCancelledBookings field to managed event types Co-Authored-By: [email protected] <[email protected]> * fix: remove duplicate disableReschedulingCancelledBookings property in test Co-Authored-By: [email protected] <[email protected]> * fix: change default value to true for disableReschedulingCancelledBookings Co-Authored-By: [email protected] <[email protected]> * fix: update managed event types to use true as default for disableReschedulingCancelledBookings Co-Authored-By: [email protected] <[email protected]> * test: add comprehensive tests for disableReschedulingCancelledBookings feature Co-Authored-By: [email protected] <[email protected]> * update and remove unnecesarry test * update e2e test * Update reschedule.e2e.ts * update * fix * Reverse the meaning of column * Simpify logic of rescheduling redirects * fix test * revert --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: Anik Dhabal Babu <[email protected]> Co-authored-by: unknown <[email protected]> Co-authored-by: Hariom Balhara <[email protected]>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
[email protected] <[email protected]>
Anik Dhabal Babu
unknown
Hariom Balhara
parent
55a80ba253
commit
11bddbb71d
@@ -60,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
slug: true,
|
||||
allowReschedulingPastBookings: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
@@ -110,18 +111,31 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
profileEnrichedBookingUser: enrichedBookingUser,
|
||||
});
|
||||
|
||||
const isForcedRescheduleForCancelledBooking = allowRescheduleForCancelledBooking;
|
||||
// If booking is already REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details.
|
||||
// If the booking is CANCELLED and allowRescheduleForCancelledBooking is false, we redirect the user to the original event link.
|
||||
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
|
||||
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
|
||||
if (
|
||||
isDisabledRescheduling ||
|
||||
(!allowRescheduleForCancelledBooking &&
|
||||
(booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED))
|
||||
) {
|
||||
// This comes from query param and thus is considered forced
|
||||
const canRescheduleCancelledBooking =
|
||||
isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
|
||||
if (isDisabledRescheduling) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: booking.status === BookingStatus.CANCELLED ? eventUrl : `/booking/${uid}`,
|
||||
destination: `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonRescheduleableBooking) {
|
||||
const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking;
|
||||
return {
|
||||
redirect: {
|
||||
destination: canReschedule ? eventUrl : `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +71,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (booking?.status === BookingStatus.CANCELLED && !allowRescheduleForCancelledBooking) {
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
@@ -212,6 +216,7 @@ const getTeamWithEventsData = async (
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
|
||||
@@ -252,8 +252,8 @@ test.describe("pro user", () => {
|
||||
|
||||
await page.goto(`/reschedule/${bookingCancelledId}`);
|
||||
|
||||
// Should be redirected to the original event link
|
||||
await expect(page).toHaveURL(new RegExp(`/${pro.username}/${eventSlug}`));
|
||||
expect(page.url()).not.toContain("rescheduleUid");
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
});
|
||||
|
||||
test("can book an event that requires confirmation and then that booking can be accepted by organizer", async ({
|
||||
|
||||
@@ -389,6 +389,62 @@ test.describe("Reschedule Tests", async () => {
|
||||
// It is tested in teams.e2e.ts
|
||||
});
|
||||
|
||||
test("Should redirect to cancelled page when allowReschedulingCancelledBookings is false (default)", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes[0];
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.CANCELLED,
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
expect(page.url()).not.toContain("rescheduleUid");
|
||||
await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should allow rescheduling when allowReschedulingCancelledBookings is true", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
const eventType = user.eventTypes[0];
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
allowReschedulingCancelledBookings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.CANCELLED,
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Booking should be rescheduleable for a user that was moved to an organization through org domain", async ({
|
||||
users,
|
||||
|
||||
@@ -1171,6 +1171,8 @@
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"disable_rescheduling": "Disable Rescheduling",
|
||||
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email",
|
||||
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
|
||||
"description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking",
|
||||
"disable_cancelling": "Disable Cancelling",
|
||||
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email",
|
||||
"revoke_api_key": "Revoke API key",
|
||||
|
||||
@@ -60,7 +60,9 @@ async function processReschedule({
|
||||
booking === null ||
|
||||
!booking.eventTypeId ||
|
||||
(booking?.eventTypeId === props.eventData?.id &&
|
||||
(booking.status !== BookingStatus.CANCELLED || allowRescheduleForCancelledBooking))
|
||||
(booking.status !== BookingStatus.CANCELLED ||
|
||||
allowRescheduleForCancelledBooking ||
|
||||
!!(props.eventData as any)?.allowReschedulingCancelledBookings))
|
||||
) {
|
||||
props.booking = booking;
|
||||
props.rescheduleUid = Array.isArray(rescheduleUid) ? rescheduleUid[0] : rescheduleUid;
|
||||
|
||||
@@ -147,6 +147,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
userId: 4,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
@@ -206,6 +207,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
deleteMany: {},
|
||||
},
|
||||
instantMeetingScheduleId: undefined,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
where: {
|
||||
userId_parentId: {
|
||||
@@ -315,6 +317,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
workflows: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
@@ -371,6 +374,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
where: {
|
||||
userId_parentId: {
|
||||
@@ -476,6 +480,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
const { profileId, rrSegmentQueryValue, ...rest } = evType;
|
||||
|
||||
@@ -187,6 +187,7 @@ export default async function handleChildrenEventTypes({
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false,
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -259,6 +260,7 @@ export default async function handleChildrenEventTypes({
|
||||
: {
|
||||
deleteMany: {},
|
||||
},
|
||||
allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false,
|
||||
metadata: {
|
||||
...(eventType.metadata as Prisma.JsonObject),
|
||||
...(metadata?.multipleDuration && "length" in unlockedFieldProps
|
||||
|
||||
@@ -497,6 +497,9 @@ export const EventAdvancedTab = ({
|
||||
|
||||
const disableCancellingLocked = shouldLockDisableProps("disableCancelling");
|
||||
const disableReschedulingLocked = shouldLockDisableProps("disableRescheduling");
|
||||
const allowReschedulingCancelledBookingsLocked = shouldLockDisableProps(
|
||||
"allowReschedulingCancelledBookings"
|
||||
);
|
||||
|
||||
const { isLocked, ...eventNameLocked } = shouldLockDisableProps("eventName");
|
||||
|
||||
@@ -508,6 +511,10 @@ export const EventAdvancedTab = ({
|
||||
|
||||
const [disableRescheduling, setDisableRescheduling] = useState(eventType.disableRescheduling || false);
|
||||
|
||||
const [allowReschedulingCancelledBookings, setallowReschedulingCancelledBookings] = useState(
|
||||
eventType.allowReschedulingCancelledBookings ?? false
|
||||
);
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
|
||||
const [isEventTypeColorChecked, setIsEventTypeColorChecked] = useState(!!eventType.eventTypeColor);
|
||||
@@ -1028,6 +1035,26 @@ export const EventAdvancedTab = ({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="allowReschedulingCancelledBookings"
|
||||
render={({ field: { onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("allow_rescheduling_cancelled_bookings")}
|
||||
data-testid="allow-rescheduling-cancelled-bookings-toggle"
|
||||
{...allowReschedulingCancelledBookingsLocked}
|
||||
description={t("description_allow_rescheduling_cancelled_bookings")}
|
||||
checked={allowReschedulingCancelledBookings}
|
||||
onCheckedChange={(val) => {
|
||||
setallowReschedulingCancelledBookings(val);
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!isPlatform && (
|
||||
<>
|
||||
<Controller
|
||||
|
||||
@@ -84,6 +84,7 @@ const getPublicEventSelect = (fetchAllUsers: boolean) => {
|
||||
seatsPerTimeSlot: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
bookingFields: true,
|
||||
teamId: true,
|
||||
@@ -528,6 +529,7 @@ export const getPublicEvent = async (
|
||||
assignAllTeamMembers: event.assignAllTeamMembers,
|
||||
disableCancelling: event.disableCancelling,
|
||||
disableRescheduling: event.disableRescheduling,
|
||||
allowReschedulingCancelledBookings: event.allowReschedulingCancelledBookings,
|
||||
interfaceLanguage: event.interfaceLanguage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
disableGuests: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
hideCalendarNotes: true,
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
|
||||
@@ -517,6 +517,7 @@ export class EventTypeRepository {
|
||||
disableGuests: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
|
||||
@@ -126,6 +126,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
seatsShowAttendees: null,
|
||||
disableCancelling: false,
|
||||
disableRescheduling: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
seatsShowAvailabilityCount: null,
|
||||
maxLeadThreshold: null,
|
||||
includeNoShowInRRCalculation: false,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "allowReschedulingCancelledBookings" BOOLEAN DEFAULT false;
|
||||
@@ -152,6 +152,7 @@ model EventType {
|
||||
schedulingType SchedulingType?
|
||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
scheduleId Int?
|
||||
allowReschedulingCancelledBookings Boolean? @default(false)
|
||||
// price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column.
|
||||
price Int @default(0)
|
||||
// currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column.
|
||||
|
||||
@@ -629,6 +629,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
||||
disableGuests: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
requiresConfirmation: true,
|
||||
canSendCalVideoTranscriptionEmails: true,
|
||||
requiresConfirmationForFreeEmail: true,
|
||||
|
||||
Reference in New Issue
Block a user