diff --git a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx index 1ce751939d5814..2d15f09f473c6f 100644 --- a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx +++ b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx @@ -13,7 +13,11 @@ import type { SettingsToggleClassNames, } from "@calcom/features/eventtypes/lib/types"; import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types"; -import { MAX_EVENT_DURATION_MINUTES, MIN_EVENT_DURATION_MINUTES } from "@calcom/lib/constants"; +import { + MAX_EVENT_DURATION_MINUTES, + MAX_MULTI_DAY_EVENT_DURATION_MINUTES, + MIN_EVENT_DURATION_MINUTES, +} from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import { slugify } from "@calcom/lib/slugify"; @@ -77,7 +81,13 @@ export const EventSetupTab = ( ); const [firstRender, setFirstRender] = useState(true); + // Watch form values for multi-day configuration - use watched values as source of truth + const multiDayConfig = formMethods.watch("metadata")?.multiDayConfig; + const multiDayEnabled = multiDayConfig?.enabled ?? false; + const multiDayNumberOfDays = multiDayConfig?.numberOfDays ?? 1; + const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); + const recurringEventEnabled = !!formMethods.watch("recurringEvent"); const multipleDurationOptions = [ 5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480, @@ -190,7 +200,19 @@ export const EventSetupTab = ( "border-subtle rounded-lg border p-6", customClassNames?.durationSection?.container )}> - {multipleDuration ? ( + {multiDayEnabled ? ( +
+
+ +
+ {t("multi_day_duration_calculated", { + days: multiDayNumberOfDays, + hours: multiDayNumberOfDays * 24, + })} +
+
+
+ ) : multipleDuration ? (
)} {!lengthLockedProps.disabled && ( -
- { - if (multipleDuration !== undefined) { - setMultipleDuration(undefined); - setSelectedMultipleDuration([]); - setDefaultDuration(null); - formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true }); - formMethods.setValue("length", eventType.length, { shouldDirty: true }); - } else { - setMultipleDuration([]); - formMethods.setValue("metadata.multipleDuration", [], { shouldDirty: true }); - formMethods.setValue("length", 0, { shouldDirty: true }); + <> +
+ -
+ labelClassName={customClassNames?.durationSection?.selectDurationToggle?.label} + descriptionClassName={customClassNames?.durationSection?.selectDurationToggle?.description} + switchContainerClassName={customClassNames?.durationSection?.selectDurationToggle?.container} + childrenClassName={customClassNames?.durationSection?.selectDurationToggle?.children} + onCheckedChange={() => { + if (multipleDuration !== undefined) { + setMultipleDuration(undefined); + setSelectedMultipleDuration([]); + setDefaultDuration(null); + formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true }); + formMethods.setValue("length", eventType.length, { shouldDirty: true }); + } else { + setMultipleDuration([]); + formMethods.setValue("metadata.multipleDuration", [], { shouldDirty: true }); + formMethods.setValue("length", 0, { shouldDirty: true }); + } + }} + /> +
+
+ { + if (checked) { + formMethods.setValue( + "metadata.multiDayConfig", + { + enabled: true, + numberOfDays: multiDayNumberOfDays, + }, + { shouldDirty: true } + ); + // Set duration to number of days * 24 hours * 60 minutes + formMethods.setValue("length", multiDayNumberOfDays * 24 * 60, { shouldDirty: true }); + } else { + formMethods.setValue("metadata.multiDayConfig", undefined, { shouldDirty: true }); + formMethods.setValue("length", eventType.length, { shouldDirty: true }); + } + }}> + {multiDayEnabled && ( +
+ { + const days = parseInt(e.target.value) || 1; + const clampedDays = Math.min(Math.max(days, 1), 30); + formMethods.setValue( + "metadata.multiDayConfig", + { + enabled: true, + numberOfDays: clampedDays, + }, + { shouldDirty: true } + ); + // Update duration to reflect number of days + formMethods.setValue("length", clampedDays * 24 * 60, { shouldDirty: true }); + }} + min={1} + max={30} + addOnSuffix={<>{t("days")}} + /> +
+ )} +
+
+ )}
{ + const mockLogger = { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Single Duration Mode", () => { + it("should accept booking with correct single duration", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(30, "minutes").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 30, + logger: mockLogger, + }); + }).not.toThrow(); + }); + + it("should reject booking with incorrect duration", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(45, "minutes").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 30, + logger: mockLogger, + }); + }).toThrow(HttpError); + }); + }); + + describe("Multiple Duration Mode", () => { + it("should accept booking with one of the multiple duration options", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(45, "minutes").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeMultipleDuration: [15, 30, 45, 60], + eventTypeLength: 30, + logger: mockLogger, + }); + }).not.toThrow(); + }); + + it("should reject booking with duration not in multiple duration options", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(90, "minutes").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeMultipleDuration: [15, 30, 45, 60], + eventTypeLength: 30, + logger: mockLogger, + }); + }).toThrow(HttpError); + }); + }); + + describe("Multi-Day Mode", () => { + it("should accept booking with correct multi-day duration (1 day)", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(1, "day").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 1440, + eventTypeMultiDayConfig: { + enabled: true, + numberOfDays: 1, + }, + logger: mockLogger, + }); + }).not.toThrow(); + }); + + it("should accept booking with correct multi-day duration (3 days)", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(3, "days").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 4320, + eventTypeMultiDayConfig: { + enabled: true, + numberOfDays: 3, + }, + logger: mockLogger, + }); + }).not.toThrow(); + }); + + it("should reject booking with incorrect multi-day duration", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(2, "days").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 4320, + eventTypeMultiDayConfig: { + enabled: true, + numberOfDays: 3, + }, + logger: mockLogger, + }); + }).toThrow(HttpError); + }); + + it("should accept booking with maximum multi-day duration (30 days)", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(30, "days").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 43200, + eventTypeMultiDayConfig: { + enabled: true, + numberOfDays: 30, + }, + logger: mockLogger, + }); + }).not.toThrow(); + }); + + it("should prioritize multi-day config over multiple durations", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(2, "days").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeMultipleDuration: [30, 60, 90], + eventTypeLength: 30, + eventTypeMultiDayConfig: { + enabled: true, + numberOfDays: 2, + }, + logger: mockLogger, + }); + }).not.toThrow(); + }); + }); + + describe("Error Handling", () => { + it("should log warning when validation fails", () => { + const start = dayjs().toISOString(); + const end = dayjs().add(45, "minutes").toISOString(); + + expect(() => { + validateEventLength({ + reqBodyStart: start, + reqBodyEnd: end, + eventTypeLength: 30, + logger: mockLogger, + }); + }).toThrow(); + + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: "NewBooking: Invalid event length", + }); + }); + }); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts b/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts index 9568ed27321555..cf4ee82c0559e1 100644 --- a/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts +++ b/packages/features/bookings/lib/handleNewBooking/validateEventLength.ts @@ -9,6 +9,10 @@ type Props = { reqBodyEnd: string; eventTypeMultipleDuration?: number[]; eventTypeLength: number; + eventTypeMultiDayConfig?: { + enabled: boolean; + numberOfDays: number; + }; logger: Logger; }; @@ -18,10 +22,26 @@ const _validateEventLength = ({ reqBodyEnd, eventTypeMultipleDuration, eventTypeLength, + eventTypeMultiDayConfig, logger, }: Props) => { const reqEventLength = dayjs(reqBodyEnd).diff(dayjs(reqBodyStart), "minutes"); - const validEventLengths = eventTypeMultipleDuration?.length ? eventTypeMultipleDuration : [eventTypeLength]; + + // Calculate valid event lengths based on configuration + let validEventLengths: number[]; + + if (eventTypeMultiDayConfig?.enabled) { + // For multi-day bookings, the valid duration is numberOfDays * 24 hours * 60 minutes + const multiDayDurationInMinutes = eventTypeMultiDayConfig.numberOfDays * 24 * 60; + validEventLengths = [multiDayDurationInMinutes]; + } else if (eventTypeMultipleDuration?.length) { + // For multiple duration options + validEventLengths = eventTypeMultipleDuration; + } else { + // For single duration + validEventLengths = [eventTypeLength]; + } + if (!validEventLengths.includes(reqEventLength)) { logger.warn({ message: "NewBooking: Invalid event length" }); throw new HttpError({ statusCode: 400, message: "Invalid event length" }); diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 79f608adb14159..a75bbfc81e9c9f 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -840,6 +840,7 @@ async function handler( reqBodyEnd: reqBody.end, eventTypeMultipleDuration: eventType.metadata?.multipleDuration, eventTypeLength: eventType.length, + eventTypeMultiDayConfig: eventType.metadata?.multiDayConfig, logger: tracingLogger, }); diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 7e9917562a7c2f..722ef8b0ddd073 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -162,6 +162,8 @@ export type FormValues = { availability?: AvailabilityOption; bookerLayouts: BookerLayoutSettings; multipleDurationEnabled: boolean; + multiDayEnabled: boolean; + multiDayNumberOfDays: number; users: EventTypeSetup["users"]; assignAllTeamMembers: boolean; assignRRMembersUsingSegment: boolean; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index aa4ae30d95a5f1..cd45b79e391ca6 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -65,6 +65,9 @@ export const MAX_SEATS_PER_TIME_SLOT = 1000; /** Maximum duration allowed for an event in minutes (24 hours) */ export const MAX_EVENT_DURATION_MINUTES = 1440; +/** Maximum duration allowed for a multi-day event in minutes (30 days) */ +export const MAX_MULTI_DAY_EVENT_DURATION_MINUTES = 30 * 24 * 60; + /** Minimum duration allowed for an event in minutes */ export const MIN_EVENT_DURATION_MINUTES = 1; diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 698570b77299be..808ebce526dcbe 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -100,6 +100,9 @@ export const useEventTypeForm = ({ allowReschedulingPastBookings: eventType.allowReschedulingPastBookings, hideOrganizerEmail: eventType.hideOrganizerEmail, metadata: eventType.metadata, + multipleDurationEnabled: !!eventType.metadata?.multipleDuration, + multiDayEnabled: eventType.metadata?.multiDayConfig?.enabled ?? false, + multiDayNumberOfDays: eventType.metadata?.multiDayConfig?.numberOfDays ?? 1, hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)), hostGroups: eventType.hostGroups || [], successRedirectUrl: eventType.successRedirectUrl || "", @@ -329,6 +332,10 @@ export const useEventTypeForm = ({ bookerLayouts, // eslint-disable-next-line @typescript-eslint/no-unused-vars multipleDurationEnabled, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiDayEnabled, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + multiDayNumberOfDays, length, ...input } = dirtyValues; diff --git a/packages/prisma/zod-utils.multiday.test.ts b/packages/prisma/zod-utils.multiday.test.ts new file mode 100644 index 00000000000000..3efb75302424db --- /dev/null +++ b/packages/prisma/zod-utils.multiday.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { EventTypeMetaDataSchema } from "./zod-utils"; + +describe("EventTypeMetaDataSchema - Multi-Day Config", () => { + describe("Valid multi-day configurations", () => { + it("should accept valid multi-day config with 1 day", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: 1, + }, + }); + + expect(result.success).toBe(true); + }); + + it("should accept valid multi-day config with 30 days", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: 30, + }, + }); + + expect(result.success).toBe(true); + }); + + it("should accept multi-day config with mid-range value", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: false, + numberOfDays: 15, + }, + }); + + expect(result.success).toBe(true); + }); + + it("should accept null metadata", () => { + const result = EventTypeMetaDataSchema.safeParse(null); + expect(result.success).toBe(true); + }); + + it("should accept metadata without multiDayConfig", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multipleDuration: [30, 60, 90], + }); + + expect(result.success).toBe(true); + }); + }); + + describe("Invalid multi-day configurations", () => { + it("should reject numberOfDays less than 1", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: 0, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("numberOfDays"); + } + }); + + it("should reject numberOfDays greater than 30", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: 31, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("numberOfDays"); + } + }); + + it("should reject negative numberOfDays", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: -5, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("numberOfDays"); + } + }); + + it("should reject decimal numberOfDays due to .int() constraint", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: 2.5, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("numberOfDays"); + expect(result.error.issues[0].message).toContain("integer"); + } + }); + + it("should reject missing enabled field", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + numberOfDays: 5, + }, + }); + + expect(result.success).toBe(false); + }); + + it("should reject missing numberOfDays field", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + }, + }); + + expect(result.success).toBe(false); + }); + + it("should reject non-boolean enabled field", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: "true", + numberOfDays: 5, + }, + }); + + expect(result.success).toBe(false); + }); + + it("should reject non-number numberOfDays field", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: "5", + }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe("Compatibility with other metadata fields", () => { + it("should accept multi-day config alongside other metadata fields", () => { + const result = EventTypeMetaDataSchema.safeParse({ + multiDayConfig: { + enabled: true, + numberOfDays: 3, + }, + additionalNotesRequired: true, + disableSuccessPage: false, + apps: { + stripe: { + enabled: true, + }, + }, + }); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9dd03b80d581d3..27374c8b1757b1 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -218,6 +218,12 @@ const _eventTypeMetaDataSchemaWithoutApps = z.object({ smartContractAddress: z.string().optional(), blockchainId: z.number().optional(), multipleDuration: z.number().array().optional(), + multiDayConfig: z + .object({ + enabled: z.boolean(), + numberOfDays: z.number().int().min(1).max(30), + }) + .optional(), giphyThankYouPage: z.string().optional(), additionalNotesRequired: z.boolean().optional(), disableSuccessPage: z.boolean().optional(), diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index cef6bd12538532..11b086905f8d06 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -212,6 +212,31 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); } + // Validate multi-day booking conflicts + const multiDayConfig = input.metadata?.multiDayConfig; + const multipleDuration = input.metadata?.multipleDuration; + + if (multiDayConfig?.enabled && finalSeatsPerTimeSlot) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Multi-day bookings and Offer Seats cannot be active at the same time.", + }); + } + + if (multiDayConfig?.enabled && multipleDuration?.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Multi-day bookings and multiple durations cannot be active at the same time.", + }); + } + + if (multiDayConfig?.enabled && finalRecurringEvent) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Multi-day bookings and recurring events cannot be active at the same time.", + }); + } + const teamId = input.teamId || eventType.team?.id; const guestsField = bookingFields?.find((field) => field.name === "guests");