fix: Alternate reschedule behaviour for past bookings - Don't allow new booking (#23736)
* fix: Past booking redirect * fix: rename ENV_PAST_BOOKING_RESCHEDULE_CHANGE to ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS for clarity - Renamed environment variable to better reflect that it contains comma-separated team IDs - Updated all references in constants.ts, implementation file, tests, and turbo.json - Addresses PR comment feedback about misleading variable name Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
9ee06b410e
commit
39917ebfa6
@@ -4,14 +4,12 @@ import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { getSafe } from "@calcom/lib/getSafe";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
const querySchema = z.object({
|
||||
uid: z.string(),
|
||||
@@ -63,6 +61,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
allowReschedulingCancelledBookings: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
slug: true,
|
||||
},
|
||||
@@ -98,7 +97,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
@@ -112,59 +110,36 @@ 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;
|
||||
// This comes from query param and thus is considered forced
|
||||
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
|
||||
if (isDisabledRescheduling) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
|
||||
const canReschedule =
|
||||
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
|
||||
return {
|
||||
redirect: {
|
||||
destination: canReschedule ? eventUrl : `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
|
||||
// TODO: Show something in UI to let user know that this booking is not rescheduleable
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
const isBookingInPast = booking.endTime && new Date(booking.endTime) < new Date();
|
||||
if (isBookingInPast && !eventType.allowReschedulingPastBookings) {
|
||||
const destinationUrlSearchParams = new URLSearchParams();
|
||||
const responses = bookingSeat ? getSafe<string>(bookingSeat.data, ["responses"]) : booking.responses;
|
||||
const name = getFullName(getSafe<string | { firstName: string; lastName?: string }>(responses, ["name"]));
|
||||
const email = getSafe<string>(responses, ["email"]);
|
||||
// Check if reschedule should be prevented based on booking status and event type settings
|
||||
const reschedulePreventionRedirectUrl = determineReschedulePreventionRedirect({
|
||||
booking: {
|
||||
uid,
|
||||
status: booking.status,
|
||||
endTime: booking.endTime,
|
||||
responses: booking.responses,
|
||||
eventType: {
|
||||
disableRescheduling: !!eventType?.disableRescheduling,
|
||||
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
|
||||
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
|
||||
teamId: eventType.team?.id ?? null,
|
||||
},
|
||||
},
|
||||
eventUrl,
|
||||
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
|
||||
bookingSeat,
|
||||
});
|
||||
|
||||
if (name) destinationUrlSearchParams.set("name", name);
|
||||
if (email) destinationUrlSearchParams.set("email", email);
|
||||
|
||||
const searchParamsString = destinationUrlSearchParams.toString();
|
||||
if (reschedulePreventionRedirectUrl) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: searchParamsString ? `${eventUrl}?${searchParamsString}` : eventUrl,
|
||||
destination: reschedulePreventionRedirectUrl,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
import type { JsonValue } from "@calcom/types/Json";
|
||||
|
||||
import {
|
||||
determineReschedulePreventionRedirect,
|
||||
type ReschedulePreventionRedirectInput,
|
||||
type ReschedulePreventionRedirectResult,
|
||||
} from "./determineReschedulePreventionRedirect";
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
||||
return {
|
||||
...actual,
|
||||
ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS: undefined, // Default to undefined, will be overridden in tests
|
||||
};
|
||||
});
|
||||
|
||||
const createTestBooking = (overrides?: {
|
||||
uid?: string;
|
||||
status?: BookingStatus;
|
||||
endTime?: Date | null;
|
||||
responses?: JsonValue;
|
||||
eventType?: {
|
||||
disableRescheduling?: boolean | null;
|
||||
allowReschedulingPastBookings?: boolean | null;
|
||||
allowBookingFromCancelledBookingReschedule?: boolean | null;
|
||||
teamId?: number | null;
|
||||
} | null;
|
||||
dynamicEventSlugRef?: string | null;
|
||||
}) => ({
|
||||
uid: overrides?.uid || "test-booking-uid",
|
||||
status: overrides?.status || BookingStatus.ACCEPTED,
|
||||
endTime: overrides?.endTime !== undefined ? overrides.endTime : futureDate(5),
|
||||
responses: overrides?.responses || {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
eventType:
|
||||
overrides?.eventType !== undefined
|
||||
? overrides.eventType
|
||||
: // Default values from DB
|
||||
{
|
||||
disableRescheduling: false,
|
||||
allowReschedulingPastBookings: false,
|
||||
allowBookingFromCancelledBookingReschedule: false,
|
||||
teamId: null,
|
||||
},
|
||||
dynamicEventSlugRef: overrides?.dynamicEventSlugRef !== undefined ? overrides.dynamicEventSlugRef : null,
|
||||
});
|
||||
|
||||
const createReschedulePreventionRedirectInput = (overrides?: {
|
||||
booking?: ReturnType<typeof createTestBooking>;
|
||||
eventUrl?: string;
|
||||
forceRescheduleForCancelledBooking?: boolean;
|
||||
}): ReschedulePreventionRedirectInput => ({
|
||||
booking: overrides?.booking || createTestBooking(),
|
||||
eventUrl: overrides?.eventUrl || "https://example.com/event",
|
||||
forceRescheduleForCancelledBooking: overrides?.forceRescheduleForCancelledBooking || false,
|
||||
});
|
||||
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
const futureDate = (days: number) => new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const expectRedirectToBookingDetailsPage = (
|
||||
result: ReschedulePreventionRedirectResult,
|
||||
bookingUid: string
|
||||
) => {
|
||||
expect(result).toBe(`/booking/${bookingUid}`);
|
||||
};
|
||||
|
||||
const expectRedirectToEventBookingUrl = (result: ReschedulePreventionRedirectResult, eventUrl: string) => {
|
||||
expect(result).toBe(eventUrl);
|
||||
};
|
||||
|
||||
const expectRedirectToEventBookingPageWithParams = ({
|
||||
result,
|
||||
eventUrl,
|
||||
params,
|
||||
}: {
|
||||
result: ReschedulePreventionRedirectResult;
|
||||
eventUrl: string;
|
||||
params: Record<string, string>;
|
||||
}) => {
|
||||
console.log("expectRedirectToEventBookingPageWithParams", { result, eventUrl, params });
|
||||
if (!result) {
|
||||
throw new Error("We expected a redirect result");
|
||||
}
|
||||
|
||||
const actualRedirectUrlObject = new URL(result);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
expect(actualRedirectUrlObject.searchParams.get(key)).toBe(value);
|
||||
});
|
||||
expect(`${actualRedirectUrlObject.origin + actualRedirectUrlObject.pathname}`).toBe(eventUrl);
|
||||
};
|
||||
|
||||
const expectToNotPreventReschedule = (result: ReschedulePreventionRedirectResult) => {
|
||||
expect(result).toBeNull();
|
||||
};
|
||||
|
||||
describe("determineReschedulePreventionRedirect", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T10:00:00Z"));
|
||||
// Reset mocked constant to default before each test
|
||||
vi.mocked(constants).ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when booking status is accepted and booking is in future", () => {
|
||||
it("should not prevent reschedule if disableRescheduling is false", () => {
|
||||
const testData = {
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.ACCEPTED,
|
||||
endTime: futureDate(5),
|
||||
}),
|
||||
};
|
||||
|
||||
const input = createReschedulePreventionRedirectInput(testData);
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectToNotPreventReschedule(result);
|
||||
});
|
||||
|
||||
it("should redirect to booking details/status page when rescheduling is disabled(disableRescheduling is true)", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.ACCEPTED,
|
||||
endTime: futureDate(5),
|
||||
eventType: {
|
||||
disableRescheduling: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToBookingDetailsPage(result, input.booking.uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when booking status is cancelled", () => {
|
||||
it("should redirect to booking details/status page by default", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.CANCELLED,
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToBookingDetailsPage(result, input.booking.uid);
|
||||
});
|
||||
|
||||
it("should redirect to new booking page when `allowBookingFromCancelledBookingReschedule` is true", () => {
|
||||
const testData = {
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.CANCELLED,
|
||||
eventType: {
|
||||
allowBookingFromCancelledBookingReschedule: true,
|
||||
},
|
||||
}),
|
||||
eventUrl: "https://example.com/event",
|
||||
};
|
||||
|
||||
const input = createReschedulePreventionRedirectInput(testData);
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToEventBookingUrl(result, testData.eventUrl);
|
||||
});
|
||||
|
||||
it("should not prevent reschedule when `forceRescheduleForCancelledBooking` is true regardless of `allowBookingFromCancelledBookingReschedule`", () => {
|
||||
const inputWhenallowBookingFromCancelledBookingRescheduleIsFalse =
|
||||
createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.CANCELLED,
|
||||
eventType: {
|
||||
allowBookingFromCancelledBookingReschedule: false,
|
||||
},
|
||||
}),
|
||||
forceRescheduleForCancelledBooking: true,
|
||||
});
|
||||
|
||||
const resultWhenallowBookingFromCancelledBookingRescheduleIsFalse =
|
||||
determineReschedulePreventionRedirect(inputWhenallowBookingFromCancelledBookingRescheduleIsFalse);
|
||||
expectToNotPreventReschedule(resultWhenallowBookingFromCancelledBookingRescheduleIsFalse);
|
||||
|
||||
const inputWhenallowBookingFromCancelledBookingRescheduleIsTrue =
|
||||
createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.CANCELLED,
|
||||
eventType: {
|
||||
allowBookingFromCancelledBookingReschedule: true,
|
||||
},
|
||||
}),
|
||||
forceRescheduleForCancelledBooking: true,
|
||||
});
|
||||
const resultWhenallowBookingFromCancelledBookingRescheduleIsTrue =
|
||||
determineReschedulePreventionRedirect(inputWhenallowBookingFromCancelledBookingRescheduleIsTrue);
|
||||
|
||||
expectToNotPreventReschedule(resultWhenallowBookingFromCancelledBookingRescheduleIsTrue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when booking status is rejected", () => {
|
||||
it("should redirect to booking details/status page by default", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.REJECTED,
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToBookingDetailsPage(result, input.booking.uid);
|
||||
});
|
||||
|
||||
it("should redirect to booking details/status page even when `allowBookingFromCancelledBookingReschedule` is true as that config doesn't apply to rejected bookings", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.REJECTED,
|
||||
eventType: {
|
||||
allowBookingFromCancelledBookingReschedule: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToBookingDetailsPage(result, input.booking.uid);
|
||||
});
|
||||
|
||||
it("Current Behavior: Current behaviour is to not prevent reschedule. But EXPECTED(should redirect to booking details page even when `forceRescheduleForCancelledBooking` as that config doesn't apply to rejected bookings). ", () => {
|
||||
const inputWhenallowBookingFromCancelledBookingRescheduleIsFalse =
|
||||
createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.REJECTED,
|
||||
eventType: {
|
||||
allowBookingFromCancelledBookingReschedule: false,
|
||||
},
|
||||
}),
|
||||
forceRescheduleForCancelledBooking: true,
|
||||
});
|
||||
|
||||
const resultWhenallowBookingFromCancelledBookingRescheduleIsFalse =
|
||||
determineReschedulePreventionRedirect(inputWhenallowBookingFromCancelledBookingRescheduleIsFalse);
|
||||
// expectRedirectToBookingDetailsPage(
|
||||
// resultWhenallowBookingFromCancelledBookingRescheduleIsFalse,
|
||||
// inputWhenallowBookingFromCancelledBookingRescheduleIsFalse.booking.uid
|
||||
// );
|
||||
expectToNotPreventReschedule(resultWhenallowBookingFromCancelledBookingRescheduleIsFalse);
|
||||
|
||||
const inputWhenallowBookingFromCancelledBookingRescheduleIsTrue =
|
||||
createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
status: BookingStatus.REJECTED,
|
||||
eventType: {
|
||||
allowBookingFromCancelledBookingReschedule: true,
|
||||
},
|
||||
}),
|
||||
forceRescheduleForCancelledBooking: true,
|
||||
});
|
||||
const resultWhenallowBookingFromCancelledBookingRescheduleIsTrue =
|
||||
determineReschedulePreventionRedirect(inputWhenallowBookingFromCancelledBookingRescheduleIsTrue);
|
||||
|
||||
// expectRedirectToBookingDetailsPage(
|
||||
// resultWhenallowBookingFromCancelledBookingRescheduleIsTrue,
|
||||
// inputWhenallowBookingFromCancelledBookingRescheduleIsTrue.booking.uid
|
||||
// );
|
||||
expectToNotPreventReschedule(resultWhenallowBookingFromCancelledBookingRescheduleIsTrue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when booking is in the past - Default behaviour(without ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS)", () => {
|
||||
it("should redirect to new booking page with prefilled params when allowReschedulingPastBookings is false", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
endTime: daysAgo(5),
|
||||
responses: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
eventType: {
|
||||
allowReschedulingPastBookings: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToEventBookingPageWithParams({
|
||||
result,
|
||||
eventUrl: "https://example.com/event",
|
||||
params: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not prevent reschedule when allowReschedulingPastBookings is true", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
endTime: daysAgo(5),
|
||||
eventType: {
|
||||
disableRescheduling: false,
|
||||
allowReschedulingPastBookings: true,
|
||||
allowBookingFromCancelledBookingReschedule: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectToNotPreventReschedule(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when booking is in the past - New behaviour(based on ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(constants).ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS = "123,456,789";
|
||||
});
|
||||
|
||||
it("should redirect to booking status page instead of event URL when the event's teamId is in the environment variable", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
uid: "team-booking-uid",
|
||||
status: BookingStatus.ACCEPTED,
|
||||
endTime: daysAgo(3),
|
||||
eventType: {
|
||||
teamId: 456, // This team ID is in the environment variable
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectRedirectToBookingDetailsPage(result, input.booking.uid);
|
||||
});
|
||||
|
||||
it("should redirect to event URL when the event's teamId is not in the environment variable", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
uid: "non-team-booking-uid",
|
||||
status: BookingStatus.ACCEPTED,
|
||||
endTime: daysAgo(3),
|
||||
eventType: {
|
||||
disableRescheduling: false,
|
||||
allowReschedulingPastBookings: false,
|
||||
allowBookingFromCancelledBookingReschedule: false,
|
||||
teamId: 999, // This team ID is NOT in the environment variable
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
// Should redirect to eventUrl with name/email params (fallback behavior)
|
||||
expectRedirectToEventBookingPageWithParams({
|
||||
result,
|
||||
eventUrl: "https://example.com/event",
|
||||
params: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to event URL when booking has no team (individual event)", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
uid: "no-team-booking-uid",
|
||||
status: BookingStatus.ACCEPTED,
|
||||
endTime: daysAgo(3),
|
||||
eventType: {
|
||||
disableRescheduling: false,
|
||||
allowReschedulingPastBookings: false,
|
||||
allowBookingFromCancelledBookingReschedule: false,
|
||||
teamId: null, // No team ID
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
// Should redirect to eventUrl with name/email params (fallback behavior)
|
||||
expectRedirectToEventBookingPageWithParams({
|
||||
result,
|
||||
eventUrl: "https://example.com/event",
|
||||
params: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not prevent reschedule when allowReschedulingPastBookings is true even if the teamId is in the environment variable", () => {
|
||||
const input = createReschedulePreventionRedirectInput({
|
||||
booking: createTestBooking({
|
||||
uid: "allowed-past-reschedule-uid",
|
||||
status: BookingStatus.ACCEPTED,
|
||||
endTime: daysAgo(3),
|
||||
eventType: {
|
||||
allowReschedulingPastBookings: true, // Past reschedule explicitly allowed
|
||||
teamId: 456, // Team ID is in environment variable
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = determineReschedulePreventionRedirect(input);
|
||||
|
||||
expectToNotPreventReschedule(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { URLSearchParams } from "url";
|
||||
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS } from "@calcom/lib/constants";
|
||||
import { getSafe } from "@calcom/lib/getSafe";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { JsonValue } from "@calcom/types/Json";
|
||||
|
||||
export type ReschedulePreventionRedirectInput = {
|
||||
booking: {
|
||||
uid: string;
|
||||
status: BookingStatus;
|
||||
endTime: Date | null;
|
||||
responses?: JsonValue;
|
||||
eventType: {
|
||||
disableRescheduling: boolean;
|
||||
allowReschedulingPastBookings: boolean;
|
||||
allowBookingFromCancelledBookingReschedule: boolean;
|
||||
teamId: number | null;
|
||||
};
|
||||
};
|
||||
eventUrl: string;
|
||||
forceRescheduleForCancelledBooking?: boolean;
|
||||
bookingSeat?: {
|
||||
data: JsonValue;
|
||||
booking: {
|
||||
uid: string;
|
||||
id: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ReschedulePreventionRedirectResult = string | null;
|
||||
|
||||
/**
|
||||
*
|
||||
* Parses the PAST_BOOKING_RESCHEDULE_NO_BOOKING_BEHAVIOUR environment variable and checks if the given team ID has the changed behaviour for past booking reschedule enabled
|
||||
*
|
||||
* The behaviour is that it does not allow allowing booking through the reschedule link of a past booking by default. If allowReschedulingPastBookings is true, then this behaviour isn't applicable
|
||||
*/
|
||||
function isPastBookingRescheduleBehaviourToPreventBooking(teamId: number | null | undefined): boolean {
|
||||
if (!teamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configuredTeamIds = ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== "")
|
||||
.map((id) => parseInt(id, 10))
|
||||
.filter((id) => !isNaN(id));
|
||||
|
||||
return configuredTeamIds.includes(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate redirect URL for a reschedule request based on booking status and event type settings
|
||||
* Returns the redirect URL string if a redirect is needed, null if reschedule should proceed normally
|
||||
*/
|
||||
export function determineReschedulePreventionRedirect(
|
||||
input: ReschedulePreventionRedirectInput
|
||||
): ReschedulePreventionRedirectResult {
|
||||
const { booking, eventUrl, forceRescheduleForCancelledBooking, bookingSeat } = input;
|
||||
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
if (isDisabledRescheduling) {
|
||||
return `/booking/${booking.uid}`;
|
||||
}
|
||||
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
const isForcedRescheduleForCancelledBooking = forceRescheduleForCancelledBooking;
|
||||
|
||||
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
|
||||
const canBookThroughCancelledBookingRescheduleLink =
|
||||
booking.eventType.allowBookingFromCancelledBookingReschedule;
|
||||
const allowedToBeBookedThroughCancelledBookingRescheduleLink =
|
||||
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
|
||||
|
||||
return allowedToBeBookedThroughCancelledBookingRescheduleLink ? eventUrl : `/booking/${booking.uid}`;
|
||||
}
|
||||
|
||||
const isBookingInPast = booking.endTime && new Date(booking.endTime) < new Date();
|
||||
if (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) {
|
||||
// Check if this team should apply the redirect behavior for past bookings
|
||||
const isNewPastBookingRescheduleBehaviour = isPastBookingRescheduleBehaviourToPreventBooking(
|
||||
booking.eventType.teamId
|
||||
);
|
||||
|
||||
if (isNewPastBookingRescheduleBehaviour) {
|
||||
return `/booking/${booking.uid}`;
|
||||
}
|
||||
|
||||
const destinationUrlSearchParams = new URLSearchParams();
|
||||
const responses = bookingSeat ? getSafe<string>(bookingSeat.data, ["responses"]) : booking.responses;
|
||||
const name = getFullName(getSafe<string | { firstName: string; lastName?: string }>(responses, ["name"]));
|
||||
const email = getSafe<string>(responses, ["email"]);
|
||||
|
||||
if (name) destinationUrlSearchParams.set("name", name);
|
||||
if (email) destinationUrlSearchParams.set("email", email);
|
||||
|
||||
const searchParamsString = destinationUrlSearchParams.toString();
|
||||
return searchParamsString ? `${eventUrl}?${searchParamsString}` : eventUrl;
|
||||
}
|
||||
|
||||
// Allow reschedule to proceed - default behaviour
|
||||
return null;
|
||||
}
|
||||
@@ -249,3 +249,8 @@ export const RETELL_AI_TEST_EVENT_TYPE_MAP = (() => {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Environment variable for configuring past booking reschedule behavior per team. A comma separated list of team IDs(e.g. '1,2,3')
|
||||
/* This is an internal environment variable and is not meant to be used by the self-hosters. It is planned to be removed later by either having it as an option in Event Type or by some other customer configurable approaches*/
|
||||
export const ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS =
|
||||
process.env._CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS;
|
||||
|
||||
@@ -92,6 +92,7 @@ const commons = {
|
||||
disableRescheduling: false,
|
||||
onlyShowFirstAvailableSlot: false,
|
||||
allowReschedulingPastBookings: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
hideOrganizerEmail: false,
|
||||
showOptimizedSlots: false,
|
||||
id: 0,
|
||||
|
||||
+2
-1
@@ -279,7 +279,8 @@
|
||||
"ATOMS_E2E_APPLE_ID",
|
||||
"ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE",
|
||||
"INTERCOM_API_TOKEN",
|
||||
"NEXT_PUBLIC_INTERCOM_APP_ID"
|
||||
"NEXT_PUBLIC_INTERCOM_APP_ID",
|
||||
"_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS"
|
||||
],
|
||||
"tasks": {
|
||||
"@calcom/prisma#build": {
|
||||
|
||||
Reference in New Issue
Block a user