diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 296fd56044..b6b385bcc2 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -658,3 +658,36 @@ test.describe("Event type with disabled cancellation and rescheduling", () => { expect(responseBody.message).toBe("This event type does not allow cancellations"); }); }); +test("Should throw error when both seatsPerTimeSlot and recurringEvent are set", async ({ page, users }) => { + const user = await users.create({ + name: `Test-user-${randomString(4)}`, + eventTypes: [ + { + title: "Seats With Recurrence", + slug: "seats-with-recurrence", + length: 30, + seatsPerTimeSlot: 3, + recurringEvent: { + freq: 1, + count: 4, + interval: 1, + }, + }, + ], + }); + + // Way to book the event + await page.goto(`/${user.username}/seats-with-recurrence`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.locator('[name="name"]').fill("Test name"); + await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`); + + page.locator("[data-testid=confirm-book-button]").click(); + + // Expect an error message to be displayed + const alertError = page.locator("[data-testid=booking-fail]"); + await expect(alertError).toBeVisible(); + await expect(alertError).toContainText( + "Could not book the meeting. Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring." + ); +}); diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index 97ba03386d..3c79f4a642 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -387,6 +387,39 @@ test.describe("Event Types tests", () => { await unCheckDisplayLocation(page); }); }); + + test("Should not allow enabling both recurring event and offer seats at the same time", async ({ + page, + }) => { + const nonce = randomString(3); + const eventTitle = `Conflict event ${nonce}`; + await createNewEventType(page, { eventTitle }); + await page.goto("/event-types"); + await page.click(`text=${eventTitle}`); + + // Go to Advanced tab and enable offerSeats + await page.click("[data-testid=vertical-tab-event_advanced_tab_title]"); + const offerSeatsToggle = page.locator("[data-testid=offer-seats-toggle]"); + await offerSeatsToggle.click(); + + // Try enabling recurring - should be disabled + await page.click("[data-testid=vertical-tab-recurring]"); + const recurringEventToggle = page.locator("[data-testid=recurring-event-check]"); + await expect(recurringEventToggle).toBeDisabled(); + + // Go back and disable offerSeats + await page.click("[data-testid=vertical-tab-event_advanced_tab_title]"); + await offerSeatsToggle.click(); // turn it off + + // Enable recurring now + await page.click("[data-testid=vertical-tab-recurring]"); + await recurringEventToggle.click(); + await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeVisible(); + + // After enabling recurring, offerSeats should now be disabled + await page.click("[data-testid=vertical-tab-event_advanced_tab_title]"); + await expect(offerSeatsToggle).toBeDisabled(); + }); }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 09d6ade20f..4134e92012 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3053,6 +3053,9 @@ "tip_username_plus": "Tip: You can a '+' between usernames: cal.com/anna+brian to make a dynamic group meeting", "user_has_no_team_yet": "You don't have a team yet", "no_team_members": "You don't have team members yet", + "recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.", + "seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.", + "recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.", "segment": "Segment", "rename": "Rename", "rename_segment": "Rename Segment", diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 929f52bc9d..9a18537ecf 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -405,6 +405,14 @@ async function handler( const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user); if (!eventType) throw new HttpError({ statusCode: 404, message: "event_type_not_found" }); + + if (eventType.seatsPerTimeSlot && eventType.recurringEvent) { + throw new HttpError({ + statusCode: 400, + message: "recurring_event_seats_error", + }); + } + const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id); const isTeamEventType = diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 2259a9f72c..7385b2c336 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -444,6 +444,7 @@ export const EventAdvancedTab = ({ formMethods.getValues("metadata")?.apps?.stripe?.enabled === true && formMethods.getValues("metadata")?.apps?.stripe?.paymentOption === "HOLD"; + const isRecurringEvent = !!formMethods.getValues("recurringEvent"); const isRoundRobinEventType = eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN; @@ -853,12 +854,14 @@ export const EventAdvancedTab = ({ {...seatsLocked} description={t("offer_seats_description")} checked={value} - disabled={noShowFeeEnabled || multiLocation} + disabled={noShowFeeEnabled || multiLocation || (!seatsEnabled && isRecurringEvent)} tooltip={ multiLocation ? t("multilocation_doesnt_support_seats") : noShowFeeEnabled ? t("no_show_fee_doesnt_support_seats") + : isRecurringEvent + ? t("recurring_event_doesnt_support_seats") : undefined } onCheckedChange={(e) => { diff --git a/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx b/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx index 9aa196fef0..b31b1a4caf 100644 --- a/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx +++ b/packages/features/eventtypes/components/tabs/recurring/RecurringEventController.tsx @@ -12,11 +12,11 @@ import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Frequency } from "@calcom/prisma/zod-utils"; import type { RecurringEvent } from "@calcom/types/Calendar"; +import classNames from "@calcom/ui/classNames"; +import { Alert } from "@calcom/ui/components/alert"; import { Select } from "@calcom/ui/components/form"; import { SettingsToggle } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; -import { Alert } from "@calcom/ui/components/alert"; -import classNames from "@calcom/ui/classNames"; export type RecurringEventControllerProps = { eventType: EventTypeSetup; @@ -49,6 +49,7 @@ export default function RecurringEventController({ const [recurringEventState, setRecurringEventState] = useState( formMethods.getValues("recurringEvent") ); + const isSeatsOffered = !!formMethods.getValues("seatsPerTimeSlot"); /* Just yearly-0, monthly-1 and weekly-2 */ const recurringEventFreqOptions = Object.entries(Frequency) .filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3) @@ -90,8 +91,10 @@ export default function RecurringEventController({ title={t("recurring_event")} {...recurringLocked} description={t("recurring_event_description")} - checked={recurringEventState !== null} + checked={!!recurringEventState} data-testid="recurring-event-check" + disabled={!recurringEventState && isSeatsOffered} + tooltip={isSeatsOffered ? t("seats_doesnt_support_recurring") : undefined} onCheckedChange={(e) => { if (!e) { formMethods.setValue("recurringEvent", null, { shouldDirty: true }); diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index eb5635fbdb..ede162fe88 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -298,6 +298,13 @@ export const useEventTypeForm = ({ } = dirtyValues; if (length && !Number(length)) throw new Error(t("event_setup_length_error")); + const finalSeatsPerTimeSlot = seatsPerTimeSlot ?? values.seatsPerTimeSlot; + const finalRecurringEvent = recurringEvent ?? values.recurringEvent; + + if (finalSeatsPerTimeSlot && finalRecurringEvent) { + throw new Error(t("recurring_event_seats_error")); + } + if (bookingLimits) { const isValid = validateIntervalLimitOrder(bookingLimits); if (!isValid) throw new Error(t("event_setup_booking_limits_error")); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 1355a7db4b..a00f679853 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -82,6 +82,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { autoTranslateDescriptionEnabled, description: newDescription, title: newTitle, + seatsPerTimeSlot, ...rest } = input; @@ -90,6 +91,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { select: { title: true, description: true, + seatsPerTimeSlot: true, + recurringEvent: true, fieldTranslations: { select: { field: true, @@ -160,6 +163,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { throw new TRPCError({ code: "UNAUTHORIZED" }); } + const finalSeatsPerTimeSlot = seatsPerTimeSlot ?? eventType.seatsPerTimeSlot; + const finalRecurringEvent = recurringEvent ?? eventType.recurringEvent; + + if (finalSeatsPerTimeSlot && finalRecurringEvent) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Recurring Events and Offer Seats cannot be active at the same time.", + }); + } + const teamId = input.teamId || eventType.team?.id; const guestsField = bookingFields?.find((field) => field.name === "guests"); @@ -187,6 +200,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { disableGuests: guestsField?.hidden ?? false, }; data.locations = locations ?? undefined; + if (periodType) { data.periodType = handlePeriodType(periodType); }