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
This commit is contained in:
Lauris Skraucis
2025-10-15 17:31:35 +03:00
committed by GitHub
parent 1aac14056e
commit bb9d99c3e1
21 changed files with 938 additions and 69 deletions
@@ -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);
}
});
@@ -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;
@@ -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<EventTypeOutput_2024_06_14> = 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<EventTypeOutput_2024_06_14> = 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<EventTypeOutput_2024_06_14> = 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<EventTypeOutput_2024_06_14> = 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);
@@ -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 [];
@@ -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);
@@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit<
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;
maxActiveBookingsPerBooker?: number;
maxActiveBookingPerBookerOfferReschedule?: boolean;
eventTypeColor?: ReturnType<typeof transformEventColorsApiToInternal>;
useEventTypeDestinationCalendarEmail?: boolean;
} & Partial<
@@ -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]);
});
});
@@ -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<EventTypeMetadata>
) {
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;
@@ -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 {
+113
View File
@@ -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."
}
}
},
@@ -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) => {
@@ -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";
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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";
@@ -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;
}
@@ -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;
},
},
});
};
}
@@ -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;
}
@@ -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;
}
@@ -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<string, unknown>;
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;
},
},
});
};
}