From b4315b186a8808d062c298e8bb331f2f7720a798 Mon Sep 17 00:00:00 2001 From: alannnc Date: Fri, 6 Oct 2023 09:17:15 -0700 Subject: [PATCH] fix: seated event type public id cancels/reschedule entire booking without auth (#11667) Co-authored-by: Keith Williams --- apps/web/pages/[user]/[type].tsx | 8 +- apps/web/pages/booking/[uid].tsx | 77 +++++--- apps/web/pages/d/[link]/[slug].tsx | 4 +- apps/web/pages/reschedule/[uid].tsx | 56 +++++- apps/web/pages/team/[slug]/[type].tsx | 5 +- apps/web/playwright/booking-seats.e2e.ts | 166 +++++++++++++++++- .../BookEventForm/BookEventForm.tsx | 33 ++-- .../BookEventForm/BookingFields.tsx | 14 +- .../bookings/components/BookerSeo.tsx | 15 +- packages/features/bookings/lib/get-booking.ts | 36 +++- .../bookings/lib/handleCancelBooking.ts | 13 ++ .../lib/server/maybeGetBookingUidFromSeat.ts | 4 +- 12 files changed, 376 insertions(+), 55 deletions(-) diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 8f62d6e6ba..4c568cc946 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import { Booker } from "@calcom/atoms"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { @@ -43,6 +44,7 @@ export default function Type({ hideBranding={isBrandingHidden} isSEOIndexable={isSEOIndexable ?? true} entity={entity} + bookingData={booking} /> (undefined); - + const { requiresLoginToUpdate } = props; function setIsCancellationMode(value: boolean) { const _searchParams = new URLSearchParams(searchParams); @@ -532,7 +532,28 @@ export default function Success(props: SuccessProps) { })} - {(!needsConfirmation || !userIsOwner) && + {requiresLoginToUpdate && ( + <> +
+
+ {t("need_to_make_a_change")} + {/* Login button but redirect to here */} + + + + {t("login")} + + + +
+ + )} + {!requiresLoginToUpdate && + (!needsConfirmation || !userIsOwner) && !isCancelled && (!isCancellationMode ? ( <> @@ -540,28 +561,30 @@ export default function Success(props: SuccessProps) {
{t("need_to_make_a_change")} - {!props.recurringBookings && ( - - - - {t("reschedule")} - + <> + {!props.recurringBookings && ( + + + + {t("reschedule")} + + + {t("or_lowercase")} - {t("or_lowercase")} - - )} - - + + +
) : ( @@ -1010,7 +1033,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const session = await getServerSession(context); let tz: string | null = null; let userTimeFormat: number | null = null; - + let requiresLoginToUpdate = false; if (session) { const user = await ssr.viewer.me.fetch(); tz = user.timeZone; @@ -1022,9 +1045,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (!parsedQuery.success) return { notFound: true }; const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; + const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid); const bookingInfoRaw = await prisma.booking.findFirst({ where: { - uid: await maybeGetBookingUidFromSeat(prisma, uid), + uid: maybeUid, }, select: { title: true, @@ -1088,6 +1112,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } + if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) { + requiresLoginToUpdate = true; + } + const bookingInfo = getBookingWithResponses(bookingInfoRaw); // @NOTE: had to do this because Server side cant return [Object objects] // probably fixable with json.stringify -> json.parse @@ -1156,6 +1184,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { paymentStatus: payment, ...(tz && { tz }), userTimeFormat, + requiresLoginToUpdate, }, }; } diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 73b4e91f02..0b715ca5b9 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import { Booker } from "@calcom/atoms"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; @@ -57,6 +58,7 @@ Type.PageWrapper = PageWrapper; Type.isBookingPage = true; async function getUserPageProps(context: GetServerSidePropsContext) { + const session = await getServerSession(context); const { link, slug } = paramsSchema.parse(context.params); const { rescheduleUid, duration: queryDuration } = context.query; const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? ""); @@ -119,7 +121,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { let booking: GetBookingType | null = null; if (rescheduleUid) { - booking = await getBookingForReschedule(`${rescheduleUid}`); + booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id); } const isTeamEvent = !!hashedLink.eventType?.team?.id; diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index fd07ae5f61..fbfa33d963 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next"; import { URLSearchParams } from "url"; import { z } from "zod"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; @@ -12,11 +13,16 @@ export default function Type() { } 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() }) .parse(context.query); - const uid = await maybeGetBookingUidFromSeat(prisma, bookingId); + const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat( + prisma, + bookingUid + ); const booking = await prisma.booking.findUnique({ where: { uid, @@ -37,6 +43,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }, }, seatsPerTimeSlot: true, + userId: true, + owner: { + select: { + id: true, + }, + }, + hosts: { + select: { + user: { + select: { + id: true, + }, + }, + }, + }, }, }, dynamicEventSlugRef: true, @@ -53,7 +74,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } 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 { notFound: true, } 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 eventPage = `${ @@ -72,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }/${eventType?.slug}`; const destinationUrl = new URLSearchParams(); - destinationUrl.set("rescheduleUid", seatReferenceUid || bookingId); + destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid); return { redirect: { diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index c94247646e..668a82d6f8 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import { Booker } from "@calcom/atoms"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; @@ -37,6 +38,7 @@ export default function Type({ hideBranding={isBrandingHidden} isTeamEvent entity={entity} + bookingData={booking} /> { + const session = await getServerSession(context); const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params); const { rescheduleUid, duration: queryDuration } = context.query; const { ssrInit } = await import("@server/lib/ssr"); @@ -92,7 +95,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => let booking: GetBookingType | null = null; if (rescheduleUid) { - booking = await getBookingForReschedule(`${rescheduleUid}`); + booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id); } const org = isValidOrgDomain ? currentOrgDomain : null; diff --git a/apps/web/playwright/booking-seats.e2e.ts b/apps/web/playwright/booking-seats.e2e.ts index c46e84d4d2..c291f2d111 100644 --- a/apps/web/playwright/booking-seats.e2e.ts +++ b/apps/web/playwright/booking-seats.e2e.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import { uuid } from "short-uuid"; import { v4 as uuidv4 } from "uuid"; +import { randomString } from "@calcom/lib/random"; import prisma from "@calcom/prisma"; 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 }) => { - 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: "Jane Second", email: "second+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); }); + 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 () => { // The remaining 2 attendees cancel for (let i = 1; i < bookingSeats.length; i++) { @@ -166,6 +180,47 @@ test.describe("Booking with Seats", () => { 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", () => { @@ -543,4 +598,113 @@ test.describe("Reschedule for booking with seats", () => { .first(); 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 }); diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 60f9a627cf..eda9cb262c 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -375,6 +375,7 @@ export const BookEventFormChild = ({ fields={eventType.bookingFields} locations={eventType.locations} rescheduleUid={rescheduleUid || undefined} + bookingData={bookingData} /> {(createBookingMutation.isError || createRecurringBookingMutation.isError || @@ -404,8 +405,8 @@ export const BookEventFormChild = ({ type="submit" color="primary" loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading} - data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}> - {rescheduleUid + data-testid={rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"}> + {rescheduleUid && bookingData ? t("reschedule") : renderConfirmNotVerifyEmailButtonCond ? t("confirm") @@ -497,12 +498,18 @@ function useInitialFormValues({ }); const defaultUserValues = { - email: rescheduleUid - ? bookingData?.attendees[0].email - : parsedQuery["email"] || session.data?.user?.email || "", - name: rescheduleUid - ? bookingData?.attendees[0].name - : parsedQuery["name"] || session.data?.user?.name || "", + email: + rescheduleUid && bookingData && bookingData.attendees.length > 0 + ? bookingData?.attendees[0].email + : !!parsedQuery["email"] + ? parsedQuery["email"] + : 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) { @@ -526,14 +533,12 @@ function useInitialFormValues({ setDefaultValues(defaults); } - if ((!rescheduleUid && !bookingData) || !bookingData?.attendees.length) { - return {}; - } - const primaryAttendee = bookingData.attendees[0]; - if (!primaryAttendee) { + if (!rescheduleUid && !bookingData) { return {}; } + // We should allow current session user as default values for booking form + const defaults = { responses: {} as Partial>>, }; @@ -541,7 +546,7 @@ function useInitialFormValues({ const responses = eventType.bookingFields.reduce((responses, field) => { return { ...responses, - [field.name]: bookingData.responses[field.name], + [field.name]: bookingData?.responses[field.name], }; }, {}); defaults.responses = { diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx index d301891770..ab1505a787 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx @@ -1,6 +1,7 @@ import { useFormContext } from "react-hook-form"; 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 { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -13,10 +14,12 @@ export const BookingFields = ({ locations, rescheduleUid, isDynamicGroupBooking, + bookingData, }: { fields: NonNullable["bookingFields"]; locations: LocationObject[]; rescheduleUid?: string; + bookingData?: GetBookingType | null; isDynamicGroupBooking: boolean; }) => { 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. // Allowing a system field to be edited might require sending emails to attendees, so we need to be careful 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; const fieldViews = field.views; @@ -42,6 +47,9 @@ export const BookingFields = ({ } if (field.name === SystemField.Enum.rescheduleReason) { + if (bookingData === null) { + return null; + } // rescheduleReason is a reschedule specific field and thus should be editable during reschedule readOnly = false; } @@ -64,8 +72,8 @@ export const BookingFields = ({ hidden = isDynamicGroupBooking ? true : !!field.hidden; } - // We don't show `notes` field during reschedule - if (field.name === SystemField.Enum.notes && !!rescheduleUid) { + // 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 && bookingData !== null) { return null; } diff --git a/packages/features/bookings/components/BookerSeo.tsx b/packages/features/bookings/components/BookerSeo.tsx index b331b8fb9f..e11b872431 100644 --- a/packages/features/bookings/components/BookerSeo.tsx +++ b/packages/features/bookings/components/BookerSeo.tsx @@ -1,3 +1,4 @@ +import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { HeadSeo } from "@calcom/ui"; @@ -14,10 +15,20 @@ interface BookerSeoProps { teamSlug?: string | null; name?: string | null; }; + bookingData?: GetBookingType | null; } 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 { data: event } = trpc.viewer.public.event.useQuery( { username, eventSlug, isTeamEvent, org: entity.orgSlug ?? null }, @@ -29,7 +40,7 @@ export const BookerSeo = (props: BookerSeoProps) => { const title = event?.title ?? ""; return ( { +export const getBookingForReschedule = async (uid: string, userId?: number) => { let rescheduleUid: string | null = null; const theBooking = await prisma.booking.findFirst({ where: { @@ -117,8 +117,25 @@ export const getBookingForReschedule = async (uid: string) => { }, select: { 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 // that its being rescheduled, which we query next. @@ -144,11 +161,26 @@ export const getBookingForReschedule = async (uid: string) => { }, }); if (bookingSeat) { + bookingSeatReferenceUid = bookingSeat.id; rescheduleUid = bookingSeat.booking.uid; 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, // and we return null here. if (!theBooking && !rescheduleUid) return null; @@ -161,6 +193,8 @@ export const getBookingForReschedule = async (uid: string) => { ...booking, attendees: rescheduleUid ? booking.attendees.filter((attendee) => attendee.email === attendeeEmail) + : hasOwnershipOnBooking + ? [] : booking.attendees, }; }; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 37758d3f34..924ae49436 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -136,6 +136,19 @@ async function handler(req: CustomRequest) { 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 const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; diff --git a/packages/lib/server/maybeGetBookingUidFromSeat.ts b/packages/lib/server/maybeGetBookingUidFromSeat.ts index c211da5f4c..a6a4b8587c 100644 --- a/packages/lib/server/maybeGetBookingUidFromSeat.ts +++ b/packages/lib/server/maybeGetBookingUidFromSeat.ts @@ -15,6 +15,6 @@ export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: stri }, }, }); - if (bookingSeat) return bookingSeat.booking.uid; - return uid; + if (bookingSeat) return { uid: bookingSeat.booking.uid, seatReferenceUid: uid }; + return { uid }; }