fix: seated event type public id cancels/reschedule entire booking without auth (#11667)

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
pull/11740/head
alannnc 2023-10-06 09:17:15 -07:00 committed by GitHub
parent a90848edcb
commit b4315b186a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 376 additions and 55 deletions

View File

@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod"; import { z } from "zod";
import { Booker } from "@calcom/atoms"; import { Booker } from "@calcom/atoms";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { import {
@ -43,6 +44,7 @@ export default function Type({
hideBranding={isBrandingHidden} hideBranding={isBrandingHidden}
isSEOIndexable={isSEOIndexable ?? true} isSEOIndexable={isSEOIndexable ?? true}
entity={entity} entity={entity}
bookingData={booking}
/> />
<Booker <Booker
username={user} username={user}
@ -61,6 +63,7 @@ Type.isBookingPage = true;
Type.PageWrapper = PageWrapper; Type.PageWrapper = PageWrapper;
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params); const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query; const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
@ -96,7 +99,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
if (rescheduleUid) { if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`); booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} else if (bookingUid) { } else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`); booking = await getBookingForSeatedEvent(`${bookingUid}`);
} }
@ -138,6 +141,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
} }
async function getUserPageProps(context: GetServerSidePropsContext) { async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params); const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0]; const username = usernames[0];
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query; const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
@ -168,7 +172,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
if (rescheduleUid) { if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`); booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} else if (bookingUid) { } else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`); booking = await getBookingForSeatedEvent(`${bookingUid}`);
} }

View File

@ -146,7 +146,7 @@ export default function Success(props: SuccessProps) {
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined); const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
const { requiresLoginToUpdate } = props;
function setIsCancellationMode(value: boolean) { function setIsCancellationMode(value: boolean) {
const _searchParams = new URLSearchParams(searchParams); const _searchParams = new URLSearchParams(searchParams);
@ -532,7 +532,28 @@ export default function Success(props: SuccessProps) {
})} })}
</div> </div>
</div> </div>
{(!needsConfirmation || !userIsOwner) && {requiresLoginToUpdate && (
<>
<hr className="border-subtle mb-8" />
<div className="text-center">
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
{/* Login button but redirect to here */}
<span className="text-default inline">
<span className="underline" data-testid="reschedule-link">
<Link
href={`/auth/login?callbackUrl=${encodeURIComponent(
`/booking/${bookingInfo?.uid}`
)}`}
legacyBehavior>
{t("login")}
</Link>
</span>
</span>
</div>
</>
)}
{!requiresLoginToUpdate &&
(!needsConfirmation || !userIsOwner) &&
!isCancelled && !isCancelled &&
(!isCancellationMode ? ( (!isCancellationMode ? (
<> <>
@ -540,28 +561,30 @@ export default function Success(props: SuccessProps) {
<div className="text-center last:pb-0"> <div className="text-center last:pb-0">
<span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span> <span className="text-emphasis ltr:mr-2 rtl:ml-2">{t("need_to_make_a_change")}</span>
{!props.recurringBookings && ( <>
<span className="text-default inline"> {!props.recurringBookings && (
<span className="underline" data-testid="reschedule-link"> <span className="text-default inline">
<Link <span className="underline" data-testid="reschedule-link">
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`} <Link
legacyBehavior> href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
{t("reschedule")} legacyBehavior>
</Link> {t("reschedule")}
</Link>
</span>
<span className="mx-2">{t("or_lowercase")}</span>
</span> </span>
<span className="mx-2">{t("or_lowercase")}</span>
</span>
)}
<button
data-testid="cancel"
className={classNames(
"text-default underline",
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
)} )}
onClick={() => setIsCancellationMode(true)}>
{t("cancel")} <button
</button> data-testid="cancel"
className={classNames(
"text-default underline",
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
)}
onClick={() => setIsCancellationMode(true)}>
{t("cancel")}
</button>
</>
</div> </div>
</> </>
) : ( ) : (
@ -1010,7 +1033,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context); const session = await getServerSession(context);
let tz: string | null = null; let tz: string | null = null;
let userTimeFormat: number | null = null; let userTimeFormat: number | null = null;
let requiresLoginToUpdate = false;
if (session) { if (session) {
const user = await ssr.viewer.me.fetch(); const user = await ssr.viewer.me.fetch();
tz = user.timeZone; tz = user.timeZone;
@ -1022,9 +1045,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!parsedQuery.success) return { notFound: true }; if (!parsedQuery.success) return { notFound: true };
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
const bookingInfoRaw = await prisma.booking.findFirst({ const bookingInfoRaw = await prisma.booking.findFirst({
where: { where: {
uid: await maybeGetBookingUidFromSeat(prisma, uid), uid: maybeUid,
}, },
select: { select: {
title: true, title: true,
@ -1088,6 +1112,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}; };
} }
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
requiresLoginToUpdate = true;
}
const bookingInfo = getBookingWithResponses(bookingInfoRaw); const bookingInfo = getBookingWithResponses(bookingInfoRaw);
// @NOTE: had to do this because Server side cant return [Object objects] // @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse // probably fixable with json.stringify -> json.parse
@ -1156,6 +1184,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
paymentStatus: payment, paymentStatus: payment,
...(tz && { tz }), ...(tz && { tz }),
userTimeFormat, userTimeFormat,
requiresLoginToUpdate,
}, },
}; };
} }

View File

@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod"; import { z } from "zod";
import { Booker } from "@calcom/atoms"; import { Booker } from "@calcom/atoms";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
@ -57,6 +58,7 @@ Type.PageWrapper = PageWrapper;
Type.isBookingPage = true; Type.isBookingPage = true;
async function getUserPageProps(context: GetServerSidePropsContext) { async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { link, slug } = paramsSchema.parse(context.params); const { link, slug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration } = context.query; const { rescheduleUid, duration: queryDuration } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
@ -119,7 +121,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
if (rescheduleUid) { if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`); booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} }
const isTeamEvent = !!hashedLink.eventType?.team?.id; const isTeamEvent = !!hashedLink.eventType?.team?.id;

View File

@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { URLSearchParams } from "url"; import { URLSearchParams } from "url";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import prisma, { bookingMinimalSelect } from "@calcom/prisma";
@ -12,11 +13,16 @@ export default function Type() {
} }
export async function getServerSideProps(context: GetServerSidePropsContext) { export async function getServerSideProps(context: GetServerSidePropsContext) {
const { uid: bookingId, seatReferenceUid } = z const session = await getServerSession(context);
const { uid: bookingUid, seatReferenceUid } = z
.object({ uid: z.string(), seatReferenceUid: z.string().optional() }) .object({ uid: z.string(), seatReferenceUid: z.string().optional() })
.parse(context.query); .parse(context.query);
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId); const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
prisma,
bookingUid
);
const booking = await prisma.booking.findUnique({ const booking = await prisma.booking.findUnique({
where: { where: {
uid, uid,
@ -37,6 +43,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}, },
}, },
seatsPerTimeSlot: true, seatsPerTimeSlot: true,
userId: true,
owner: {
select: {
id: true,
},
},
hosts: {
select: {
user: {
select: {
id: true,
},
},
},
},
}, },
}, },
dynamicEventSlugRef: true, dynamicEventSlugRef: true,
@ -53,7 +74,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
} }
if (!booking?.eventType && !booking?.dynamicEventSlugRef) { if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
// TODO: Show something in UI to let user know that this booking is not rescheduleable. // TODO: Show something in UI to let user know that this booking is not rescheduleable
return { return {
notFound: true, notFound: true,
} as { } as {
@ -61,6 +82,33 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}; };
} }
// if booking event type is for a seated event and no seat reference uid is provided, throw not found
if (booking?.eventType?.seatsPerTimeSlot && !maybeSeatReferenceUid) {
const userId = session?.user?.id;
if (!userId && !seatReferenceUid) {
return {
redirect: {
destination: `/auth/login?callbackUrl=/reschedule/${bookingUid}`,
permanent: false,
},
};
}
const userIsHost = booking?.eventType.hosts.find((host) => {
if (host.user.id === userId) return true;
});
const userIsOwnerOfEventType = booking?.eventType.owner?.id === userId;
if (!userIsHost && !userIsOwnerOfEventType) {
return {
notFound: true,
} as {
notFound: true;
};
}
}
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef); const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const eventPage = `${ const eventPage = `${
@ -72,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}/${eventType?.slug}`; }/${eventType?.slug}`;
const destinationUrl = new URLSearchParams(); const destinationUrl = new URLSearchParams();
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId); destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid);
return { return {
redirect: { redirect: {

View File

@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod"; import { z } from "zod";
import { Booker } from "@calcom/atoms"; import { Booker } from "@calcom/atoms";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
@ -37,6 +38,7 @@ export default function Type({
hideBranding={isBrandingHidden} hideBranding={isBrandingHidden}
isTeamEvent isTeamEvent
entity={entity} entity={entity}
bookingData={booking}
/> />
<Booker <Booker
username={user} username={user}
@ -64,6 +66,7 @@ const paramsSchema = z.object({
// 1. Check if team exists, to show 404 // 1. Check if team exists, to show 404
// 2. If rescheduling, get the booking details // 2. If rescheduling, get the booking details
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getServerSession(context);
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params); const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration } = context.query; const { rescheduleUid, duration: queryDuration } = context.query;
const { ssrInit } = await import("@server/lib/ssr"); const { ssrInit } = await import("@server/lib/ssr");
@ -92,7 +95,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
let booking: GetBookingType | null = null; let booking: GetBookingType | null = null;
if (rescheduleUid) { if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`); booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} }
const org = isValidOrgDomain ? currentOrgDomain : null; const org = isValidOrgDomain ? currentOrgDomain : null;

View File

@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
import { uuid } from "short-uuid"; import { uuid } from "short-uuid";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
@ -96,7 +97,7 @@ test.describe("Booking with Seats", () => {
}); });
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => { test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [ const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" }, { name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" }, { name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" }, { name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
@ -143,6 +144,19 @@ test.describe("Booking with Seats", () => {
expect(attendeeIds).not.toContain(bookingAttendees[0].id); expect(attendeeIds).not.toContain(bookingAttendees[0].id);
}); });
await test.step("Attendee #2 shouldn't be able to cancel booking using only booking/uid", async () => {
await page.goto(`/booking/${booking.uid}`);
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
});
await test.step("Attendee #2 shouldn't be able to cancel booking using randomString for seatReferenceUId", async () => {
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${randomString(10)}`);
// expect cancel button to don't be in the page
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
});
await test.step("All attendees cancelling should delete the booking for the user", async () => { await test.step("All attendees cancelling should delete the booking for the user", async () => {
// The remaining 2 attendees cancel // The remaining 2 attendees cancel
for (let i = 1; i < bookingSeats.length; i++) { for (let i = 1; i < bookingSeats.length; i++) {
@ -166,6 +180,47 @@ test.describe("Booking with Seats", () => {
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED); expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
}); });
}); });
test("Owner shouldn't be able to cancel booking without login in", async ({ page, bookings, users }) => {
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await page.goto(`/booking/${booking.uid}?cancel=true`);
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
// expect login text to be in the page, not data-testid
await expect(page.locator("text=Login")).toHaveCount(1);
// click on login button text
await page.locator("text=Login").click();
// expect to be redirected to login page with query parameter callbackUrl
await expect(page).toHaveURL(/\/auth\/login\?callbackUrl=.*/);
await user.apiLogin();
// manual redirect to booking page
await page.goto(`/booking/${booking.uid}?cancel=true`);
// expect login button to don't be in the page
await expect(page.locator("text=Login")).toHaveCount(0);
// fill reason for cancellation
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
// confirm cancellation
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
const updatedBooking = await prisma.booking.findFirst({
where: { id: booking.id },
});
expect(updatedBooking).not.toBeNull();
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
});
}); });
test.describe("Reschedule for booking with seats", () => { test.describe("Reschedule for booking with seats", () => {
@ -543,4 +598,113 @@ test.describe("Reschedule for booking with seats", () => {
.first(); .first();
await expect(foundFirstAttendeeAgain).toHaveCount(1); await expect(foundFirstAttendeeAgain).toHaveCount(1);
}); });
test("Owner shouldn't be able to reschedule booking without login in", async ({
page,
bookings,
users,
}) => {
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const getBooking = await booking.self();
await page.goto(`/booking/${booking.uid}`);
await expect(page.locator('[data-testid="reschedule"]')).toHaveCount(0);
// expect login text to be in the page, not data-testid
await expect(page.locator("text=Login")).toHaveCount(1);
// click on login button text
await page.locator("text=Login").click();
// expect to be redirected to login page with query parameter callbackUrl
await expect(page).toHaveURL(/\/auth\/login\?callbackUrl=.*/);
await user.apiLogin();
// manual redirect to booking page
await page.goto(`/booking/${booking.uid}`);
// expect login button to don't be in the page
await expect(page.locator("text=Login")).toHaveCount(0);
// reschedule-link click
await page.locator('[data-testid="reschedule-link"]').click();
await selectFirstAvailableTimeSlotNextMonth(page);
// data displayed in form should be user owner
const nameElement = await page.locator("input[name=name]");
const name = await nameElement.inputValue();
expect(name).toBe(user.username);
//same for email
const emailElement = await page.locator("input[name=email]");
const email = await emailElement.inputValue();
expect(email).toBe(user.email);
// reason to reschedule input should be visible textfield with name rescheduleReason
const reasonElement = await page.locator("textarea[name=rescheduleReason]");
await expect(reasonElement).toBeVisible();
// expect to be redirected to reschedule page
await page.locator('[data-testid="confirm-reschedule-button"]').click();
// should wait for URL but that path starts with booking/
await page.waitForURL(/\/booking\/.*/);
await expect(page).toHaveURL(/\/booking\/.*/);
await page.waitForLoadState("networkidle");
const updatedBooking = await prisma.booking.findFirst({
where: { id: booking.id },
});
expect(updatedBooking).not.toBeNull();
expect(getBooking?.startTime).not.toBe(updatedBooking?.startTime);
expect(getBooking?.endTime).not.toBe(updatedBooking?.endTime);
expect(updatedBooking?.status).toBe(BookingStatus.ACCEPTED);
});
test("Owner shouldn't be able to reschedule when going directly to booking/rescheduleUid", async ({
page,
bookings,
users,
}) => {
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const getBooking = await booking.self();
await page.goto(`/${user.username}/seats?rescheduleUid=${getBooking?.uid}&bookingUid=null`);
await selectFirstAvailableTimeSlotNextMonth(page);
// expect textarea with name notes to be visible
const notesElement = await page.locator("textarea[name=notes]");
await expect(notesElement).toBeVisible();
// expect button confirm instead of reschedule
await expect(page.locator('[data-testid="confirm-book-button"]')).toHaveCount(1);
// now login and try again
await user.apiLogin();
await page.goto(`/${user.username}/seats?rescheduleUid=${getBooking?.uid}&bookingUid=null`);
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page).toHaveTitle(/(?!.*reschedule).*/);
// expect button reschedule
await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1);
});
// @TODO: force 404 when rescheduleUid is not found
}); });

View File

@ -375,6 +375,7 @@ export const BookEventFormChild = ({
fields={eventType.bookingFields} fields={eventType.bookingFields}
locations={eventType.locations} locations={eventType.locations}
rescheduleUid={rescheduleUid || undefined} rescheduleUid={rescheduleUid || undefined}
bookingData={bookingData}
/> />
{(createBookingMutation.isError || {(createBookingMutation.isError ||
createRecurringBookingMutation.isError || createRecurringBookingMutation.isError ||
@ -404,8 +405,8 @@ export const BookEventFormChild = ({
type="submit" type="submit"
color="primary" color="primary"
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading} loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}> data-testid={rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"}>
{rescheduleUid {rescheduleUid && bookingData
? t("reschedule") ? t("reschedule")
: renderConfirmNotVerifyEmailButtonCond : renderConfirmNotVerifyEmailButtonCond
? t("confirm") ? t("confirm")
@ -497,12 +498,18 @@ function useInitialFormValues({
}); });
const defaultUserValues = { const defaultUserValues = {
email: rescheduleUid email:
? bookingData?.attendees[0].email rescheduleUid && bookingData && bookingData.attendees.length > 0
: parsedQuery["email"] || session.data?.user?.email || "", ? bookingData?.attendees[0].email
name: rescheduleUid : !!parsedQuery["email"]
? bookingData?.attendees[0].name ? parsedQuery["email"]
: parsedQuery["name"] || session.data?.user?.name || "", : session.data?.user?.email ?? "",
name:
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].name
: !!parsedQuery["name"]
? parsedQuery["name"]
: session.data?.user?.name ?? session.data?.user?.username ?? "",
}; };
if (!isRescheduling) { if (!isRescheduling) {
@ -526,14 +533,12 @@ function useInitialFormValues({
setDefaultValues(defaults); setDefaultValues(defaults);
} }
if ((!rescheduleUid && !bookingData) || !bookingData?.attendees.length) { if (!rescheduleUid && !bookingData) {
return {};
}
const primaryAttendee = bookingData.attendees[0];
if (!primaryAttendee) {
return {}; return {};
} }
// We should allow current session user as default values for booking form
const defaults = { const defaults = {
responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>, responses: {} as Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>,
}; };
@ -541,7 +546,7 @@ function useInitialFormValues({
const responses = eventType.bookingFields.reduce((responses, field) => { const responses = eventType.bookingFields.reduce((responses, field) => {
return { return {
...responses, ...responses,
[field.name]: bookingData.responses[field.name], [field.name]: bookingData?.responses[field.name],
}; };
}, {}); }, {});
defaults.responses = { defaults.responses = {

View File

@ -1,6 +1,7 @@
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import type { LocationObject } from "@calcom/app-store/locations"; import type { LocationObject } from "@calcom/app-store/locations";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField"; import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -13,10 +14,12 @@ export const BookingFields = ({
locations, locations,
rescheduleUid, rescheduleUid,
isDynamicGroupBooking, isDynamicGroupBooking,
bookingData,
}: { }: {
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"]; fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
locations: LocationObject[]; locations: LocationObject[];
rescheduleUid?: string; rescheduleUid?: string;
bookingData?: GetBookingType | null;
isDynamicGroupBooking: boolean; isDynamicGroupBooking: boolean;
}) => { }) => {
const { t } = useLocale(); const { t } = useLocale();
@ -32,7 +35,9 @@ export const BookingFields = ({
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis. // During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful // Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly = let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid; (field.editable === "system" || field.editable === "system-but-optional") &&
!!rescheduleUid &&
bookingData !== null;
let hidden = !!field.hidden; let hidden = !!field.hidden;
const fieldViews = field.views; const fieldViews = field.views;
@ -42,6 +47,9 @@ export const BookingFields = ({
} }
if (field.name === SystemField.Enum.rescheduleReason) { if (field.name === SystemField.Enum.rescheduleReason) {
if (bookingData === null) {
return null;
}
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule // rescheduleReason is a reschedule specific field and thus should be editable during reschedule
readOnly = false; readOnly = false;
} }
@ -64,8 +72,8 @@ export const BookingFields = ({
hidden = isDynamicGroupBooking ? true : !!field.hidden; hidden = isDynamicGroupBooking ? true : !!field.hidden;
} }
// We don't show `notes` field during reschedule // We don't show `notes` field during reschedule but since it's a query param we better valid if rescheduleUid brought any bookingData
if (field.name === SystemField.Enum.notes && !!rescheduleUid) { if (field.name === SystemField.Enum.notes && bookingData !== null) {
return null; return null;
} }

View File

@ -1,3 +1,4 @@
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { HeadSeo } from "@calcom/ui"; import { HeadSeo } from "@calcom/ui";
@ -14,10 +15,20 @@ interface BookerSeoProps {
teamSlug?: string | null; teamSlug?: string | null;
name?: string | null; name?: string | null;
}; };
bookingData?: GetBookingType | null;
} }
export const BookerSeo = (props: BookerSeoProps) => { export const BookerSeo = (props: BookerSeoProps) => {
const { eventSlug, username, rescheduleUid, hideBranding, isTeamEvent, entity, isSEOIndexable } = props; const {
eventSlug,
username,
rescheduleUid,
hideBranding,
isTeamEvent,
entity,
isSEOIndexable,
bookingData,
} = props;
const { t } = useLocale(); const { t } = useLocale();
const { data: event } = trpc.viewer.public.event.useQuery( const { data: event } = trpc.viewer.public.event.useQuery(
{ username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null }, { username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null },
@ -29,7 +40,7 @@ export const BookerSeo = (props: BookerSeoProps) => {
const title = event?.title ?? ""; const title = event?.title ?? "";
return ( return (
<HeadSeo <HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${title} | ${profileName}`} title={`${rescheduleUid && !!bookingData ? t("reschedule") : ""} ${title} | ${profileName}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${title}`} description={`${rescheduleUid ? t("reschedule") : ""} ${title}`}
meeting={{ meeting={{
title: title, title: title,

View File

@ -109,7 +109,7 @@ export const getBookingWithResponses = <
export default getBooking; export default getBooking;
export const getBookingForReschedule = async (uid: string) => { export const getBookingForReschedule = async (uid: string, userId?: number) => {
let rescheduleUid: string | null = null; let rescheduleUid: string | null = null;
const theBooking = await prisma.booking.findFirst({ const theBooking = await prisma.booking.findFirst({
where: { where: {
@ -117,8 +117,25 @@ export const getBookingForReschedule = async (uid: string) => {
}, },
select: { select: {
id: true, id: true,
userId: true,
eventType: {
select: {
seatsPerTimeSlot: true,
hosts: {
select: {
userId: true,
},
},
owner: {
select: {
id: true,
},
},
},
},
}, },
}); });
let bookingSeatReferenceUid: number | null = null;
// If no booking is found via the uid, it's probably a booking seat // If no booking is found via the uid, it's probably a booking seat
// that its being rescheduled, which we query next. // that its being rescheduled, which we query next.
@ -144,11 +161,26 @@ export const getBookingForReschedule = async (uid: string) => {
}, },
}); });
if (bookingSeat) { if (bookingSeat) {
bookingSeatReferenceUid = bookingSeat.id;
rescheduleUid = bookingSeat.booking.uid; rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email; attendeeEmail = bookingSeat.attendee.email;
} }
} }
// If we have the booking and not bookingSeat, we need to make sure the booking belongs to the userLoggedIn
// Otherwise, we return null here.
let hasOwnershipOnBooking = false;
if (theBooking && theBooking?.eventType?.seatsPerTimeSlot && bookingSeatReferenceUid === null) {
const isOwnerOfBooking = theBooking.userId === userId;
const isHostOfEventType = theBooking?.eventType?.hosts.some((host) => host.userId === userId);
const isUserIdInBooking = theBooking.userId === userId;
if (!isOwnerOfBooking && !isHostOfEventType && !isUserIdInBooking) return null;
hasOwnershipOnBooking = true;
}
// If we don't have a booking and no rescheduleUid, the ID is invalid, // If we don't have a booking and no rescheduleUid, the ID is invalid,
// and we return null here. // and we return null here.
if (!theBooking && !rescheduleUid) return null; if (!theBooking && !rescheduleUid) return null;
@ -161,6 +193,8 @@ export const getBookingForReschedule = async (uid: string) => {
...booking, ...booking,
attendees: rescheduleUid attendees: rescheduleUid
? booking.attendees.filter((attendee) => attendee.email === attendeeEmail) ? booking.attendees.filter((attendee) => attendee.email === attendeeEmail)
: hasOwnershipOnBooking
? []
: booking.attendees, : booking.attendees,
}; };
}; };

View File

@ -136,6 +136,19 @@ async function handler(req: CustomRequest) {
throw new HttpError({ statusCode: 400, message: "User not found" }); throw new HttpError({ statusCode: 400, message: "User not found" });
} }
// If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host
if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) {
const userIsHost = bookingToDelete.eventType.hosts.find((host) => {
if (host.user.id === userId) return true;
});
const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId;
if (!userIsHost && !userIsOwnerOfEventType) {
throw new HttpError({ statusCode: 401, message: "User not a host of this event" });
}
}
// get webhooks // get webhooks
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";

View File

@ -15,6 +15,6 @@ export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: stri
}, },
}, },
}); });
if (bookingSeat) return bookingSeat.booking.uid; if (bookingSeat) return { uid: bookingSeat.booking.uid, seatReferenceUid: uid };
return uid; return { uid };
} }