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:
co-authored by
supalarry
parent
3bea03f4b1
commit
e160a4add0
@@ -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
|
||||
|
||||
+309
-40
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -135,6 +135,8 @@ const commons = {
|
||||
rrTimestampBasis: null,
|
||||
interfaceLanguage: null,
|
||||
customReplyToEmail: null,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
profileId: null,
|
||||
requiresConfirmationWillBlockSlot: false,
|
||||
canSendCalVideoTranscriptionEmails: false,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventType_restrictionScheduleId_idx" ON "EventType"("restrictionScheduleId");
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user