From bb9d99c3e1a1f5188661639af50deeb62dc8d32c Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Wed, 15 Oct 2025 16:31:35 +0200 Subject: [PATCH] feat: api v2 event type settings - booker bookings limit, emails, round robin reschedule (#24169) * feat: event type bookerActiveBookingsLimit * test: booking with bookerActiveBookingsLimit * fix: typecheck * feat: org team event type emailSettings & rescheduleWithSameRoundRobinHost * docs: regenerate swagger * chore: default booker name and email in examples app * refactor: email settings description * refactor: rename email setting properties * refactor: function to add email settings to metadata * fix: try to fix e2e * fix: dont return input bookerActiveBookingsLimit in transformed object * fix: dont allow recurrence and bookerActiveBookingsLimit * fix: output service return bookerActiveBookingsLimit --- .../controllers/e2e/user-bookings.e2e-spec.ts | 151 +++++++++++++- .../2024-08-13/services/errors.service.ts | 13 ++ .../event-types.controller.e2e-spec.ts | 191 +++++++++++++++++- .../services/input-event-types.service.ts | 90 ++++++--- .../services/output-event-types.service.ts | 28 +++ .../transformed/event-type.tranformed.ts | 2 + ...-member-team-admin-event-types.e2e-spec.ts | 13 ++ .../event-types/services/input.service.ts | 73 +++++-- .../event-types/services/output.service.ts | 30 ++- docs/api-reference/v2/openapi.json | 113 +++++++++++ .../examples/base/src/pages/booking.tsx | 2 +- packages/platform/libraries/event-types.ts | 2 + .../booker-active-booking-limit.input.ts | 25 +++ .../inputs/create-event-type.input.ts | 54 ++++- .../inputs/email-settings.input.ts | 20 ++ .../event-types_2024_06_14/inputs/index.ts | 1 + .../inputs/update-event-type.input.ts | 63 +++++- ...eRecurrenceAndBookerActiveBookingsLimit.ts | 40 ++++ .../booker-active-bookings-limit.output.ts | 21 ++ .../outputs/event-type.output.ts | 26 ++- .../RequiresOneOfPropertiesWhenNotDisabled.ts | 49 +++++ 21 files changed, 938 insertions(+), 69 deletions(-) create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/inputs/booker-active-booking-limit.input.ts create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/inputs/email-settings.input.ts create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit.ts create mode 100644 packages/platform/types/event-types/event-types_2024_06_14/outputs/booker-active-bookings-limit.output.ts create mode 100644 packages/platform/types/utils/RequiresOneOfPropertiesWhenNotDisabled.ts diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts index 4cc21e3b59..dc57ee99c0 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts @@ -2494,7 +2494,7 @@ describe("Bookings Endpoints 2024-08-13", () => { .expect(400); expect(response.body.error.message).toEqual( - `Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.` + `Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.` ); }); @@ -3026,13 +3026,154 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); }); + + describe("event type with max bookings count per booker", () => { + it("should not allow booking more than maximumActiveBookings per attendee", async () => { + const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create( + { + slug: `max-bookings-count-per-booker-${randomString(10)}`, + length: 60, + title: "Event Type with max bookings count per booker", + maxActiveBookingsPerBooker: 1, + }, + user.id + ); + + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 0, 13, 10, 0, 0)).toISOString(), + eventTypeId: eventTypeIdWithMaxBookerBookings.id, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + const response2 = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + + expect(response2.body.error.message).toBe( + "Attendee with this email can't book because the maximum number of active bookings has been reached." + ); + + const body2: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 0, 13, 12, 0, 0)).toISOString(), + eventTypeId: eventTypeIdWithMaxBookerBookings.id, + attendee: { + name: "bob", + email: "bob@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + + const response3 = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body2) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody2: CreateBookingOutput_2024_08_13 = response3.body; + expect(responseBody2.status).toEqual(SUCCESS_STATUS); + expect(responseBody2.data).toBeDefined(); + expect(responseDataIsBooking(responseBody2.data)).toBe(true); + + if (responseDataIsBooking(responseBody2.data)) { + const data: BookingOutput_2024_08_13 = responseBody2.data; + expect(data.id).toBeDefined(); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + + it("should not allow booking more than maximumActiveBookings per attendee and offer rescheduling", async () => { + const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create( + { + slug: `max-bookings-count-per-booker-with-reschedule-${randomString(10)}`, + length: 60, + title: "Event Type with max bookings count per booker with reschedule", + maxActiveBookingsPerBooker: 1, + maxActiveBookingPerBookerOfferReschedule: true, + }, + user.id + ); + + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2040, 0, 13, 14, 0, 0)).toISOString(), + eventTypeId: eventTypeIdWithMaxBookerBookings.id, + attendee: { + name: "charlie", + email: "charlie@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + const response2 = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + + expect(response2.body.error.message).toBe( + `Attendee with this email can't book because the maximum number of active bookings has been reached. You can reschedule your existing booking (${responseBody.data.uid}) to a new timeslot instead.` + ); + }); + }); }); }); - function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { + function responseDataIsRecurranceBooking(data: unknown): data is RecurringBookingOutput_2024_08_13 { return ( !Array.isArray(data) && typeof data === "object" && + data !== null && data && "id" in data && "recurringBookingUid" in data @@ -3048,11 +3189,11 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); - function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { - return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data; } - function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + function responseDataIsRecurringBooking(data: unknown): data is RecurringBookingOutput_2024_08_13[] { return Array.isArray(data); } }); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts index 0563358baf..beeadd6395 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts @@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 { throw new BadRequestException("Attempting to book a meeting in the past."); } else if (error.message === "hosts_unavailable_for_booking") { throw new BadRequestException(hostsUnavaile); + } else if (error.message === "booker_limit_exceeded_error") { + throw new BadRequestException( + "Attendee with this email can't book because the maximum number of active bookings has been reached." + ); + } else if (error.message === "booker_limit_exceeded_error_reschedule") { + const errorData = + "data" in error ? (error.data as { rescheduleUid: string }) : { rescheduleUid: undefined }; + let message = + "Attendee with this email can't book because the maximum number of active bookings has been reached."; + if (errorData?.rescheduleUid) { + message += ` You can reschedule your existing booking (${errorData.rescheduleUid}) to a new timeslot instead.`; + } + throw new BadRequestException(message); } } throw error; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts index 1e4f5e2509..8ac36da0d5 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -31,16 +31,18 @@ import { FrequencyInput, } from "@calcom/platform-enums"; import { SchedulingType } from "@calcom/platform-libraries"; -import type { - ApiSuccessResponse, - CreateEventTypeInput_2024_06_14, - EventTypeOutput_2024_06_14, - GuestsDefaultFieldOutput_2024_06_14, - NameDefaultFieldInput_2024_06_14, - NotesDefaultFieldInput_2024_06_14, - SplitNameDefaultFieldOutput_2024_06_14, - UpdateEventTypeInput_2024_06_14, +import { + type ApiSuccessResponse, + type CreateEventTypeInput_2024_06_14, + type EventTypeOutput_2024_06_14, + type GuestsDefaultFieldOutput_2024_06_14, + type NameDefaultFieldInput_2024_06_14, + type NotesDefaultFieldInput_2024_06_14, + type SplitNameDefaultFieldOutput_2024_06_14, + type UpdateEventTypeInput_2024_06_14, } from "@calcom/platform-types"; +import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit"; +import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled"; import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client"; const orderBySlug = (a: { slug: string }, b: { slug: string }) => { @@ -1442,6 +1444,177 @@ describe("Event types Endpoints", () => { .expect(200); }); + describe("bookerActiveBookingsLimit", () => { + describe("negative tests", () => { + it("should not create an event type with bookerActiveBookingsLimit and recurrence", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit", + slug: "coding-class-booker-active-bookings-limit", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + maximumActiveBookings: 2, + offerReschedule: true, + }, + recurrence: { + frequency: FrequencyInput.weekly, + interval: 2, + occurrences: 10, + }, + }; + + const response = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(400); + + expect( + response.body.error.message.includes(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE) + ).toBe(true); + }); + + it("should not allow creating an event type with bookerActiveBookingsLimit disabled:false", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit disabled:false", + slug: "coding-class-booker-active-bookings-limit-disabled", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + // note(Lauris): disabled false means that it is enabled so it should have maximumActiveBookings and / or offerReschedule provided + bookerActiveBookingsLimit: { + disabled: false, + }, + }; + + const response = await request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(400); + + expect(response.body.error.message.includes(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR)).toBe(true); + }); + }); + + describe("positive tests", () => { + let eventTypeWithBookerActiveBookingsLimitId: number; + + it("should create an event type with bookerActiveBookingsLimit", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit", + slug: "coding-class-booker-active-bookings-limit", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + maximumActiveBookings: 2, + offerReschedule: true, + }, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = createdEventType.id; + }); + }); + + it("should update an event type with bookerActiveBookingsLimit", async () => { + const body: UpdateEventTypeInput_2024_06_14 = { + bookerActiveBookingsLimit: { + disabled: true, + }, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/event-types/${eventTypeWithBookerActiveBookingsLimitId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const updatedEventType = responseBody.data; + expect(updatedEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = updatedEventType.id; + }); + }); + + it("should create an event type with bookerActiveBookingsLimit and recurrence disabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit and recurrence disabled", + slug: "coding-class-booker-active-bookings-limit-recurrence-disabled", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + maximumActiveBookings: 2, + offerReschedule: true, + }, + recurrence: { + disabled: true, + }, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = createdEventType.id; + }); + }); + + it("should create an event type with recurrence and bookerActiveBookingsLimit disabled", async () => { + const body: CreateEventTypeInput_2024_06_14 = { + title: "Coding class with bookerActiveBookingsLimit disabled and recurrence", + slug: "coding-class-booker-active-bookings-limit-disabled-and-recurrence", + description: "Let's learn how to code like a pro.", + lengthInMinutes: 60, + bookerActiveBookingsLimit: { + disabled: true, + }, + recurrence: { + frequency: FrequencyInput.weekly, + interval: 2, + occurrences: 10, + }, + }; + + return request(app.getHttpServer()) + .post("/api/v2/event-types") + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .set("Authorization", `Bearer ${apiKeyString}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + const createdEventType = responseBody.data; + expect(createdEventType).toHaveProperty("id"); + expect(createdEventType.title).toEqual(body.title); + expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit); + eventTypeWithBookerActiveBookingsLimitId = createdEventType.id; + }); + }); + }); + }); + afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts index c05e7c765b..c904901e89 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts @@ -28,7 +28,11 @@ import { UserWithProfile } from "@/modules/users/users.repository"; import { Injectable, BadRequestException } from "@nestjs/common"; import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store"; -import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types"; +import { + validateCustomEventName, + EventTypeMetaDataSchema, + EventTypeMetadata, +} from "@calcom/platform-libraries/event-types"; import { CreateEventTypeInput_2024_06_14, DestinationCalendar_2024_06_14, @@ -67,11 +71,13 @@ export class InputEventTypesService_2024_06_14 { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar)); + if (transformedBody.destinationCalendar) { + await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.validateInputUseDestinationCalendarEmail(user.id)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.validateInputUseDestinationCalendarEmail(user.id); + } return transformedBody; } @@ -93,11 +99,13 @@ export class InputEventTypesService_2024_06_14 { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar)); + if (transformedBody.destinationCalendar) { + await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.validateInputUseDestinationCalendarEmail(user.id)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.validateInputUseDestinationCalendarEmail(user.id); + } return transformedBody; } @@ -119,6 +127,7 @@ export class InputEventTypesService_2024_06_14 { customName, useDestinationCalendarEmail, disableGuests, + bookerActiveBookingsLimit, ...rest } = inputEventType; const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy); @@ -130,6 +139,17 @@ export class InputEventTypesService_2024_06_14 { ? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests) : bookingFields; + const maxActiveBookingsPerBooker = bookerActiveBookingsLimit + ? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit) + : {}; + + const metadata: EventTypeMetadata = { + bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), + requiresConfirmationThreshold: + confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, + multipleDuration: lengthInMinutesOptions, + }; + const eventType = { ...rest, length: lengthInMinutes, @@ -140,12 +160,7 @@ export class InputEventTypesService_2024_06_14 { ? this.transformInputIntervalLimits(bookingLimitsDuration) : undefined, ...this.transformInputBookingWindow(bookingWindow), - metadata: { - bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), - requiresConfirmationThreshold: - confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, - multipleDuration: lengthInMinutesOptions, - }, + metadata, requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined, requiresConfirmationWillBlockSlot: confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined, @@ -154,11 +169,27 @@ export class InputEventTypesService_2024_06_14 { ...this.transformInputSeatOptions(seats), eventName: customName, useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail, + ...maxActiveBookingsPerBooker, }; return eventType; } + transformInputBookerActiveBookingsLimit( + bookerActiveBookingsLimit: CreateEventTypeInput_2024_06_14["bookerActiveBookingsLimit"] + ) { + if (!bookerActiveBookingsLimit || bookerActiveBookingsLimit.disabled) { + return { + maxActiveBookingsPerBooker: null, + maxActiveBookingPerBookerOfferReschedule: false, + }; + } + return { + maxActiveBookingsPerBooker: bookerActiveBookingsLimit?.maximumActiveBookings, + maxActiveBookingPerBookerOfferReschedule: bookerActiveBookingsLimit?.offerReschedule, + }; + } + async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) { const { lengthInMinutes, @@ -176,10 +207,11 @@ export class InputEventTypesService_2024_06_14 { customName, useDestinationCalendarEmail, disableGuests, + bookerActiveBookingsLimit, ...rest } = inputEventType; const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId); - const metadataTransformed = !!eventTypeDb?.metadata + const metadataTransformed = eventTypeDb?.metadata ? EventTypeMetaDataSchema.parse(eventTypeDb.metadata) : {}; @@ -190,6 +222,18 @@ export class InputEventTypesService_2024_06_14 { ? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests) : bookingFields; + const maxActiveBookingsPerBooker = bookerActiveBookingsLimit + ? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit) + : {}; + + const metadata: EventTypeMetadata = { + ...metadataTransformed, + bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), + requiresConfirmationThreshold: + confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, + multipleDuration: lengthInMinutesOptions, + }; + const eventType = { ...rest, length: lengthInMinutes, @@ -202,13 +246,7 @@ export class InputEventTypesService_2024_06_14 { ? this.transformInputIntervalLimits(bookingLimitsDuration) : undefined, ...this.transformInputBookingWindow(bookingWindow), - metadata: { - ...metadataTransformed, - bookerLayouts: this.transformInputBookerLayouts(bookerLayouts), - requiresConfirmationThreshold: - confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined, - multipleDuration: lengthInMinutesOptions, - }, + metadata, recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined, requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined, requiresConfirmationWillBlockSlot: @@ -217,6 +255,7 @@ export class InputEventTypesService_2024_06_14 { ...this.transformInputSeatOptions(seats), eventName: customName, useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail, + ...maxActiveBookingsPerBooker, }; return eventType; @@ -319,7 +358,7 @@ export class InputEventTypesService_2024_06_14 { transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) { const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow); - return !!res ? res : {}; + return res ? res : {}; } transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) { @@ -369,7 +408,7 @@ export class InputEventTypesService_2024_06_14 { requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false; } - const seatsPerTimeSlotFinal = !!seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb; + const seatsPerTimeSlotFinal = seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb; const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0; const locationsFinal = locations !== undefined ? locations : locationsDb; @@ -394,6 +433,7 @@ export class InputEventTypesService_2024_06_14 { ); } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformLocations(locations: any) { if (!locations) return []; diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts index b0db6cc1ec..d53d14e4e0 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -92,6 +92,8 @@ type Input = Pick< | "calVideoSettings" | "hidden" | "bookingRequiresAuthentication" + | "maxActiveBookingsPerBooker" + | "maxActiveBookingPerBookerOfferReschedule" >; @Injectable() @@ -165,6 +167,7 @@ export class OutputEventTypesService_2024_06_14 { periodEndDate: databaseEventType.periodEndDate, } as TransformFutureBookingsLimitSchema_2024_06_14); const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar); + const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType); return { id, @@ -210,9 +213,29 @@ export class OutputEventTypesService_2024_06_14 { calVideoSettings, hidden, bookingRequiresAuthentication, + bookerActiveBookingsLimit, }; } + transformBookerActiveBookingsLimit(databaseEventType: Input) { + const noMaxActiveBookingsPerBooker = + !databaseEventType.maxActiveBookingsPerBooker && databaseEventType.maxActiveBookingsPerBooker !== 0; + const noMaxActiveBookingPerBookerOfferReschedule = + !databaseEventType.maxActiveBookingPerBookerOfferReschedule; + + if (noMaxActiveBookingsPerBooker && noMaxActiveBookingPerBookerOfferReschedule) { + return { + disabled: true, + }; + } + + return { + maximumActiveBookings: databaseEventType.maxActiveBookingsPerBooker ?? undefined, + offerReschedule: databaseEventType.maxActiveBookingPerBookerOfferReschedule ?? undefined, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformLocations(locationDb: any) { if (!locationDb) return []; @@ -239,6 +262,7 @@ export class OutputEventTypesService_2024_06_14 { }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformBookingFields(bookingFields: any) { if (!bookingFields) return []; @@ -273,6 +297,7 @@ export class OutputEventTypesService_2024_06_14 { return this.transformBookingFields(defaultBookingFields); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformRecurringEvent(recurringEvent: any) { if (!recurringEvent) return null; const recurringEventParsed = parseRecurringEvent(recurringEvent); @@ -280,6 +305,7 @@ export class OutputEventTypesService_2024_06_14 { return transformRecurrenceInternalToApi(recurringEventParsed); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformMetadata(metadata: any) { if (!metadata) return {}; return EventTypeMetaDataSchema.parse(metadata); @@ -301,6 +327,7 @@ export class OutputEventTypesService_2024_06_14 { }); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformIntervalLimits(bookingLimits: any) { const bookingLimitsParsed = parseBookingLimit(bookingLimits); return transformIntervalLimitsInternalToApi(bookingLimitsParsed); @@ -327,6 +354,7 @@ export class OutputEventTypesService_2024_06_14 { ); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any transformEventTypeColor(eventTypeColor: any) { if (!eventTypeColor) return undefined; const parsedeventTypeColor = parseEventTypeColor(eventTypeColor); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts index 65a8399873..c613b6d71c 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/transformed/event-type.tranformed.ts @@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit< bookingFields?: ReturnType; durationLimits?: ReturnType; recurringEvent?: ReturnType; + maxActiveBookingsPerBooker?: number; + maxActiveBookingPerBookerOfferReschedule?: boolean; eventTypeColor?: ReturnType; useEventTypeDestinationCalendarEmail?: boolean; } & Partial< diff --git a/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts index 43edaf2530..db232ff21f 100644 --- a/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/event-types/organizations-member-team-admin-event-types.e2e-spec.ts @@ -355,6 +355,11 @@ describe("Organizations Event Types Endpoints", () => { darkThemeHex: "#292929", lightThemeHex: "#fafafa", }, + emailSettings: { + disableEmailsToAttendees: true, + disableEmailsToHosts: true, + }, + rescheduleWithSameRoundRobinHost: true, }; return request(app.getHttpServer()) @@ -385,6 +390,7 @@ describe("Organizations Event Types Endpoints", () => { expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage); expect(data.color).toEqual(body.color); expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234"); + expect(data.emailSettings).toEqual(body.emailSettings); collectiveEventType = responseBody.data; }); }); @@ -600,6 +606,11 @@ describe("Organizations Event Types Endpoints", () => { const body: UpdateTeamEventTypeInput_2024_06_14 = { hosts: newHosts, successRedirectUrl: "https://new-url-success.com", + emailSettings: { + disableEmailsToAttendees: false, + disableEmailsToHosts: false, + }, + rescheduleWithSameRoundRobinHost: false, }; return request(app.getHttpServer()) @@ -614,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => { expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com"); expect(eventType.title).toEqual(collectiveEventType.title); expect(eventType.hosts.length).toEqual(1); + expect(eventType.emailSettings).toEqual(body.emailSettings); + expect(eventType.rescheduleWithSameRoundRobinHost).toEqual(body.rescheduleWithSameRoundRobinHost); evaluateHost(eventType.hosts[0], newHosts[0]); }); }); diff --git a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts index c5267015f4..0d6699a6fd 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts @@ -8,10 +8,12 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { SchedulingType } from "@calcom/platform-libraries"; +import { EventTypeMetadata } from "@calcom/platform-libraries/event-types"; import { CreateTeamEventTypeInput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14, HostPriority, + EmailSettings_2024_06_14, } from "@calcom/platform-types"; export type TransformedCreateTeamEventTypeInput = Awaited< @@ -49,14 +51,16 @@ export class InputOrganizationsEventTypesService { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.inputEventTypesService.validateInputDestinationCalendar( + if (transformedBody.destinationCalendar) { + await this.inputEventTypesService.validateInputDestinationCalendar( userId, transformedBody.destinationCalendar - )); + ); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId); + } return transformedBody; } @@ -83,14 +87,16 @@ export class InputOrganizationsEventTypesService { eventName: transformedBody.eventName, }); - transformedBody.destinationCalendar && - (await this.inputEventTypesService.validateInputDestinationCalendar( + if (transformedBody.destinationCalendar) { + await this.inputEventTypesService.validateInputDestinationCalendar( userId, transformedBody.destinationCalendar - )); + ); + } - transformedBody.useEventTypeDestinationCalendarEmail && - (await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId)); + if (transformedBody.useEventTypeDestinationCalendarEmail) { + await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId); + } return transformedBody; } @@ -110,7 +116,7 @@ export class InputOrganizationsEventTypesService { teamId: number, inputEventType: CreateTeamEventTypeInput_2024_06_14 ) { - const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType; + const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType; const eventType = this.inputEventTypesService.transformInputCreateEventType(rest); @@ -123,11 +129,15 @@ export class InputOrganizationsEventTypesService { const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId); - const metadata = + let metadata = rest.schedulingType === "MANAGED" ? { managedEventConfig: {}, ...eventType.metadata } : eventType.metadata; + if (emailSettings) { + metadata = this.addEmailSettingsToMetadata(emailSettings, metadata); + } + const teamEventType = { ...eventType, // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children @@ -145,12 +155,42 @@ export class InputOrganizationsEventTypesService { return teamEventType; } + private addEmailSettingsToMetadata( + emailSettings: EmailSettings_2024_06_14, + metadata: NonNullable + ) { + if ( + emailSettings?.disableEmailsToAttendees === undefined && + emailSettings?.disableEmailsToHosts === undefined + ) { + return metadata; + } + + const clonedMetadata = structuredClone(metadata); + + if (!clonedMetadata.disableStandardEmails) { + clonedMetadata.disableStandardEmails = {}; + } + if (!clonedMetadata.disableStandardEmails.all) { + clonedMetadata.disableStandardEmails.all = {}; + } + + if (emailSettings?.disableEmailsToAttendees !== undefined) { + clonedMetadata.disableStandardEmails.all.attendee = emailSettings.disableEmailsToAttendees; + } + if (emailSettings?.disableEmailsToHosts !== undefined) { + clonedMetadata.disableStandardEmails.all.host = emailSettings.disableEmailsToHosts; + } + + return clonedMetadata; + } + async transformInputUpdateTeamEventType( eventTypeId: number, teamId: number, inputEventType: UpdateTeamEventTypeInput_2024_06_14 ) { - const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType; + const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType; const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId); const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId); @@ -164,6 +204,12 @@ export class InputOrganizationsEventTypesService { ? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId) : undefined; + let metadata = eventType.metadata; + + if (emailSettings) { + metadata = this.addEmailSettingsToMetadata(emailSettings, metadata); + } + const teamEventType = { ...eventType, // note(Lauris): we don't populate hosts for managed event-types because they are handled by the children @@ -175,6 +221,7 @@ export class InputOrganizationsEventTypesService { assignAllTeamMembers, children, locations: locations ? this.transformInputTeamLocations(locations) : undefined, + metadata, }; return teamEventType; diff --git a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts index 67cf769e1e..06776e5e30 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts @@ -4,6 +4,7 @@ import { UsersRepository } from "@/modules/users/users.repository"; import { Injectable } from "@nestjs/common"; import { SchedulingType } from "@calcom/platform-libraries"; +import { EventTypeMetadata } from "@calcom/platform-libraries/event-types"; import type { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types"; import type { Team, @@ -84,6 +85,9 @@ type Input = Pick< | "calVideoSettings" | "hidden" | "bookingRequiresAuthentication" + | "rescheduleWithSameRoundRobinHost" + | "maxActiveBookingPerBookerOfferReschedule" + | "maxActiveBookingsPerBooker" >; @Injectable() @@ -95,7 +99,12 @@ export class OutputOrganizationsEventTypesService { ) {} async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) { - const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType; + const metadata = this.outputEventTypesService.transformMetadata(databaseEventType.metadata); + + const emailSettings = this.transformEmailSettings(metadata); + + const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } = + databaseEventType; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType( 0, @@ -117,6 +126,7 @@ export class OutputOrganizationsEventTypesService { ? this.getResponseSchedulingType(databaseEventType.schedulingType) : databaseEventType.schedulingType, assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined, + emailSettings, team: { id: teamId, name: databaseEventType?.team?.name, @@ -128,6 +138,7 @@ export class OutputOrganizationsEventTypesService { darkBrandColor: databaseEventType?.team?.darkBrandColor, theme: databaseEventType?.team?.theme, }, + rescheduleWithSameRoundRobinHost, }; } @@ -194,6 +205,23 @@ export class OutputOrganizationsEventTypesService { return transformedHosts; } + + private transformEmailSettings(metadata: EventTypeMetadata) { + if (!metadata?.disableStandardEmails?.all) { + return undefined; + } + + const { attendee, host } = metadata.disableStandardEmails.all; + + if (attendee !== undefined || host !== undefined) { + return { + disableEmailsToAttendees: attendee ?? false, + disableEmailsToHosts: host ?? false, + }; + } + + return undefined; + } } function getPriorityLabel(priority: number): keyof typeof HostPriority { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 063b03d9f3..2c22349a70 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -15649,6 +15649,20 @@ } } }, + "BookerActiveBookingsLimit_2024_06_14": { + "type": "object", + "properties": { + "maximumActiveBookings": { + "type": "number", + "description": "The maximum number of active bookings a booker can have for this event type.", + "example": 3 + }, + "offerReschedule": { + "type": "boolean", + "description": "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached." + } + } + }, "Disabled_2024_06_14": { "type": "object", "properties": { @@ -15986,6 +16000,19 @@ }, "required": ["type"] }, + "EmailSettings_2024_06_14": { + "type": "object", + "properties": { + "disableEmailsToAttendees": { + "type": "boolean", + "description": "Disables all email communication to attendees for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows." + }, + "disableEmailsToHosts": { + "type": "boolean", + "description": "Disables all email communication to hosts for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows." + } + } + }, "CalVideoSettings": { "type": "object", "properties": { @@ -16146,6 +16173,17 @@ } ] }, + "bookerActiveBookingsLimit": { + "description": "Limit the number of active bookings a booker can make for this event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, "onlyShowFirstAvailableSlot": { "type": "boolean", "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." @@ -17389,6 +17427,9 @@ "bookingLimitsCount": { "type": "object" }, + "bookerActiveBookingsLimit": { + "$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14" + }, "onlyShowFirstAvailableSlot": { "type": "boolean" }, @@ -17673,6 +17714,17 @@ } ] }, + "bookerActiveBookingsLimit": { + "description": "Limit the number of active bookings a booker can make for this event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, "onlyShowFirstAvailableSlot": { "type": "boolean", "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." @@ -19244,6 +19296,17 @@ } ] }, + "bookerActiveBookingsLimit": { + "description": "Limit the number of active bookings a booker can make for this event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, "onlyShowFirstAvailableSlot": { "type": "boolean", "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." @@ -19420,6 +19483,18 @@ } ] } + }, + "emailSettings": { + "description": "Email settings for this event type - only available for organization team event types.", + "allOf": [ + { + "$ref": "#/components/schemas/EmailSettings_2024_06_14" + } + ] + }, + "rescheduleWithSameRoundRobinHost": { + "type": "boolean", + "description": "Rescheduled events will be assigned to the same host as initially scheduled." } }, "required": ["lengthInMinutes", "title", "slug", "schedulingType"] @@ -19653,6 +19728,9 @@ "bookingLimitsCount": { "type": "object" }, + "bookerActiveBookingsLimit": { + "$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14" + }, "onlyShowFirstAvailableSlot": { "type": "boolean" }, @@ -19755,6 +19833,18 @@ }, "team": { "$ref": "#/components/schemas/EventTypeTeam" + }, + "emailSettings": { + "description": "Email settings for this event type. Only available for organization team event types.", + "allOf": [ + { + "$ref": "#/components/schemas/EmailSettings_2024_06_14" + } + ] + }, + "rescheduleWithSameRoundRobinHost": { + "type": "boolean", + "description": "Rescheduled events will be assigned to the same host as initially scheduled." } }, "required": [ @@ -20043,6 +20133,17 @@ } ] }, + "bookerActiveBookingsLimit": { + "description": "Limit the number of active bookings a booker can make for this event type.", + "oneOf": [ + { + "$ref": "#/components/schemas/BookerActiveBookingsLimit_2024_06_14" + }, + { + "$ref": "#/components/schemas/Disabled_2024_06_14" + } + ] + }, "onlyShowFirstAvailableSlot": { "type": "boolean", "description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time." @@ -20213,6 +20314,18 @@ } ] } + }, + "emailSettings": { + "description": "Email settings for this event type. Only available for organization team event types.", + "allOf": [ + { + "$ref": "#/components/schemas/EmailSettings_2024_06_14" + } + ] + }, + "rescheduleWithSameRoundRobinHost": { + "type": "boolean", + "description": "Rescheduled events will be assigned to the same host as initially scheduled." } } }, diff --git a/packages/platform/examples/base/src/pages/booking.tsx b/packages/platform/examples/base/src/pages/booking.tsx index 14ac3fec91..01a52fce5e 100644 --- a/packages/platform/examples/base/src/pages/booking.tsx +++ b/packages/platform/examples/base/src/pages/booking.tsx @@ -1,6 +1,5 @@ import { Navbar } from "@/components/Navbar"; import { Inter } from "next/font/google"; -// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router import { useRouter } from "next/router"; import { useState } from "react"; @@ -95,6 +94,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string // timeZones={["Europe/London", "Asia/Kolkata"]} // isBookingDryRun={true} // roundRobinHideOrgAndTeam={true} + defaultFormValues={{ name: "Bob the booker", email: "bob@thebooker.com" }} bannerUrl="https://i0.wp.com/mahala.co.uk/wp-content/uploads/2014/12/img_banner-thin_mountains.jpg?fit=800%2C258&ssl=1" eventSlug={eventTypeSlug} onCreateBookingSuccess={(data) => { diff --git a/packages/platform/libraries/event-types.ts b/packages/platform/libraries/event-types.ts index df62d14916..a67a28c50f 100644 --- a/packages/platform/libraries/event-types.ts +++ b/packages/platform/libraries/event-types.ts @@ -18,6 +18,8 @@ export { eventTypeLocations, } from "@calcom/prisma/zod-utils"; +export type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; + export { validateCustomEventName } from "@calcom/features/eventtypes/lib/eventNaming"; export { EventManager }; export { getEventTypeById } from "@calcom/features/eventtypes/lib/getEventTypeById"; diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/booker-active-booking-limit.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/booker-active-booking-limit.input.ts new file mode 100644 index 0000000000..e2e7f9cda1 --- /dev/null +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/booker-active-booking-limit.input.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsInt, IsOptional, Min } from "class-validator"; + +export class BookerActiveBookingsLimit_2024_06_14 { + @IsOptional() + @IsInt() + @Min(1) + @ApiPropertyOptional({ + description: "The maximum number of active bookings a booker can have for this event type.", + example: 3, + }) + maximumActiveBookings?: number; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ + description: + "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached.", + }) + offerReschedule?: boolean; + + @IsOptional() + @IsBoolean() + disabled?: boolean = false; +} diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts index e81eb1c948..63ed7969ba 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts @@ -19,9 +19,10 @@ import { ArrayUnique, } from "class-validator"; - import { SchedulingType } from "@calcom/platform-enums"; +import { RequiresAtLeastOnePropertyWhenNotDisabled } from "../../../utils/RequiresOneOfPropertiesWhenNotDisabled"; +import { BookerActiveBookingsLimit_2024_06_14 } from "./booker-active-booking-limit.input"; import { BookerLayouts_2024_06_14 } from "./booker-layouts.input"; import { AddressFieldInput_2024_06_14, @@ -64,6 +65,7 @@ import type { ConfirmationPolicy_2024_06_14 } from "./confirmation-policy.input" import { BaseConfirmationPolicy_2024_06_14, ValidateConfirmationPolicy } from "./confirmation-policy.input"; import { DestinationCalendar_2024_06_14 } from "./destination-calendar.input"; import { Disabled_2024_06_14 } from "./disabled.input"; +import { EmailSettings_2024_06_14 } from "./email-settings.input"; import { EventTypeColor_2024_06_14 } from "./event-type-color.input"; import { InputAddressLocation_2024_06_14, @@ -80,6 +82,7 @@ import { import type { InputLocation_2024_06_14, InputTeamLocation_2024_06_14 } from "./locations.input"; import { Recurrence_2024_06_14 } from "./recurrence.input"; import { Seats_2024_06_14 } from "./seats.input"; +import { CantHaveRecurrenceAndBookerActiveBookingsLimit } from "./validators/CantHaveRecurrenceAndBookerActiveBookingsLimit"; export const CREATE_EVENT_LENGTH_EXAMPLE = 60; export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!"; @@ -108,6 +111,7 @@ export const CREATE_EVENT_SLUG_EXAMPLE = "learn-the-secrets-of-masterchief"; CalendarDaysWindow_2024_06_14, RangeWindow_2024_06_14, BaseBookingLimitsCount_2024_06_14, + BookerActiveBookingsLimit_2024_06_14, Disabled_2024_06_14, BaseBookingLimitsDuration_2024_06_14, Recurrence_2024_06_14, @@ -123,7 +127,8 @@ export const CREATE_EVENT_SLUG_EXAMPLE = "learn-the-secrets-of-masterchief"; NotesDefaultFieldInput_2024_06_14, GuestsDefaultFieldInput_2024_06_14, RescheduleReasonDefaultFieldInput_2024_06_14, - InputOrganizersDefaultApp_2024_06_14 + InputOrganizersDefaultApp_2024_06_14, + EmailSettings_2024_06_14 ) export class CalVideoSettings { @IsOptional() @@ -176,7 +181,8 @@ export class CalVideoSettings { disableTranscriptionForOrganizer?: boolean; } -class BaseCreateEventTypeInput { +@CantHaveRecurrenceAndBookerActiveBookingsLimit() +export class BaseCreateEventTypeInput { @IsInt() @Min(1) @DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE }) @@ -297,6 +303,29 @@ class BaseCreateEventTypeInput { @Type(() => Object) bookingLimitsCount?: BookingLimitsCount_2024_06_14; + @IsOptional() + @RequiresAtLeastOnePropertyWhenNotDisabled() + @Transform(({ value }) => { + if (value && typeof value === "object") { + if ("disabled" in value && value.disabled) { + return Object.assign(new Disabled_2024_06_14(), value); + } else { + return Object.assign(new BookerActiveBookingsLimit_2024_06_14(), value); + } + } + return value; + }) + @ValidateNested() + @DocsPropertyOptional({ + description: "Limit the number of active bookings a booker can make for this event type.", + oneOf: [ + { $ref: getSchemaPath(BookerActiveBookingsLimit_2024_06_14) }, + { $ref: getSchemaPath(Disabled_2024_06_14) }, + ], + }) + @Type(() => Object) + bookerActiveBookingsLimit?: BookerActiveBookingsLimit_2024_06_14 | Disabled_2024_06_14; + @IsOptional() @IsBoolean() @DocsPropertyOptional({ @@ -568,7 +597,8 @@ export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInpu @IsBoolean() @IsOptional() @DocsPropertyOptional({ - description: "If true, all current and future team members will be assigned to this event type. Provide either assignAllTeamMembers or hosts but not both", + description: + "If true, all current and future team members will be assigned to this event type. Provide either assignAllTeamMembers or hosts but not both", }) assignAllTeamMembers?: boolean; @@ -591,4 +621,20 @@ export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInpu }) @Type(() => Object) locations?: InputTeamLocation_2024_06_14[]; + + @IsOptional() + @ValidateNested() + @DocsPropertyOptional({ + description: "Email settings for this event type - only available for organization team event types.", + type: () => EmailSettings_2024_06_14, + }) + @Type(() => EmailSettings_2024_06_14) + emailSettings?: EmailSettings_2024_06_14; + + @IsBoolean() + @IsOptional() + @DocsPropertyOptional({ + description: "Rescheduled events will be assigned to the same host as initially scheduled.", + }) + rescheduleWithSameRoundRobinHost?: boolean; } diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/email-settings.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/email-settings.input.ts new file mode 100644 index 0000000000..f9af0587f7 --- /dev/null +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/email-settings.input.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional } from "class-validator"; + +export class EmailSettings_2024_06_14 { + @IsBoolean() + @IsOptional() + @ApiPropertyOptional({ + description: + "Disables all email communication to attendees for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows.", + }) + disableEmailsToAttendees?: boolean; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional({ + description: + "Disables all email communication to hosts for this event type, including booking confirmations, reminders, and cancellations. This DOES NOT include emails sent by custom email workflows.", + }) + disableEmailsToHosts?: boolean; +} diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/index.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/index.ts index 4461d87e0b..9f87d63fb7 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/index.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/index.ts @@ -9,6 +9,7 @@ export * from "./update-event-type.input"; export * from "./get-event-types-query.input"; export * from "./booker-layouts.input"; export * from "./confirmation-policy.input"; +export * from "./email-settings.input"; export * from "./event-type-color.input"; export * from "./seats.input"; export * from "./destination-calendar.input"; diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts index 8f69ea1717..7ac1697478 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts @@ -1,9 +1,4 @@ -import { - ApiProperty as DocsProperty, - ApiPropertyOptional as DocsPropertyOptional, - getSchemaPath, - ApiExtraModels, -} from "@nestjs/swagger"; +import { ApiPropertyOptional as DocsPropertyOptional, getSchemaPath, ApiExtraModels } from "@nestjs/swagger"; import { Type, Transform } from "class-transformer"; import { IsString, @@ -18,6 +13,8 @@ import { IsUrl, } from "class-validator"; +import { RequiresAtLeastOnePropertyWhenNotDisabled } from "../../../utils/RequiresOneOfPropertiesWhenNotDisabled"; +import { BookerActiveBookingsLimit_2024_06_14 } from "./booker-active-booking-limit.input"; import { BookerLayouts_2024_06_14 } from "./booker-layouts.input"; import type { InputBookingField_2024_06_14 } from "./booking-fields.input"; import { @@ -67,6 +64,7 @@ import { } from "./create-event-type.input"; import { DestinationCalendar_2024_06_14 } from "./destination-calendar.input"; import { Disabled_2024_06_14 } from "./disabled.input"; +import { EmailSettings_2024_06_14 } from "./email-settings.input"; import { EventTypeColor_2024_06_14 } from "./event-type-color.input"; import { InputAddressLocation_2024_06_14, @@ -83,6 +81,7 @@ import { import type { InputLocation_2024_06_14, InputTeamLocation_2024_06_14 } from "./locations.input"; import { Recurrence_2024_06_14 } from "./recurrence.input"; import { Seats_2024_06_14 } from "./seats.input"; +import { CantHaveRecurrenceAndBookerActiveBookingsLimit } from "./validators/CantHaveRecurrenceAndBookerActiveBookingsLimit"; @ApiExtraModels( InputAddressLocation_2024_06_14, @@ -118,8 +117,11 @@ import { Seats_2024_06_14 } from "./seats.input"; LocationDefaultFieldInput_2024_06_14, NotesDefaultFieldInput_2024_06_14, GuestsDefaultFieldInput_2024_06_14, - RescheduleReasonDefaultFieldInput_2024_06_14 + RescheduleReasonDefaultFieldInput_2024_06_14, + BookerActiveBookingsLimit_2024_06_14, + EmailSettings_2024_06_14 ) +@CantHaveRecurrenceAndBookerActiveBookingsLimit() class BaseUpdateEventTypeInput { @IsOptional() @IsInt() @@ -243,6 +245,29 @@ class BaseUpdateEventTypeInput { @Type(() => Object) bookingLimitsCount?: BookingLimitsCount_2024_06_14; + @IsOptional() + @Transform(({ value }) => { + if (value && typeof value === "object") { + if ("disabled" in value && value.disabled) { + return Object.assign(new Disabled_2024_06_14(), value); + } else { + return Object.assign(new BookerActiveBookingsLimit_2024_06_14(), value); + } + } + return value; + }) + @ValidateNested() + @RequiresAtLeastOnePropertyWhenNotDisabled() + @DocsPropertyOptional({ + description: "Limit the number of active bookings a booker can make for this event type.", + oneOf: [ + { $ref: getSchemaPath(BookerActiveBookingsLimit_2024_06_14) }, + { $ref: getSchemaPath(Disabled_2024_06_14) }, + ], + }) + @Type(() => Object) + bookerActiveBookingsLimit?: BookerActiveBookingsLimit_2024_06_14 | Disabled_2024_06_14; + @IsOptional() @IsBoolean() @DocsPropertyOptional({ @@ -455,16 +480,18 @@ export class UpdateTeamEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInpu @Type(() => Host) @IsArray() @IsOptional() - @DocsPropertyOptional({ type: [Host], + @DocsPropertyOptional({ + type: [Host], description: "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users. Provide either hosts or assignAllTeamMembers but not both", - }) + }) hosts?: Host[]; @IsOptional() @IsBoolean() @DocsPropertyOptional({ - description: "If true, all current and future team members will be assigned to this event type. Provide either assignAllTeamMembers or hosts but not both", + description: + "If true, all current and future team members will be assigned to this event type. Provide either assignAllTeamMembers or hosts but not both", }) assignAllTeamMembers?: boolean; @@ -487,4 +514,20 @@ export class UpdateTeamEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInpu }) @Type(() => Object) locations?: InputTeamLocation_2024_06_14[]; + + @IsOptional() + @ValidateNested() + @DocsPropertyOptional({ + description: "Email settings for this event type. Only available for organization team event types.", + type: () => EmailSettings_2024_06_14, + }) + @Type(() => EmailSettings_2024_06_14) + emailSettings?: EmailSettings_2024_06_14; + + @IsBoolean() + @IsOptional() + @DocsPropertyOptional({ + description: "Rescheduled events will be assigned to the same host as initially scheduled.", + }) + rescheduleWithSameRoundRobinHost?: boolean; } diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit.ts new file mode 100644 index 0000000000..7d3e8e1ab6 --- /dev/null +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit.ts @@ -0,0 +1,40 @@ +import { BadRequestException } from "@nestjs/common"; +import { ValidationOptions, registerDecorator, ValidationArguments } from "class-validator"; + +import type { BaseCreateEventTypeInput } from "../create-event-type.input"; + +export const FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE = + "Can't have `recurrence` and `bookerActiveBookingsLimit` enabled at the same time - recurring events do not support maximum active bookings limit setting."; + +export function CantHaveRecurrenceAndBookerActiveBookingsLimit(validationOptions?: ValidationOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function (object: any) { + registerDecorator({ + name: "cantHaveRecurrenceAndBookerActiveBookingsLimit", + target: object, + propertyName: "recurrence or bookerActiveBookingsLimit", + options: validationOptions, + constraints: [], + validator: { + validate(_: unknown, args: ValidationArguments) { + const obj = args.object as BaseCreateEventTypeInput; + + const hasActiveRecurrence = obj?.recurrence && "interval" in obj.recurrence; + + const hasActiveBookerLimit = + obj?.bookerActiveBookingsLimit && + ("maximumActiveBookings" in obj.bookerActiveBookingsLimit || + "offerReschedule" in obj.bookerActiveBookingsLimit); + + if (hasActiveRecurrence && hasActiveBookerLimit) { + throw new BadRequestException(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE); + } + return true; + }, + defaultMessage(): string { + return FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE; + }, + }, + }); + }; +} diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/booker-active-bookings-limit.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/booker-active-bookings-limit.output.ts new file mode 100644 index 0000000000..2b13a7959e --- /dev/null +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/booker-active-bookings-limit.output.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsInt, IsOptional, Min } from "class-validator"; + +export class BookerActiveBookingsLimitOutput_2024_06_14 { + @IsOptional() + @IsInt() + @Min(1) + @ApiPropertyOptional({ + description: "The maximum number of active bookings a booker can have for this event type.", + example: 3, + }) + maximumActiveBookings?: number; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ + description: + "Whether to offer rescheduling the last active booking to the chosen time slot when limit is reached.", + }) + offerReschedule?: boolean; +} diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts index 4610cb2959..240a60f97a 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts @@ -35,6 +35,7 @@ import type { BookingLimitsCount_2024_06_14 } from "../inputs/booking-limits-cou import type { ConfirmationPolicy_2024_06_14 } from "../inputs/confirmation-policy.input"; import { DestinationCalendar_2024_06_14 } from "../inputs/destination-calendar.input"; import type { Disabled_2024_06_14 } from "../inputs/disabled.input"; +import { EmailSettings_2024_06_14 } from "../inputs/email-settings.input"; import { EmailDefaultFieldOutput_2024_06_14, NameDefaultFieldOutput_2024_06_14, @@ -56,6 +57,7 @@ import { TextFieldOutput_2024_06_14, UrlFieldOutput_2024_06_14, } from "../outputs/booking-fields.output"; +import { BookerActiveBookingsLimitOutput_2024_06_14 } from "./booker-active-bookings-limit.output"; import type { OutputBookingField_2024_06_14 } from "./booking-fields.output"; import { ValidateOutputBookingFields_2024_06_14 } from "./booking-fields.output"; import type { OutputLocation_2024_06_14 } from "./locations.output"; @@ -178,7 +180,8 @@ class EventTypeTeam { BaseBookingLimitsDuration_2024_06_14, BusinessDaysWindow_2024_06_14, CalendarDaysWindow_2024_06_14, - RangeWindow_2024_06_14 + RangeWindow_2024_06_14, + EmailSettings_2024_06_14 ) class BaseEventTypeOutput_2024_06_14 { @IsInt() @@ -346,6 +349,11 @@ class BaseEventTypeOutput_2024_06_14 { @ApiPropertyOptional() bookingLimitsCount?: BookingLimitsCount_2024_06_14; + @IsOptional() + @Type(() => BookerActiveBookingsLimitOutput_2024_06_14) + @ApiPropertyOptional({ type: BookerActiveBookingsLimitOutput_2024_06_14 }) + bookerActiveBookingsLimit?: BookerActiveBookingsLimitOutput_2024_06_14; + @IsOptional() @IsBoolean() @ApiPropertyOptional() @@ -526,4 +534,20 @@ export class TeamEventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_ @Type(() => EventTypeTeam) @DocsProperty() team!: EventTypeTeam; + + @IsOptional() + @ValidateNested() + @Type(() => EmailSettings_2024_06_14) + @ApiPropertyOptional({ + description: "Email settings for this event type. Only available for organization team event types.", + type: () => EmailSettings_2024_06_14, + }) + emailSettings?: EmailSettings_2024_06_14; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional({ + description: "Rescheduled events will be assigned to the same host as initially scheduled.", + }) + rescheduleWithSameRoundRobinHost?: boolean; } diff --git a/packages/platform/types/utils/RequiresOneOfPropertiesWhenNotDisabled.ts b/packages/platform/types/utils/RequiresOneOfPropertiesWhenNotDisabled.ts new file mode 100644 index 0000000000..12645f5bdd --- /dev/null +++ b/packages/platform/types/utils/RequiresOneOfPropertiesWhenNotDisabled.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from "@nestjs/common"; +import { ValidationOptions, registerDecorator } from "class-validator"; + +/** + * note(Lauris): See example in event-types_2024_06_14/inputs/create-event-type.input.ts file bookerActiveBookingsLimit property. + * This validator is used if type can be SomeClass | Disabled_2024_06_14 to ensure that if {disabled: false} is passed + * meaning that the feature is enabled that other properties are provided too. + */ + +export const REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR = + "When disabled is false {disabled: false} at least one other property must be provided, because it means that the property is enabled but then other properties must be provided too."; + +export function RequiresAtLeastOnePropertyWhenNotDisabled(validationOptions?: ValidationOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function (object: any, propertyName: string) { + registerDecorator({ + name: "requiresAtLeastOnePropertyWhenNotDisabled", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: { + validate(value: unknown) { + if (!value || typeof value !== "object") { + return true; + } + + const obj = value as Record; + + if (obj.disabled !== false) { + return true; + } + + const otherProperties = Object.keys(obj).filter((key) => key !== "disabled"); + const hasAtLeastOneOtherProperty = otherProperties.length > 0; + + if (!hasAtLeastOneOtherProperty) { + throw new BadRequestException(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR); + } + + return true; + }, + defaultMessage(): string { + return REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR; + }, + }, + }); + }; +}