Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: global booking limits for teams #16614

Merged
merged 48 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3a3330c
add booking booking to team settings
Sep 9, 2024
694447f
add update mutation
Sep 9, 2024
5a786fb
add missing export
Sep 9, 2024
a9cbeb4
fix dirty state
Sep 10, 2024
e9ea093
first version of global team limits in getUserAvailability
Sep 11, 2024
ae8ac75
add test setup
Sep 12, 2024
899fcef
Merge branch 'main' into fix/global-booking-limits
Sep 12, 2024
b781c9f
create seperate test file for global limits
Sep 12, 2024
6a457f3
add tests for all units
Sep 12, 2024
5e233ee
move limitManager and booking limit functions outside of getUserAvail…
Sep 12, 2024
5775af5
add migration
Sep 12, 2024
3315380
clean up code
Sep 12, 2024
679191e
move yearly booking count to booking repository
Sep 12, 2024
f7657a0
code clean up
Sep 12, 2024
b6a9165
don't count rescheduling booking
Sep 12, 2024
a83adce
add test for getSchedule
Sep 12, 2024
df750c7
fix type error
Sep 12, 2024
9ebbdc9
fix type error
Sep 12, 2024
a82f667
fix type error
Sep 13, 2024
0195d9a
fix from and end date for fetching bookings
Sep 13, 2024
f3ff147
Merge branch 'main' into fix/global-booking-limits
CarinaWolli Sep 16, 2024
e1fb61b
reuse functions
Sep 16, 2024
54d657a
allow null for bookingLimits
Sep 16, 2024
b6b8022
remove bookings from managed event type
Sep 16, 2024
8ad7270
fix type error
Sep 16, 2024
ad8c9d5
code clean up
Sep 17, 2024
9f7989b
small fixes form clean up
Sep 17, 2024
4f1651a
Merge branch 'main' into fix/global-booking-limits
CarinaWolli Sep 17, 2024
7341ee1
fix type issue
Sep 17, 2024
5d4b3ac
same fixes in teams/_post
Sep 17, 2024
b6e6210
Merge branch 'main' into fix/global-booking-limits
CarinaWolli Sep 17, 2024
8c351c7
fix existing tz issue
Sep 17, 2024
e798a45
tests for fix
Sep 17, 2024
e025f51
adds missingn await
Sep 17, 2024
c3a7d5b
imrove description
Sep 17, 2024
89d578b
remove spreading
Sep 17, 2024
3400b03
fix reschedule issue with booking limits
Sep 18, 2024
fb7d7e3
fix reschedule error with booking durations
Sep 18, 2024
413dcba
remove useeffect
Sep 18, 2024
62926c9
undo commit
Sep 18, 2024
f7de41a
add bookingLimits to UpdateOrgTeamDto
Sep 18, 2024
910fff7
Merge branch 'main' into fix/global-booking-limits
Sep 20, 2024
1f184f3
Merge branch 'main' into fix/global-booking-limits
Sep 23, 2024
1e86cad
fix unit tests
Sep 23, 2024
8c337cd
Prepare view for app router migration
joeauyeung Sep 23, 2024
014ba85
throw error if not in ascending order
Sep 23, 2024
ab66400
fix disabled update button
Sep 23, 2024
9be243e
Merge branch 'main' into fix/global-booking-limits
joeauyeung Sep 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/v1/lib/validations/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
isPlatform: true,
smsLockState: true,
smsLockReviewedByAdmin: true,
bookingLimits: true,
});

const schemaTeamRequiredParams = z.object({
Expand Down
2 changes: 2 additions & 0 deletions apps/api/v1/pages/api/teams/[teamId]/_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ export async function patchHandler(req: NextApiRequest) {
// TODO: Perhaps there is a better fix for this?
const cloneData: typeof data & {
metadata: NonNullable<typeof data.metadata> | undefined;
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
} = {
...data,
smsLockReviewedByAdmin: false,
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
Expand Down
2 changes: 2 additions & 0 deletions apps/api/v1/pages/api/teams/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ async function postHandler(req: NextApiRequest) {
// TODO: Perhaps there is a better fix for this?
const cloneData: typeof data & {
metadata: NonNullable<typeof data.metadata> | undefined;
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
} = {
...data,
smsLockReviewedByAdmin: false,
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits || undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're letting users set the booking limits, via the API we should update the swagger docs to reflect that.

metadata: data.metadata === null ? {} : data.metadata || undefined,
};

Expand Down
9 changes: 9 additions & 0 deletions apps/web/pages/settings/teams/[id]/bookingLimits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import TeamBookingLimitsView from "@calcom/features/ee/teams/pages/team-booking-limits-view";

import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";

const Page = TeamBookingLimitsView as CalPageWrapper;
Page.PageWrapper = PageWrapper;

export default Page;
4 changes: 4 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2607,6 +2607,10 @@
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
"booking_limits": "Booking Limits",
"booking_limits_team_description": "Booking limits for team members across all team event types",
"limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types (collective and round-robin)",
"booking_limits_updated_successfully": "Booking limits updated successfully",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
323 changes: 322 additions & 1 deletion apps/web/test/lib/getSchedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { describe, vi, test } from "vitest";

import dayjs from "@calcom/dayjs";
import type { BookingStatus } from "@calcom/prisma/enums";
import { SchedulingType, type BookingStatus } from "@calcom/prisma/enums";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";

import { expect } from "./getSchedule/expects";
Expand Down Expand Up @@ -1151,6 +1151,327 @@ describe("getSchedule", () => {
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that
});

test("test that booking limit is working correctly if user is all day available and attendee is in different timezone than host", async () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });

const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
bookingLimits: {
PER_DAY: 1,
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
// One bookings for each(E1 and E2) on plus2Date
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};

await createBookingScenario(scenarioData);

const thisUserAvailabilityBookingLimit = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["-11:00"], //attendee timezone
isTeamEvent: false,
orgSlug: null,
},
});

const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in thisUserAvailabilityBookingLimit.slots) {
thisUserAvailabilityBookingLimit.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}

expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit
});

test("global team booking limit block slot if one fixed host reached limit", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });

const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
bookingLimits: { PER_DAY: 1 },
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 2,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
bookingLimits: { PER_DAY: 1 },
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 3,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};

await createBookingScenario(scenarioData);

const availabilityEventTypeOne = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});

const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in availabilityEventTypeOne.slots) {
availabilityEventTypeOne.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}

expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit

const availabilityEventTypeTwo = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});

for (const date in availabilityEventTypeTwo.slots) {
availabilityEventTypeTwo.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}

expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit

const availabilityUserEventType = await getSchedule({
input: {
eventTypeId: 3,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});

for (const date in availabilityUserEventType.slots) {
availabilityUserEventType.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}

expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23);
});

test("global team booking limit blocks correct slots if attendee and host are in different timezone", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });

const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
bookingLimits: { PER_DAY: 1 },
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};

await createBookingScenario(scenarioData);

const thisUserAvailabilityBookingLimit = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["-11:00"], // attendee timezone
isTeamEvent: false,
orgSlug: null,
},
});

const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in thisUserAvailabilityBookingLimit.slots) {
thisUserAvailabilityBookingLimit.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}

expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0); // 1 booking per day as limit
});

test("a slot counts as being busy when the eventType is requiresConfirmation and requiresConfirmationWillBlockSlot", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
Expand Down
Loading
Loading