diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a5b86beebf..3a4bf3671a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3240,6 +3240,10 @@ "requires_credits": "Requires credits", "requires_credits_tooltip": "You need enough credits in your account to use this feature", "available_credits": "Available credits", + "choose_restriction_schedule": "Add restriction schedule", + "choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.", + "use_booker_timezone": "Use booker's timezone", + "use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone", "load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types", "rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time", "round_robin_settings_description": "Customize the default round robin settings for this team", @@ -3253,6 +3257,8 @@ "booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?", "duration_min_error": "Duration must be at least {{min}} minute(s)", "duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)", + "booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.", + "restriction_schedule_not_found_error": "Restriction schedule not found", "converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts b/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts new file mode 100644 index 0000000000..1772a9f47e --- /dev/null +++ b/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts @@ -0,0 +1,479 @@ +import prismock from "../../../../../tests/libs/__mocks__/prisma"; + +import { + createBookingScenario, + Timezones, + TestData, + type ScenarioData, +} from "../../utils/bookingScenario/bookingScenario"; + +import { describe, test, vi } from "vitest"; +import type { z } from "zod"; + +import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types"; +import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util"; + +import { expect } from "./expects"; +import { setupAndTeardown } from "./setupAndTeardown"; + +// Mock the FeaturesRepository to enable restriction-schedule feature +vi.mock("@calcom/features/flags/features.repository", () => ({ + FeaturesRepository: vi.fn().mockImplementation(() => ({ + checkIfTeamHasFeature: vi.fn().mockResolvedValue(true), + checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + getAllFeatures: vi.fn().mockResolvedValue([]), + getFeatureFlagMap: vi.fn().mockResolvedValue({}), + checkIfUserHasFeature: vi.fn().mockResolvedValue(true), + })), +})); + +type ScheduleScenario = { + eventTypes: ScenarioData["eventTypes"]; + users: ScenarioData["users"]; + apps: ScenarioData["apps"]; + selectedSlots?: ScenarioData["selectedSlots"]; +}; + +const getTestScheduleInput = ({ + yesterdayDateString, + plus5DateString, +}: { + yesterdayDateString: string; + plus5DateString: string; +}): z.infer => ({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${yesterdayDateString}T18:30:00.000Z`, + endTime: `${plus5DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: true, + orgSlug: null, +}); + +const getBaseScenarioData = (): ScheduleScenario => ({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + teamId: 1, + hosts: [{ userId: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [TestData.schedules.IstWorkHours], + teams: [ + { + membership: { role: "ADMIN", accepted: true }, + team: { id: 1, name: "Test Team", slug: "test-team" }, + }, + ], + }, + ], + apps: [], +}); + +// Helper function to set up team for the tests +async function setupTeamAndFeatures() { + // Create the team (needed for event type configuration) + await prismock.team.create({ + data: { + id: 1, + name: "Test Team", + slug: "test-team", + }, + }); +} + +describe("getSchedule", () => { + setupAndTeardown(); + + describe("Restriction Schedule", () => { + test("should respect date override rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => { + await setupTeamAndFeatures(); + vi.setSystemTime("2025-06-01T23:30:00Z"); + + const plus2DateString = "2025-06-02"; // Date with override + const plus5DateString = "2025-06-05"; + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + eventTypes: [ + { + id: 1, + length: 60, + teamId: 1, + restrictionScheduleId: 50, + useBookerTimezone: false, + hosts: [{ userId: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + id: 1, + name: "Test Schedule", + timeZone: "Europe/London", + availability: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + date: null, + }, + ], + }, + ], + teams: [ + { + membership: { role: "ADMIN", accepted: true }, + team: { id: 1, name: "Test Team", slug: "test-team" }, + }, + ], + }, + ], + }; + + scenarioData.users[0].schedules.push({ + id: 50, + name: "Restriction Schedule - Only Date Override", + timeZone: "Asia/Dubai", + availability: [ + { + date: "2025-06-02T00:00:00.000Z", + days: [], + startTime: new Date("1970-01-01T10:00:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + }, + ], + }); + + await createBookingScenario(scenarioData); + + const result = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: "2025-06-01T00:00:00.000Z", + endTime: `${plus5DateString}T23:59:59.999Z`, + timeZone: "Asia/Kolkata", + isTeamEvent: true, + orgSlug: null, + }, + }); + + // Expected slots based on intersection of: + // - User availability: 9 AM - 5 PM London + // - Restriction: 10 AM - 6 PM Dubai + const expectedUtcSlots = [ + "2025-06-02T08:00:00.000Z", // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata + "2025-06-02T09:00:00.000Z", // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata + "2025-06-02T10:00:00.000Z", // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata + "2025-06-02T11:00:00.000Z", // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata + "2025-06-02T12:00:00.000Z", // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata + "2025-06-02T13:00:00.000Z", // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata + "2025-06-02T14:00:00.000Z", // 3 PM London, 6 PM Dubai, 7:30 PM Kolkata + ]; + + expect(result).toHaveTimeSlots(expectedUtcSlots, { + dateString: plus2DateString, + doExactMatch: false, + }); + expect(result).toHaveDateDisabled({ dateString: "2025-06-03" }); + }); + + test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => { + await setupTeamAndFeatures(); + vi.setSystemTime("2025-06-01T23:30:00Z"); + + const plus2DateString = "2025-06-02"; // Monday + const plus6DateString = "2025-06-06"; // Friday + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + eventTypes: [ + { + id: 1, + length: 60, + teamId: 1, + restrictionScheduleId: 50, + useBookerTimezone: false, + hosts: [{ userId: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + id: 1, + name: "Test Schedule", + timeZone: "Europe/London", + availability: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + date: null, + }, + ], + }, + ], + teams: [ + { + membership: { role: "ADMIN", accepted: true }, + team: { id: 1, name: "Test Team", slug: "test-team" }, + }, + ], + }, + ], + }; + + scenarioData.users[0].schedules.push({ + id: 50, + name: "Test Schedule", + timeZone: "Asia/Dubai", + availability: [ + { + days: [1, 2, 3, 4, 5], + startTime: new Date("1970-01-01T10:00:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + date: null, + }, + ], + }); + + await createBookingScenario(scenarioData); + + const result = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: "2025-06-01T00:00:00.000Z", + endTime: `${plus6DateString}T23:59:59.999Z`, + timeZone: "Asia/Kolkata", + isTeamEvent: true, + orgSlug: null, + }, + }); + + // Expected slots based on intersection of: + // - User availability: 9 AM - 5 PM London + // - Restriction: 10 AM - 6 PM Dubai, weekdays only + const getExpectedSlotsForDate = (dateString: string) => [ + `${dateString}T08:00:00.000Z`, // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata + `${dateString}T09:00:00.000Z`, // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata + `${dateString}T10:00:00.000Z`, // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata + `${dateString}T11:00:00.000Z`, // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata + `${dateString}T12:00:00.000Z`, // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata + `${dateString}T13:00:00.000Z`, // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata + ]; + + // Verify weekday slots + ["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => { + expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), { + dateString, + doExactMatch: false, + }); + }); + + // Verify weekend is disabled + expect(result).toHaveDateDisabled({ dateString: "2025-06-07" }); + expect(result).toHaveDateDisabled({ dateString: "2025-06-08" }); + }); + + test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=true)", async () => { + await setupTeamAndFeatures(); + vi.setSystemTime("2025-06-01T23:30:00Z"); + + const plus2DateString = "2025-06-02"; // Monday + const plus6DateString = "2025-06-06"; // Friday + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + eventTypes: [ + { + id: 1, + length: 60, + teamId: 1, + restrictionScheduleId: 50, + useBookerTimezone: true, + hosts: [{ userId: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + id: 1, + name: "Test Schedule", + timeZone: "Europe/London", + availability: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + date: null, + }, + ], + }, + ], + teams: [ + { + membership: { role: "ADMIN", accepted: true }, + team: { id: 1, name: "Test Team", slug: "test-team" }, + }, + ], + }, + ], + }; + + scenarioData.users[0].schedules.push({ + id: 50, + name: "Test Schedule", + timeZone: "Asia/Dubai", + availability: [ + { + days: [1, 2, 3, 4, 5], + startTime: new Date("1970-01-01T10:00:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + date: null, + }, + ], + }); + + await createBookingScenario(scenarioData); + + const result = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: "2025-06-01T00:00:00.000Z", + endTime: `${plus6DateString}T23:59:59.999Z`, + timeZone: "Asia/Kolkata", + isTeamEvent: true, + orgSlug: null, + }, + }); + + // Expected slots based on: + // - User availability: 9 AM - 5 PM London (1:30 PM - 9:30 PM Kolkata) + // - Restriction: 10 AM - 6 PM Kolkata + const getExpectedSlotsForDate = (dateString: string) => [ + `${dateString}T08:00:00.000Z`, // 1:30 PM Kolkata, 9 AM London + `${dateString}T09:00:00.000Z`, // 2:30 PM Kolkata, 10 AM London + `${dateString}T10:00:00.000Z`, // 3:30 PM Kolkata, 11 AM London + `${dateString}T11:00:00.000Z`, // 4:30 PM Kolkata, 12 PM London + `${dateString}T12:00:00.000Z`, // 5:30 PM Kolkata, 1 PM London + ]; + + // Verify weekday slots + ["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => { + expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), { + dateString, + doExactMatch: false, + }); + }); + + // Verify weekend is disabled + expect(result).toHaveDateDisabled({ dateString: "2025-06-07" }); + expect(result).toHaveDateDisabled({ dateString: "2025-06-08" }); + }); + + test("should return all slots when no restriction schedule is applied", async () => { + await setupTeamAndFeatures(); + vi.setSystemTime("2025-06-01T23:30:00Z"); + + const plus2DateString = "2025-06-02"; // Monday + const plus6DateString = "2025-06-06"; // Friday + + const scenarioData: ScheduleScenario = { + ...getBaseScenarioData(), + eventTypes: [ + { + id: 1, + length: 60, + teamId: 1, + useBookerTimezone: false, + hosts: [{ userId: 101 }], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + id: 1, + name: "Test Schedule", + timeZone: "Europe/London", + availability: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T17:00:00.000Z"), + date: null, + }, + ], + }, + ], + teams: [ + { + membership: { role: "ADMIN", accepted: true }, + team: { id: 1, name: "Test Team", slug: "test-team" }, + }, + ], + }, + ], + }; + + await createBookingScenario(scenarioData); + + const result = await getSchedule({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: "2025-06-01T00:00:00.000Z", + endTime: `${plus6DateString}T23:59:59.999Z`, + timeZone: "Asia/Kolkata", + isTeamEvent: true, + orgSlug: null, + }, + }); + + // Expected slots based on user's availability: 9 AM - 5 PM London + const getExpectedSlotsForDate = (dateString: string) => [ + `${dateString}T08:00:00.000Z`, // 9 AM London, 1:30 PM Kolkata + `${dateString}T09:00:00.000Z`, // 10 AM London, 2:30 PM Kolkata + `${dateString}T10:00:00.000Z`, // 11 AM London, 3:30 PM Kolkata + `${dateString}T11:00:00.000Z`, // 12 PM London, 4:30 PM Kolkata + `${dateString}T12:00:00.000Z`, // 1 PM London, 5:30 PM Kolkata + `${dateString}T13:00:00.000Z`, // 2 PM London, 6:30 PM Kolkata + `${dateString}T14:00:00.000Z`, // 3 PM London, 7:30 PM Kolkata + `${dateString}T15:00:00.000Z`, // 4 PM London, 8:30 PM Kolkata + `${dateString}T16:00:00.000Z`, // 5 PM London, 9:30 PM Kolkata + ]; + + // Verify weekday slots + ["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => { + expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), { + dateString, + doExactMatch: false, + }); + }); + + // Verify a late evening slot (8:30 PM Kolkata, 4 PM London) is present + const lateEveningSlot = `${plus2DateString}T15:00:00.000Z`; + expect(result.slots[plus2DateString].map((s) => s.time)).toContain(lateEveningSlot); + }); + }); +}); diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 9c9b4a4c6b..0cf671d3c7 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -147,6 +147,8 @@ describe("handleChildrenEventTypes", () => { userId: 4, rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, + useBookerTimezone: false, + restrictionScheduleId: null, allowReschedulingCancelledBookings: false, }, }); @@ -203,6 +205,8 @@ describe("handleChildrenEventTypes", () => { scheduleId: null, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, + useBookerTimezone: false, + restrictionScheduleId: null, hashedLink: { deleteMany: {}, }, @@ -317,6 +321,8 @@ describe("handleChildrenEventTypes", () => { workflows: undefined, rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, + useBookerTimezone: false, + restrictionScheduleId: null, allowReschedulingCancelledBookings: false, }, }); @@ -372,6 +378,7 @@ describe("handleChildrenEventTypes", () => { hashedLink: { deleteMany: {}, }, + useBookerTimezone: false, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, allowReschedulingCancelledBookings: false, @@ -469,6 +476,8 @@ describe("handleChildrenEventTypes", () => { locations: [], lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, + useBookerTimezone: false, + restrictionScheduleId: null, parentId: 1, userId: 5, users: { @@ -493,6 +502,8 @@ describe("handleChildrenEventTypes", () => { customReplyToEmail: null, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, + useBookerTimezone: false, + restrictionScheduleId: null, hashedLink: { deleteMany: {}, }, diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 13384a852c..cdb8b7e9a9 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -213,9 +213,6 @@ export type InputEventType = { userId?: number; minimumBookingNotice?: number; useEventLevelSelectedCalendars?: boolean; - /** - * These user ids are `ScenarioData["users"]["id"]` - */ users?: { id: number }[]; hosts?: InputHost[]; schedulingType?: SchedulingType; @@ -237,6 +234,25 @@ export type InputEventType = { owner?: number; metadata?: any; rescheduleWithSameRoundRobinHost?: boolean; + restrictionSchedule?: { + create: { + name: string; + timeZone: string; + availability: { + createMany: { + data: { + days: number[]; + startTime: Date; + endTime: Date; + date: string | null; + }[]; + }; + }; + user: { connect: { id: number } }; + }; + }; + useBookerTimezone?: boolean; + restrictionScheduleId?: number; } & Partial>; type AttendeeBookingSeatInput = Pick; @@ -310,19 +326,31 @@ export async function addEventTypesToDb( "users" | "workflows" | "destinationCalendar" | "schedule" > & { id?: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any users?: any[]; userId?: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any hosts?: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any workflows?: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any destinationCalendar?: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any schedule?: any; metadata?: any; team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean }; + restrictionSchedule?: { + create: { + name: string; + timeZone: string; + availability: { + createMany: { + data: { + days: number[]; + startTime: Date; + endTime: Date; + date: string | null; + }[]; + }; + }; + user: { connect: { id: number } }; + }; + }; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); @@ -338,11 +366,6 @@ export async function addEventTypesToDb( }, }); - /** - * This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship. - * HACK STARTS - */ - log.silly("Fixed possible prismock bug by creating schedule separately"); for (let i = 0; i < eventTypes.length; i++) { const eventType = eventTypes[i]; const createdEventType = allEventTypes[i]; @@ -350,8 +373,6 @@ export async function addEventTypesToDb( if (eventType.schedule) { log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType)); await prismock.schedule.create({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore data: { ...eventType.schedule.create, eventType: { @@ -363,6 +384,24 @@ export async function addEventTypesToDb( }); } + // Handle restrictionSchedule creation and connection + if (eventType.restrictionSchedule) { + const createdRestrictionSchedule = await prismock.schedule.create({ + data: { + ...eventType.restrictionSchedule.create, + eventType: { + connect: { + id: createdEventType.id, + }, + }, + }, + }); + await prismock.eventType.update({ + where: { id: createdEventType.id }, + data: { restrictionScheduleId: createdRestrictionSchedule.id }, + }); + } + if (eventType.team?.id) { const createdTeam = await prismock.team.create({ data: { @@ -379,9 +418,6 @@ export async function addEventTypesToDb( }); } } - /*** - * HACK ENDS - */ log.silly( "TestData: All EventTypes in DB are", @@ -454,6 +490,22 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In }, } : eventType.schedule, + restrictionScheduleId: eventType.restrictionScheduleId ?? undefined, + useBookerTimezone: eventType.useBookerTimezone ?? false, + restrictionSchedule: eventType.restrictionSchedule + ? { + create: { + name: eventType.restrictionSchedule.create.name, + timeZone: eventType.restrictionSchedule.create.timeZone, + availability: { + createMany: { + data: eventType.restrictionSchedule.create.availability.createMany.data, + }, + }, + user: { connect: { id: eventType.userId || (eventType.users && eventType.users[0]?.id) || 1 } }, + }, + } + : undefined, owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined, schedulingType: eventType.schedulingType, parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined, diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index 432b14da7f..6c9a85b2d5 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -3,6 +3,7 @@ import type { Logger } from "tslog"; import dayjs from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs"; import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; +import { buildDateRanges } from "@calcom/lib/date-ranges"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes"; import { getUsersAvailability } from "@calcom/lib/getUserAvailability"; @@ -11,6 +12,7 @@ import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits" import { getPiiFreeUser } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import prisma from "@calcom/prisma"; import type { getEventTypeResponse } from "./getEventTypesFromDB"; import type { BookingType } from "./originalRescheduledBookingUtils"; @@ -121,6 +123,93 @@ const _ensureAvailableUsers = async ( }, }); + if (eventType.restrictionScheduleId) { + try { + const restrictionSchedule = await prisma.schedule.findUnique({ + where: { id: eventType.restrictionScheduleId }, + select: { + id: true, + timeZone: true, + userId: true, + availability: { + select: { + days: true, + startTime: true, + endTime: true, + date: true, + }, + }, + user: { + select: { + id: true, + defaultScheduleId: true, + travelSchedules: { + select: { + id: true, + timeZone: true, + startDate: true, + endDate: true, + }, + }, + }, + }, + }, + }); + + if (!restrictionSchedule) { + loggerWithEventDetails.error(`Restriction schedule ${eventType.restrictionScheduleId} not found`); + throw new Error(ErrorCode.RestrictionScheduleNotFound); + } + + const restrictionTimezone = eventType.useBookerTimezone + ? input.timeZone + : restrictionSchedule.timeZone!; + + if (!eventType.useBookerTimezone && !restrictionSchedule.timeZone) { + loggerWithEventDetails.error( + `No timezone is set for the restriction schedule and useBookerTimezone is false` + ); + throw new Error(ErrorCode.BookingNotAllowedByRestrictionSchedule); + } + + const restrictionAvailability = restrictionSchedule.availability.map((rule) => ({ + days: rule.days, + startTime: rule.startTime, + endTime: rule.endTime, + date: rule.date, + })); + + const isDefaultSchedule = restrictionSchedule.user.defaultScheduleId === restrictionSchedule.id; + const travelSchedules = + isDefaultSchedule && !eventType.useBookerTimezone + ? restrictionSchedule.user.travelSchedules.map((schedule) => ({ + startDate: dayjs(schedule.startDate), + endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, + timeZone: schedule.timeZone, + })) + : []; + + const { dateRanges: restrictionRanges } = buildDateRanges({ + availability: restrictionAvailability, + timeZone: restrictionTimezone, + dateFrom: startDateTimeUtc, + dateTo: endDateTimeUtc, + travelSchedules, + }); + + if (!hasDateRangeForBooking(restrictionRanges, startDateTimeUtc, endDateTimeUtc)) { + loggerWithEventDetails.error( + `Booking outside restriction schedule availability.`, + piiFreeInputDataForLogging + ); + throw new Error(ErrorCode.BookingNotAllowedByRestrictionSchedule); + } + } catch (error) { + loggerWithEventDetails.error(`Error checking restriction schedule.`, piiFreeInputDataForLogging); + throw error; + } + } + usersAvailability.forEach(({ oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes }, index) => { const user = eventType.users[index]; @@ -161,6 +250,7 @@ const _ensureAvailableUsers = async ( loggerWithEventDetails.error(`No available users found.`, piiFreeInputDataForLogging); throw new Error(ErrorCode.NoAvailableUsersFound); } + // make sure TypeScript understands availableUsers is at least one. return availableUsers.length === 1 ? [availableUsers[0]] : [availableUsers[0], ...availableUsers.slice(1)]; }; diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index bde2a5db13..dc4db93820 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -21,6 +21,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { id: true, customInputs: true, disableGuests: true, + restrictionScheduleId: true, + useBookerTimezone: true, users: { select: { credentials: { diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index 08bb904092..1ae08fb51c 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -160,6 +160,7 @@ export default async function handleChildrenEventTypes({ newUserIds.map((userId) => { return prisma.eventType.create({ data: { + instantMeetingScheduleId: eventType.instantMeetingScheduleId ?? undefined, profileId: profileId ?? null, ...managedEventTypeValues, ...unlockedEventTypeValues, @@ -187,6 +188,8 @@ export default async function handleChildrenEventTypes({ rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, useEventLevelSelectedCalendars: false, + restrictionScheduleId: null, + useBookerTimezone: false, allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false, }, @@ -255,6 +258,8 @@ export default async function handleChildrenEventTypes({ ...updatePayloadFiltered, hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false, scheduleId: eventType.scheduleId || null, + restrictionScheduleId: null, + useBookerTimezone: false, hashedLink: "multiplePrivateLinks" in unlockedFieldProps ? undefined diff --git a/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx b/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx index f8da0fa72d..7ffb5388c3 100644 --- a/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx +++ b/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx @@ -18,11 +18,13 @@ import type { Host, SelectClassNames, } from "@calcom/features/eventtypes/lib/types"; +import CheckboxField from "@calcom/features/form/components/CheckboxField"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { weekdayNames } from "@calcom/lib/weekday"; import { weekStartNum } from "@calcom/lib/weekstart"; import { SchedulingType } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Avatar } from "@calcom/ui/components/avatar"; import { Badge } from "@calcom/ui/components/badge"; @@ -113,6 +115,12 @@ type EventTypeScheduleProps = { eventType: EventTypeSetup; teamMembers: TeamMember[]; customClassNames?: UserAvailabilityCustomClassNames; + fieldName?: "schedule" | "restrictionSchedule"; + scheduleQueryData?: ScheduleQueryData; + restrictionScheduleQueryData?: ScheduleQueryData; + isSchedulePending?: boolean; + isRestrictionSchedulePending?: boolean; + restrictionScheduleRedirectUrl?: string; } & Omit & Omit; @@ -120,7 +128,7 @@ export type EventAvailabilityTabBaserProps = { isTeamEvent: boolean; }; -type UseCommonScheduleSettingsToggle = Omit & { +type UseTeamEventScheduleSettingsToggle = Omit & { customClassNames?: EventAvailabilityTabCustomClassNames; }; @@ -252,10 +260,12 @@ const EventTypeScheduleDetails = memo(
- - - {scheduleQueryData?.timeZone || } - +
+ + + {scheduleQueryData?.timeZone || } + +
{!!scheduleQueryData?.id && !scheduleQueryData.isManaged && !scheduleQueryData.readOnly && @@ -278,11 +288,161 @@ const EventTypeScheduleDetails = memo( EventTypeScheduleDetails.displayName = "EventTypeScheduleDetails"; +type EventTypeRestrictionScheduleDetailsProps = { + scheduleQueryData?: Pick & { + schedule: Array>; + }; + isSchedulePending?: boolean; + user?: Pick; + restrictionScheduleRedirectUrl?: string; + customClassNames?: AvailabilityTableCustomClassNames; + useBookerTimezone?: boolean; +}; + +const EventTypeRestrictionScheduleDetails = memo( + ({ + scheduleQueryData, + isSchedulePending, + user, + restrictionScheduleRedirectUrl, + customClassNames, + useBookerTimezone: initialUseBookerTimezone, + }: EventTypeRestrictionScheduleDetailsProps) => { + const timeFormat = user?.timeFormat; + const { t, i18n } = useLocale(); + const formMethods = useFormContext(); + const { setValue, watch } = formMethods; + + const weekStart = weekStartNum(user?.weekStart); + + const filterDays = (dayNum: number) => + scheduleQueryData?.schedule?.filter((item) => item.days.includes((dayNum + weekStart) % 7)) || []; + + const useBookerTimezone = watch("useBookerTimezone"); + + useEffect(() => { + if (useBookerTimezone === undefined) { + setValue("useBookerTimezone", initialUseBookerTimezone || false, { shouldDirty: false }); + } + }, [useBookerTimezone, setValue, initialUseBookerTimezone]); + + return ( +
+
+
    + {weekdayNames(i18n.language, weekStart, "long").map((day, index) => { + const isAvailable = !!filterDays(index).length; + return ( +
  1. + + {day} + + {isSchedulePending ? ( + + ) : isAvailable ? ( +
    + {filterDays(index).map((dayRange, i) => ( +
    + + {format(dayRange.startTime, timeFormat === 12)} + + + - + +
    + {format(dayRange.endTime, timeFormat === 12)} +
    +
    + ))} +
    + ) : ( + + {t("unavailable")} + + )} +
  2. + ); + })} +
+
+
+
+ + + {scheduleQueryData?.timeZone || } + +
+ { + setValue("useBookerTimezone", e.target.checked, { shouldDirty: true }); + }} + /> +
+
+ {!!scheduleQueryData?.id && + !scheduleQueryData.isManaged && + !scheduleQueryData.readOnly && + !!restrictionScheduleRedirectUrl && ( + + )} +
+
+ ); + } +); + +EventTypeRestrictionScheduleDetails.displayName = "EventTypeRestrictionScheduleDetails"; + const EventTypeSchedule = ({ eventType, schedulesQueryData, isSchedulesPending, customClassNames, + fieldName = "schedule", + scheduleQueryData, + restrictionScheduleQueryData, + isSchedulePending, + isRestrictionSchedulePending, + restrictionScheduleRedirectUrl, ...rest }: EventTypeScheduleProps) => { const { t } = useLocale(); @@ -291,14 +451,21 @@ const EventTypeSchedule = ({ useLockedFieldsManager({ eventType, translate: t, formMethods }); const { watch, setValue } = formMethods; - const scheduleId = watch("schedule"); + // Map the fieldName to the actual form field name + const formFieldName = fieldName === "restrictionSchedule" ? "restrictionScheduleId" : fieldName; + const scheduleId = watch(formFieldName); useEffect(() => { - // after data is loaded. if (schedulesQueryData && scheduleId !== 0 && !scheduleId) { - const newValue = isManagedEventType ? 0 : schedulesQueryData.find((schedule) => schedule.isDefault)?.id; + let newValue; + if (fieldName === "restrictionSchedule") { + newValue = + eventType.restrictionScheduleId || schedulesQueryData.find((schedule) => schedule.isDefault)?.id; + } else { + newValue = isManagedEventType ? 0 : schedulesQueryData.find((schedule) => schedule.isDefault)?.id; + } if (!newValue && newValue !== 0) return; - setValue("schedule", newValue, { + setValue(formFieldName, newValue, { shouldDirty: true, }); } @@ -349,6 +516,29 @@ const EventTypeSchedule = ({ }); } + if ( + fieldName === "restrictionSchedule" && + eventType.restrictionScheduleId && + !schedulesQueryData.find((schedule) => schedule.id === eventType.restrictionScheduleId) + ) { + options.push({ + value: eventType.restrictionScheduleId, + label: eventType.restrictionScheduleName ?? t("default_schedule_name"), + isDefault: false, + isManaged: false, + }); + } + + const filteredOptions = + fieldName === "restrictionSchedule" + ? options.filter((option) => option.value !== eventType.schedule) + : options; + + const currentScheduleQueryData = + fieldName === "schedule" ? scheduleQueryData : restrictionScheduleQueryData; + const isCurrentSchedulePending = + fieldName === "schedule" ? isSchedulePending : isRestrictionSchedulePending; + return (
{t("availability")} - {(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("schedule")} + {(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator(formFieldName)} { - const optionValue: AvailabilityOption | undefined = options.find( + const optionValue: AvailabilityOption | undefined = filteredOptions.find( (option) => option.value === value ); return (