feat: Schedule restriction and booker timezone (#21452)

* init migration and fix name for team event availability toggle

* show and save restriction schedule

* better naming around

* fix loading stored restricted schedule instead of default

* add the booker timezone checkbox in the form

* restriction schedule application babbbyyyy

* remove logs

* test

* typefix

* fix

* fix??

* fix???

* ---

* typefix

* functional fix

* restrictionschedule logic --1

* timezone adjustment for regular constraint

* noice

* test suite for restrictionSchedule

* auth restrictionschedule for eventtype

* remove unnecessary comments

* index

* type fix

* add concurrent

* resolve change request

* fix unauth erro

* type fix

* schedule select extracted from select

* revert

* fix auth vulnerabililty

* fix e22

* chore: hide restriction schedule on platform

* init review feedback resolutions

* fixes

* fix type

* multiple windows of recurring rule-same day

* team feature flag

* fix type err

* fix type

* improvements

* fix test

* improve

* fix error propagation

* fix unused var lint

* cleanup and using buildDateRanges

* travel schedule inclusion

* address comment

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
This commit is contained in:
Syed Ali Shahbaz
2025-06-19 14:08:51 +01:00
committed by GitHub
co-authored by supalarry
parent 3bea03f4b1
commit e160a4add0
28 changed files with 1307 additions and 73 deletions
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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<typeof getScheduleSchema> => ({
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);
});
});
});
@@ -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: {},
},
@@ -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<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
@@ -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,
@@ -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)];
};
@@ -21,6 +21,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
id: true,
customInputs: true,
disableGuests: true,
restrictionScheduleId: true,
useBookerTimezone: true,
users: {
select: {
credentials: {
@@ -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
@@ -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<EventTypeScheduleDetailsProps, "customClassNames"> &
Omit<EventTypeTeamScheduleProps, "customClassNames">;
@@ -120,7 +128,7 @@ export type EventAvailabilityTabBaserProps = {
isTeamEvent: boolean;
};
type UseCommonScheduleSettingsToggle = Omit<EventTypeScheduleProps, "customClassNames"> & {
type UseTeamEventScheduleSettingsToggle = Omit<EventTypeScheduleProps, "customClassNames"> & {
customClassNames?: EventAvailabilityTabCustomClassNames;
};
@@ -252,10 +260,12 @@ const EventTypeScheduleDetails = memo(
</ol>
</div>
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
<div className="flex flex-col gap-2">
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
</div>
{!!scheduleQueryData?.id &&
!scheduleQueryData.isManaged &&
!scheduleQueryData.readOnly &&
@@ -278,11 +288,161 @@ const EventTypeScheduleDetails = memo(
EventTypeScheduleDetails.displayName = "EventTypeScheduleDetails";
type EventTypeRestrictionScheduleDetailsProps = {
scheduleQueryData?: Pick<ScheduleQueryData, "timeZone" | "id" | "isManaged" | "readOnly"> & {
schedule: Array<Pick<ScheduleQueryData["schedule"][number], "days" | "startTime" | "endTime">>;
};
isSchedulePending?: boolean;
user?: Pick<RouterOutputs["viewer"]["me"]["get"], "timeFormat" | "weekStart">;
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<FormValues>();
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 (
<div>
<div className={classNames("border-subtle space-y-4 border-x p-6", customClassNames?.tableContainer)}>
<ol className={classNames("table border-collapse text-sm", customClassNames?.table)}>
{weekdayNames(i18n.language, weekStart, "long").map((day, index) => {
const isAvailable = !!filterDays(index).length;
return (
<li
key={day}
className={classNames(
"my-6 flex border-transparent last:mb-2",
customClassNames?.tableRow
)}>
<span
className={classNames(
"w-20 font-medium sm:w-32 ",
!isAvailable ? "text-subtle line-through" : "text-default",
customClassNames?.day
)}>
{day}
</span>
{isSchedulePending ? (
<SkeletonText className="block h-5 w-60" />
) : isAvailable ? (
<div className="space-y-3 text-right">
{filterDays(index).map((dayRange, i) => (
<div
key={i}
className={classNames(
"text-default flex items-center leading-4",
customClassNames?.dayAvailabilityContainer
)}>
<span
className={classNames(
"w-16 sm:w-28 sm:text-left",
customClassNames?.dayAvailabilityFrom
)}>
{format(dayRange.startTime, timeFormat === 12)}
</span>
<span className={classNames("ms-4", customClassNames?.dayAvailabilitySeperator)}>
-
</span>
<div className={classNames("ml-6 sm:w-28", customClassNames?.dayAvailabilityTo)}>
{format(dayRange.endTime, timeFormat === 12)}
</div>
</div>
))}
</div>
) : (
<span
className={classNames("text-subtle ml-6 sm:ml-0", customClassNames?.dayUnavailable)}>
{t("unavailable")}
</span>
)}
</li>
);
})}
</ol>
</div>
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2">
<span
className={classNames(
"text-default flex items-center justify-center text-sm sm:justify-start",
useBookerTimezone && "text-muted line-through"
)}>
<Icon
name="globe"
className={classNames("h-3.5 w-3.5 ltr:mr-2 rtl:ml-2", useBookerTimezone && "text-muted")}
/>
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
<div className="ltr:mr-2 rtl:ml-2">
<CheckboxField
checked={useBookerTimezone}
disabled={isSchedulePending}
description={t("use_booker_timezone")}
informationIconText={t("use_booker_timezone_info")}
onChange={(e) => {
setValue("useBookerTimezone", e.target.checked, { shouldDirty: true });
}}
/>
</div>
</div>
{!!scheduleQueryData?.id &&
!scheduleQueryData.isManaged &&
!scheduleQueryData.readOnly &&
!!restrictionScheduleRedirectUrl && (
<Button
href={restrictionScheduleRedirectUrl}
disabled={isSchedulePending}
color="minimal"
EndIcon="external-link"
target="_blank"
rel="noopener noreferrer">
{t("edit_availability")}
</Button>
)}
</div>
</div>
);
}
);
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 (
<div>
<div
@@ -363,22 +553,27 @@ const EventTypeSchedule = ({
customClassNames?.availabilitySelect?.label
)}>
{t("availability")}
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("schedule")}
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator(formFieldName)}
</label>
<Controller
name="schedule"
name={formFieldName}
render={({ field: { onChange, value } }) => {
const optionValue: AvailabilityOption | undefined = options.find(
const optionValue: AvailabilityOption | undefined = filteredOptions.find(
(option) => option.value === value
);
return (
<Select
placeholder={t("select")}
options={options}
isDisabled={shouldLockDisableProps("schedule").disabled}
options={filteredOptions}
isDisabled={shouldLockDisableProps(formFieldName).disabled}
isSearchable={false}
onChange={(selected) => {
if (selected) onChange(selected.value);
if (selected) {
onChange(selected.value);
if (fieldName === "restrictionSchedule" && selected.value) {
setValue("restrictionScheduleId", selected.value, { shouldDirty: true });
}
}
}}
className={classNames(
"block w-full min-w-0 flex-1 rounded-sm text-sm",
@@ -393,16 +588,34 @@ const EventTypeSchedule = ({
}}
/>
</div>
{scheduleId !== 0 ? (
<EventTypeScheduleDetails {...rest} customClassNames={customClassNames?.availabilityTable} />
{scheduleId !== 0 && (fieldName === "schedule" || fieldName === "restrictionSchedule") ? (
fieldName === "restrictionSchedule" ? (
<EventTypeRestrictionScheduleDetails
{...rest}
scheduleQueryData={currentScheduleQueryData}
isSchedulePending={isCurrentSchedulePending}
customClassNames={customClassNames?.availabilityTable}
useBookerTimezone={eventType.useBookerTimezone}
restrictionScheduleRedirectUrl={restrictionScheduleRedirectUrl}
/>
) : (
<EventTypeScheduleDetails
{...rest}
scheduleQueryData={currentScheduleQueryData}
isSchedulePending={isCurrentSchedulePending}
customClassNames={customClassNames?.availabilityTable}
/>
)
) : (
isManagedEventType && (
isManagedEventType &&
fieldName === "schedule" && (
<p className="!mt-2 ml-1 text-sm text-gray-600">{t("members_default_schedule_description")}</p>
)
)}
</div>
);
};
const TeamMemberSchedule = ({
host,
index,
@@ -575,35 +788,91 @@ const useCommonScheduleState = (initialScheduleId: number | null) => {
};
};
const UseCommonScheduleSettingsToggle = ({
const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null) => {
const { setValue } = useFormContext<FormValues>();
const [restrictScheduleForHosts, setRestrictScheduleForHosts] = useState(!!initialRestrictionScheduleId);
// Toggle function for restriction schedule
const toggleRestrictScheduleState = (checked: boolean) => {
setRestrictScheduleForHosts(checked);
if (!checked) {
setValue("restrictionScheduleId", null, { shouldDirty: true });
setValue("useBookerTimezone", false, { shouldDirty: true });
}
};
return {
restrictScheduleForHosts,
toggleRestrictScheduleState,
};
};
const UseTeamEventScheduleSettingsToggle = ({
eventType,
customClassNames,
...rest
}: UseCommonScheduleSettingsToggle) => {
}: UseTeamEventScheduleSettingsToggle) => {
const { t } = useLocale();
const isPlatform = useIsPlatform();
const { useHostSchedulesForTeamEvent, toggleScheduleState } = useCommonScheduleState(eventType.schedule);
const { restrictScheduleForHosts, toggleRestrictScheduleState } = useRestrictionScheduleState(
eventType.restrictionScheduleId
);
// Check if team has restriction schedule feature enabled
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
{
teamId: eventType.team?.id || 0,
feature: "restriction-schedule",
},
{
enabled: !!eventType.team?.id,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
}
);
return (
<div className="border-subtle space-y-6 rounded-lg border p-6">
<SettingsToggle
checked={!useHostSchedulesForTeamEvent}
onCheckedChange={toggleScheduleState}
title={t("choose_common_schedule_team_event")}
description={t("choose_common_schedule_team_event_description")}>
{/* handles the state for which 'schedule' ID is set, as it's unknown until the Select dropdown is loaded */}
<EventTypeSchedule
customClassNames={customClassNames?.userAvailability}
eventType={eventType}
{...rest}
/>
</SettingsToggle>
{useHostSchedulesForTeamEvent && (
<div className="lg:ml-14">
<TeamAvailability
teamMembers={rest.teamMembers}
hostSchedulesQuery={rest.hostSchedulesQuery}
customClassNames={customClassNames?.teamAvailability}
<div className="space-y-4">
<div className="border-subtle space-y-6 rounded-lg border p-6">
<SettingsToggle
checked={!useHostSchedulesForTeamEvent}
onCheckedChange={toggleScheduleState}
title={t("choose_common_schedule_team_event")}
description={t("choose_common_schedule_team_event_description")}>
<EventTypeSchedule
customClassNames={customClassNames?.userAvailability}
eventType={eventType}
fieldName="schedule"
{...rest}
/>
</SettingsToggle>
{useHostSchedulesForTeamEvent && (
<div className="lg:ml-14">
<TeamAvailability
teamMembers={rest.teamMembers}
hostSchedulesQuery={rest.hostSchedulesQuery}
customClassNames={customClassNames?.teamAvailability}
/>
</div>
)}
</div>
{!isPlatform && isRestrictionScheduleEnabled ? (
<div className="border-subtle space-y-6 rounded-lg border p-6">
<SettingsToggle
checked={restrictScheduleForHosts}
onCheckedChange={toggleRestrictScheduleState}
title={t("choose_restriction_schedule")}
description={t("choose_restriction_schedule_description")}>
<EventTypeSchedule
customClassNames={customClassNames?.userAvailability}
eventType={eventType}
fieldName="restrictionSchedule"
{...rest}
/>
</SettingsToggle>
</div>
) : (
<></>
)}
</div>
);
@@ -611,7 +880,7 @@ const UseCommonScheduleSettingsToggle = ({
export const EventAvailabilityTab = ({ eventType, isTeamEvent, ...rest }: EventAvailabilityTabProps) => {
return isTeamEvent && eventType.schedulingType !== SchedulingType.MANAGED ? (
<UseCommonScheduleSettingsToggle eventType={eventType} {...rest} />
<UseTeamEventScheduleSettingsToggle eventType={eventType} {...rest} />
) : (
<EventTypeSchedule
eventType={eventType}
@@ -152,6 +152,9 @@ export type FormValues = {
secondaryEmailId?: number;
isRRWeightsEnabled: boolean;
maxLeadThreshold?: number;
restrictionScheduleId: number | null;
useBookerTimezone: boolean;
restrictionScheduleName: string | null;
calVideoSettings?: {
disableRecordingForOrganizer?: boolean;
disableRecordingForGuests?: boolean;
+1
View File
@@ -21,4 +21,5 @@ export type AppFlags = {
"workflow-smtp-emails": boolean;
"cal-video-log-in-overlay": boolean;
"use-api-v2-for-team-slots": boolean;
"restriction-schedule": boolean;
};
+1
View File
@@ -20,6 +20,7 @@ const initialData: AppFlags = {
"workflow-smtp-emails": false,
"cal-video-log-in-overlay": false,
"use-api-v2-for-team-slots": false,
"restriction-schedule": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
+14
View File
@@ -1,3 +1,6 @@
import { z } from "zod";
import type { AppFlags } from "@calcom/features/flags/config";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
@@ -9,5 +12,16 @@ export const featureFlagRouter = router({
const featuresRepository = new FeaturesRepository();
return featuresRepository.getAllFeatures();
}),
checkTeamFeature: publicProcedure
.input(
z.object({
teamId: z.number(),
feature: z.string(),
})
)
.query(async ({ input }) => {
const featuresRepository = new FeaturesRepository();
return featuresRepository.checkIfTeamHasFeature(input.teamId, input.feature as keyof AppFlags);
}),
map,
});
+2
View File
@@ -135,6 +135,8 @@ const commons = {
rrTimestampBasis: null,
interfaceLanguage: null,
customReplyToEmail: null,
restrictionScheduleId: null,
useBookerTimezone: false,
profileId: null,
requiresConfirmationWillBlockSlot: false,
canSendCalVideoTranscriptionEmails: false,
+2
View File
@@ -20,4 +20,6 @@ export enum ErrorCode {
BookingConflict = "booking_conflict_error",
BookerLimitExceeded = "booker_limit_exceeded_error",
BookerLimitExceededReschedule = "booker_limit_exceeded_error_reschedule",
BookingNotAllowedByRestrictionSchedule = "booking_not_allowed_by_restriction_schedule_error",
RestrictionScheduleNotFound = "restriction_schedule_not_found_error",
}
@@ -112,6 +112,9 @@ export const getEventTypeById = async ({
rawEventType.schedule?.id ||
(!rawEventType.team ? rawEventType.users[0]?.defaultScheduleId : null) ||
null,
restrictionScheduleId: rawEventType.restrictionScheduleId || null,
restrictionScheduleName: rawEventType.restrictionSchedule?.name || null,
useBookerTimezone: rawEventType.useBookerTimezone || false,
instantMeetingSchedule: rawEventType.instantMeetingSchedule?.id || null,
scheduleName: rawEventType.schedule?.name || null,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
+15
View File
@@ -0,0 +1,15 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
/**
* Checks if the restriction schedule feature is enabled for a team
* @param teamId - The team ID to check
* @returns Promise<boolean> - True if the feature is enabled, false otherwise
*/
export async function isRestrictionScheduleEnabled(teamId?: number): Promise<boolean> {
if (!teamId) {
return false; // Personal events don't have restriction schedules
}
const featureRepo = new FeaturesRepository();
return await featureRepo.checkIfTeamHasFeature(teamId, "restriction-schedule");
}
+28 -3
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
export const eventTypeSelect = {
id: true,
teamId: true,
schedulingType: true,
@@ -71,4 +71,29 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
maxLeadThreshold: true,
useEventLevelSelectedCalendars: true,
customReplyToEmail: true,
});
restrictionScheduleId: true,
useBookerTimezone: true,
instantMeetingSchedule: {
select: {
id: true,
name: true,
},
},
} satisfies Prisma.EventTypeSelect;
// Create a separate select for schedule-related fields
export const eventTypeScheduleSelect = {
...eventTypeSelect,
schedule: {
select: {
id: true,
name: true,
},
},
restrictionSchedule: {
select: {
id: true,
name: true,
},
},
} satisfies Prisma.EventTypeSelect;
@@ -592,6 +592,8 @@ export class EventTypeRepository {
},
},
},
restrictionScheduleId: true,
useBookerTimezone: true,
users: {
select: userSelect,
},
@@ -608,6 +610,12 @@ export class EventTypeRepository {
name: true,
},
},
restrictionSchedule: {
select: {
id: true,
name: true,
},
},
hosts: {
select: {
isFixed: true,
@@ -842,6 +850,8 @@ export class EventTypeRepository {
maxLeadThreshold: true,
includeNoShowInRRCalculation: true,
useEventLevelSelectedCalendars: true,
restrictionScheduleId: true,
useBookerTimezone: true,
team: {
select: {
id: true,
+32 -1
View File
@@ -1,4 +1,4 @@
import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma";
import { availabilityUserSelect, prisma, type PrismaTransaction, type PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -70,6 +70,37 @@ const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipW
};
export class MembershipRepository {
constructor(private readonly prismaClient: PrismaClient = prisma) {}
async hasMembership({ userId, teamId }: { userId: number; teamId: number }): Promise<boolean> {
const membership = await this.prismaClient.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
},
select: {
id: true,
},
});
return !!membership;
}
async listAcceptedTeamMemberIds({ teamId }: { teamId: number }): Promise<number[]> {
const memberships =
(await this.prismaClient.membership.findMany({
where: {
teamId,
accepted: true,
},
select: {
userId: true,
},
})) || [];
const teamMemberIds = memberships.map((membership) => membership.userId);
return teamMemberIds;
}
static async create(data: IMembership) {
return await prisma.membership.create({
data: {
@@ -1,3 +1,4 @@
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
@@ -13,6 +14,56 @@ export type FindDetailedScheduleByIdReturnType = Awaited<
>;
export class ScheduleRepository {
// when instantiating, prismaClient injection is required
constructor(private prismaClient: PrismaClient) {}
async findScheduleByIdForBuildDateRanges({ scheduleId }: { scheduleId: number }) {
const schedule = await this.prismaClient.schedule.findUnique({
where: { id: scheduleId },
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,
},
},
},
},
},
});
return schedule;
}
async findScheduleByIdForOwnershipCheck({ scheduleId }: { scheduleId: number }) {
const schedule = await this.prismaClient.schedule.findUnique({
where: {
id: scheduleId,
},
select: {
userId: true,
},
});
return schedule;
}
static async findScheduleById({ id }: { id: number }) {
const schedule = await prisma.schedule.findUnique({
where: {
+2
View File
@@ -157,6 +157,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
allowReschedulingPastBookings: false,
hideOrganizerEmail: false,
customReplyToEmail: null,
restrictionScheduleId: null,
useBookerTimezone: false,
...eventType,
};
};
@@ -22,6 +22,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const scheduleId = formMethods.watch("schedule");
const restrictionScheduleId = formMethods.watch("restrictionScheduleId");
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
eventType: props.eventType,
@@ -39,6 +40,15 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
{ enabled: !!scheduleId || (!props.isTeamEvent && !!props.user?.defaultScheduleId) }
);
const { isPending: isRestrictionSchedulePending, data: restrictionScheduleQueryData } =
trpc.viewer.availability.schedule.get.useQuery(
{
scheduleId: restrictionScheduleId || undefined,
isManagedEventType: isManagedEventType || isChildrenManagedEventType,
},
{ enabled: !!restrictionScheduleId }
);
const { data: schedulesQueryData, isPending: isSchedulesPending } =
trpc.viewer.availability.list.useQuery(undefined);
@@ -51,7 +61,10 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
isSchedulesPending={isSchedulesPending}
isSchedulePending={isSchedulePending}
scheduleQueryData={scheduleQueryData}
restrictionScheduleQueryData={restrictionScheduleQueryData}
isRestrictionSchedulePending={isRestrictionSchedulePending}
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
hostSchedulesQuery={hostSchedulesQuery}
/>
);
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "restrictionScheduleId" INTEGER,
ADD COLUMN "useBookerTimezone" BOOLEAN NOT NULL DEFAULT false;
-- AddForeignKey
ALTER TABLE "EventType" ADD CONSTRAINT "EventType_restrictionScheduleId_fkey" FOREIGN KEY ("restrictionScheduleId") REFERENCES "Schedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "Feature" (slug, enabled) VALUES ('restriction-schedule', false) ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "EventType_restrictionScheduleId_idx" ON "EventType"("restrictionScheduleId");
+6
View File
@@ -203,6 +203,10 @@ model EventType {
secondaryEmailId Int?
secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade)
useBookerTimezone Boolean @default(false)
restrictionScheduleId Int?
restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id])
@@unique([userId, slug])
@@unique([teamId, slug])
@@unique([userId, parentId])
@@ -212,6 +216,7 @@ model EventType {
@@index([scheduleId])
@@index([secondaryEmailId])
@@index([parentId])
@@index([restrictionScheduleId])
}
model Credential {
@@ -813,6 +818,7 @@ model Schedule {
userId Int
eventType EventType[]
instantMeetingEvents EventType[] @relation("InstantMeetingSchedule")
restrictionSchedule EventType[] @relation("restrictionSchedule")
name String
timeZone String?
availability Availability[]
@@ -94,6 +94,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
descriptionAsSafeHTML: _descriptionAsSafeHTML,
secondaryEmailId,
instantMeetingScheduleId: _instantMeetingScheduleId,
restrictionScheduleId: _restrictionScheduleId,
...rest
} = eventType;
@@ -113,7 +114,13 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
},
}
: undefined,
restrictionSchedule: _restrictionScheduleId
? {
connect: {
id: _restrictionScheduleId,
},
}
: undefined,
recurringEvent: recurringEvent || undefined,
bookingLimits: bookingLimits ?? undefined,
durationLimits: durationLimits ?? undefined,
@@ -13,6 +13,8 @@ import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateI
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CalVideoSettingsRepository } from "@calcom/lib/server/repository/calVideoSettings";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ScheduleRepository } from "@calcom/lib/server/repository/schedule";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
@@ -87,6 +89,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
description: newDescription,
title: newTitle,
seatsPerTimeSlot,
restrictionScheduleId,
calVideoSettings,
...rest
} = input;
@@ -340,6 +343,45 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
};
}
const membershipRepo = new MembershipRepository(ctx.prisma);
if (restrictionScheduleId) {
// Verify that the user owns the restriction schedule or is a team member
const scheduleRepo = new ScheduleRepository(ctx.prisma);
const restrictionSchedule = await scheduleRepo.findScheduleByIdForOwnershipCheck({
scheduleId: restrictionScheduleId,
});
// If the user doesn't own the schedule, check if they're a team member
if (restrictionSchedule?.userId !== ctx.user.id) {
if (!teamId || !restrictionSchedule) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "The restriction schedule is not owned by you or your team",
});
}
const hasMembership = await membershipRepo.hasMembership({
teamId,
userId: restrictionSchedule.userId,
});
if (!hasMembership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "The restriction schedule is not owned by you or your team",
});
}
}
data.restrictionSchedule = {
connect: {
id: restrictionScheduleId,
},
};
} else if (restrictionScheduleId === null || restrictionScheduleId === 0) {
data.restrictionSchedule = {
disconnect: true,
};
}
if (users?.length) {
data.users = {
set: [],
@@ -349,14 +391,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
if (teamId && hosts) {
// check if all hosts can be assigned (memberships that have accepted invite)
const memberships =
(await ctx.prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
})) || [];
const teamMemberIds = memberships.map((membership) => membership.userId);
const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId });
// guard against missing IDs, this may mean a member has just been removed
// or this request was forged.
// we let this pass through on organization sub-teams
@@ -11,6 +11,7 @@ import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShou
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
import { buildDateRanges } from "@calcom/lib/date-ranges";
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { getAggregatedAvailability } from "@calcom/lib/getAggregatedAvailability";
@@ -36,6 +37,7 @@ import {
isTimeViolatingFutureLimit,
} from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { isRestrictionScheduleEnabled } from "@calcom/lib/restrictionSchedule";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
@@ -497,6 +499,94 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
let availableTimeSlots: typeof timeSlots = [];
const bookerClientUid = ctx?.req?.cookies?.uid;
const isRestrictionScheduleFeatureEnabled = await isRestrictionScheduleEnabled(eventType.team?.id);
if (eventType.restrictionScheduleId && isRestrictionScheduleFeatureEnabled) {
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) {
if (!eventType.useBookerTimezone && !restrictionSchedule.timeZone) {
throw new TRPCError({
message: "No timezone is set for the restricted schedule",
code: "BAD_REQUEST",
});
}
const restrictionTimezone = eventType.useBookerTimezone
? input.timeZone
: restrictionSchedule.timeZone!;
const eventLength = input.duration || eventType.length;
const restrictionAvailability = restrictionSchedule.availability.map((rule) => ({
days: rule.days,
startTime: rule.startTime,
endTime: rule.endTime,
date: rule.date,
}));
// Include travel schedules if restriction schedule is the user's default schedule
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 || "UTC",
dateFrom: startTime,
dateTo: endTime,
travelSchedules,
});
availableTimeSlots = timeSlots.filter((slot) => {
const slotStart = slot.time;
const slotEnd = slot.time.add(eventLength, "minute");
return restrictionRanges.some(
(range) =>
(slotStart.isAfter(range.start) || slotStart.isSame(range.start)) &&
(slotEnd.isBefore(range.end) || slotEnd.isSame(range.end))
);
});
} else {
availableTimeSlots = timeSlots;
}
} else {
availableTimeSlots = timeSlots;
}
const reservedSlots = await _getReservedSlotsAndCleanupExpired({
bookerClientUid,
@@ -504,8 +594,6 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
usersWithCredentials,
});
availableTimeSlots = timeSlots;
const availabilityCheckProps = {
eventLength: input.duration || eventType.length,
currentSeats,