feat: add advanced tab attributes to api/v2/event-types (#16314)

* added bookerLayouts

* added requiresConfirmation

* added tests

* undo extra changes

* undo extra change

* fixed imports

* added requiresBookerEmailVerification

* added hideCalendarNotes

* added lockTimeZoneToggleOnBookingPage

* added eventTypeColor

* undo extra changes

* added seats

* added requiresConfirmationWillBlockSlot to requiresConfirmation

* refactor: Implement BaseEventType class to avoid duplication across EventType input/output classes

## D-R-Y ##

* temp-fix: will revert later

* Correct placement of users array from EventTypeOutput to TeamEventTypeOutput

* Refactor: Shifted transformation logic to controllers using interceptors and pipes for a cleaner service layer.

* small fix

* Refactor: Moved transformation logic from service to controller using interceptors and pipes

** organisations/event-types

* feat: adde tests for `validateEventTypeInputs`

* fix: failing test

* fix: layout validator

* renamed blockCalendarForUnconfirmedBookings to blockUnconfirmedBookingsInBooker

* testing changes

* added disabled state

* fix merege conflict

* renamed requiresConfirmation to confirmationPolicy

* renamed OutputEventTypesResponseInterceptor to OutputTeamEventTypesResponseInterceptor

* renamed `darkThemeColor` -> `darkThemeHex` and `lightThemeColor` -> `lightThemeHex`

* renamed `eventTypeColor` -> `color`

* changed `requiresConfirmation` -> `confirmationPolicy`

* added tests for disabled state

* added class-validators to `disabled?: false`

* Revert: Split create and update input/output classes

* feat: Refactor responses to use DTOs with plainToClass for explicit property control

	•	Updated all endpoints to return response DTOs (e.g., CreateTeamEventTypeOutput, GetTeamEventTypeOutput) to ensure consistent Swagger documentation generation.
	•	Introduced plainToClass for transforming responses, enabling explicit control over which properties are returned in the response, following Morgan’s recommendation.
	•	Replaced generic HandlerResponse type with class-validator based DTOs for each endpoint, ensuring TypeScript enforces correct response structure.
	•	Applied strategy: "excludeAll" to limit response properties to only those explicitly defined in the DTO, preventing unintended fields from being included in the API responses.

* added pipes to transform output data

* add clean script to platform-types package.json

* added customName

* added destination calendar

* fixed type errors

* added useDestinationCalendarEmail

* refactor: api-reqest and api-response

* Update event-type.output.ts

* fixed errors

* Update yarn.lock

* added some missed properties to output

* Update constants.ts

* removed return type

* fixed some type errors

* reuse types

* Improve readability of validation functions

* Update CHANGELOG.md

* chore: post publish platform libraries

* Update documentation.json

* fix: reset platform libraries to 0.0.0

* fixup! fix: reset platform libraries to 0.0.0

* Update event-type.tranformed.ts

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
Somay Chauhan
2024-09-24 13:36:05 +03:00
committed by GitHub
co-authored by supalarry Morgan Vernay
parent 6de454b071
commit 2e2a6c73b1
68 changed files with 2859 additions and 6305 deletions
@@ -198,7 +198,7 @@ class DestinationCalendar {
integrationTitle?: string;
}
class ConnectedCalendarsData {
export class ConnectedCalendarsData {
@ValidateNested({ each: true })
@IsArray()
connectedCalendars!: ConnectedCalendar[];
@@ -4,7 +4,6 @@ import {
CredentialsRepository,
CredentialsWithUserEmail,
} from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -14,7 +13,6 @@ import {
UnauthorizedException,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { DateTime } from "luxon";
@@ -34,9 +32,7 @@ export class CalendarsService {
private readonly credentialsRepository: CredentialsRepository,
private readonly appsRepository: AppsRepository,
private readonly calendarsRepository: CalendarsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
@@ -42,7 +42,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
@@ -1,16 +1,44 @@
import { IntegrationLocation } from "@calcom/lib";
type BaseEventType = {
length: number;
slug: string;
title: string;
};
type EventTypeWithLocation = BaseEventType & {
locations: IntegrationLocation[];
};
const thirtyMinutes: BaseEventType = {
length: 30,
slug: "thirty-minutes",
title: "30 Minutes",
};
const thirtyMinutesVideo: EventTypeWithLocation = {
length: 30,
slug: "thirty-minutes-video",
title: "30 Minutes",
locations: [{ type: "integrations:daily" }],
};
const sixtyMinutes: BaseEventType = {
length: 60,
slug: "sixty-minutes",
title: "60 Minutes",
};
const sixtyMinutesVideo: EventTypeWithLocation = {
length: 60,
slug: "sixty-minutes-video",
title: "60 Minutes",
locations: [{ type: "integrations:daily" }],
};
export const DEFAULT_EVENT_TYPES = {
thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" },
thirtyMinutesVideo: {
length: 30,
slug: "thirty-minutes-video",
title: "30 Minutes",
locations: [{ type: "integrations:daily" }],
},
sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" },
sixtyMinutesVideo: {
length: 60,
slug: "sixty-minutes-video",
title: "60 Minutes",
locations: [{ type: "integrations:daily" }],
},
thirtyMinutes,
thirtyMinutesVideo,
sixtyMinutes,
sixtyMinutesVideo,
};
@@ -20,7 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14, FrequencyInput } from "@calcom/platform-enums";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
FrequencyInput,
} from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
@@ -230,11 +236,36 @@ describe("Event types Endpoints", () => {
value: 30,
rolling: true,
},
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
count: 60,
unit: NoticeThresholdUnitEnum.MINUTES,
},
blockUnconfirmedBookingsInBooker: true,
},
recurrence: {
frequency: FrequencyInput.weekly,
interval: 2,
occurrences: 10,
disabled: false,
},
requiresBookerEmailVerification: false,
hideCalendarNotes: false,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
customName: `{Event type title} between {Organiser} and {Scheduler}`,
};
return request(app.getHttpServer())
@@ -257,7 +288,19 @@ describe("Event types Endpoints", () => {
expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
expect(createdEventType.bookerLayouts).toEqual(body.bookerLayouts);
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(createdEventType.recurrence).toEqual(body.recurrence);
expect(createdEventType.customName).toEqual(body.customName);
expect(createdEventType.requiresBookerEmailVerification).toEqual(
body.requiresBookerEmailVerification
);
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
expect(createdEventType.color).toEqual(body.color);
const responseBookingFields = body.bookingFields || [];
const expectedBookingFields = [
@@ -272,6 +315,317 @@ describe("Event types Endpoints", () => {
});
});
it(`/GET/even-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
const fetchedEventType = responseBody.data?.[0];
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it("should return an error when creating an event type with seats enabled and multiple locations", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 2",
slug: "coding-class-2",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
{
type: "phone",
phone: "+37120993151",
public: true,
},
],
scheduleId: firstSchedule.id,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return an error when trying to enable seats for an event type with multiple locations", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 3",
slug: "coding-class-3",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
{
type: "phone",
phone: "+37120993151",
public: true,
},
],
scheduleId: firstSchedule.id,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
locations: body.locations,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
})
.expect(400);
});
it("should return an error when creating an event type with seats enabled and confirmationPolicy enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 4",
slug: "coding-class-4",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
scheduleId: firstSchedule.id,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return an error when trying to enable seats for an event type with confirmationPolicy enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 5",
slug: "coding-class-5",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
scheduleId: firstSchedule.id,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
console.log("createdEventType: ", createdEventType);
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
confirmationPolicy: body.confirmationPolicy,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
})
.expect(400);
});
it("should return an error when trying to set multiple locations for an event type with seats enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 6",
slug: "coding-class-6",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
scheduleId: firstSchedule.id,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
seats: body.seats,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
locations: [
{
type: "integration",
integration: "cal-video",
},
{
type: "phone",
phone: "+37120993151",
public: true,
},
],
})
.expect(400);
});
it("should return an error when creating an event type with confirmationPolicy enabled and seats enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 7",
slug: "coding-class-7",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
scheduleId: firstSchedule.id,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return an error when trying to enable confirmationPolicy for an event type with seats enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 8",
slug: "coding-class-8",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
scheduleId: firstSchedule.id,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
seats: body.seats,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
})
.expect(400);
});
it("should update event type", async () => {
const newTitle = "Coding class in Italian!";
@@ -293,11 +647,32 @@ describe("Event types Endpoints", () => {
value: 40,
rolling: false,
},
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
recurrence: {
frequency: FrequencyInput.monthly,
interval: 4,
occurrences: 10,
disabled: false,
},
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
};
return request(app.getHttpServer())
@@ -323,7 +698,18 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(updatedEventType.offsetStart).toEqual(body.offsetStart);
expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow);
expect(updatedEventType.bookerLayouts).toEqual(body.bookerLayouts);
expect(updatedEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(updatedEventType.recurrence).toEqual(body.recurrence);
expect(updatedEventType.customName).toEqual(body.customName);
expect(updatedEventType.requiresBookerEmailVerification).toEqual(
body.requiresBookerEmailVerification
);
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
expect(updatedEventType.color).toEqual(body.color);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -332,7 +718,14 @@ describe("Event types Endpoints", () => {
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
eventType.offsetStart = updatedEventType.offsetStart;
eventType.bookingWindow = updatedEventType.bookingWindow;
eventType.bookerLayouts = updatedEventType.bookerLayouts;
eventType.confirmationPolicy = updatedEventType.confirmationPolicy;
eventType.recurrence = updatedEventType.recurrence;
eventType.customName = updatedEventType.customName;
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
});
});
@@ -373,38 +766,18 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
});
it(`/GET/even-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
const fetchedEventType = responseBody.data?.[0];
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/event-types by username and eventSlug`, async () => {
@@ -430,7 +803,18 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/:id not existing`, async () => {
@@ -3,7 +3,9 @@ import { DeleteEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { GetEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output";
import { GetEventTypesOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output";
import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output";
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -23,14 +25,15 @@ import {
HttpStatus,
Delete,
Query,
ParseIntPipe,
} from "@nestjs/common";
import { ApiHeader, ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -45,7 +48,11 @@ import {
required: true,
})
export class EventTypesController_2024_06_14 {
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe
) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@@ -60,11 +67,16 @@ export class EventTypesController_2024_06_14 {
@Body() body: CreateEventTypeInput_2024_06_14,
@GetUser() user: UserWithProfile
): Promise<CreateEventTypeOutput_2024_06_14> {
const eventType = await this.eventTypesService.createUserEventType(user, body);
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
user.id,
body
);
const eventType = await this.eventTypesService.createUserEventType(user, transformedBody);
return {
status: SUCCESS_STATUS,
data: eventType,
data: this.eventTypeResponseTransformPipe.transform(eventType),
};
}
@@ -89,7 +101,7 @@ export class EventTypesController_2024_06_14 {
return {
status: SUCCESS_STATUS,
data: eventType,
data: this.eventTypeResponseTransformPipe.transform(eventType),
};
}
@@ -101,7 +113,7 @@ export class EventTypesController_2024_06_14 {
return {
status: SUCCESS_STATUS,
data: eventTypes,
data: this.eventTypeResponseTransformPipe.transform(eventTypes),
};
}
@@ -116,15 +128,21 @@ export class EventTypesController_2024_06_14 {
})
@HttpCode(HttpStatus.OK)
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeInput_2024_06_14,
@GetUser() user: UserWithProfile
): Promise<UpdateEventTypeOutput_2024_06_14> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
body,
user.id,
eventTypeId
);
const eventType = await this.eventTypesService.updateEventType(eventTypeId, transformedBody, user);
return {
status: SUCCESS_STATUS,
data: eventType,
data: this.eventTypeResponseTransformPipe.transform(eventType),
};
}
@@ -1,9 +1,14 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
@@ -22,6 +27,11 @@ import { Module } from "@nestjs/common";
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
EventTypeResponseTransformPipe,
CalendarsService,
CredentialsRepository,
AppsRepository,
CalendarsRepository,
],
controllers: [EventTypesController_2024_06_14],
exports: [
@@ -4,36 +4,10 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import {
getEventTypeById,
transformBookingFieldsApiToInternal,
transformLocationsApiToInternal,
} from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import { getEventTypeById } from "@calcom/platform-libraries";
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
type InputEventTransformed = Omit<
CreateEventTypeInput_2024_06_14,
| "lengthInMinutes"
| "locations"
| "bookingFields"
| "bookingLimitsCount"
| "onlyShowFirstAvailableSlot"
| "bookingLimitsDuration"
| "offsetStart"
| "periodType"
| "periodDays"
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "recurrence"
> & {
length: number;
slug: string;
locations?: ReturnType<typeof transformLocationsApiToInternal>;
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
};
@Injectable()
export class EventTypesRepository_2024_06_14 {
constructor(
@@ -42,7 +16,10 @@ export class EventTypesRepository_2024_06_14 {
private usersService: UsersService
) {}
async createUserEventType(userId: number, body: InputEventTransformed) {
async createUserEventType(
userId: number,
body: Omit<InputEventTransformed_2024_06_14, "destinationCalendar">
) {
return this.dbWrite.prisma.eventType.create({
data: {
...body,
@@ -57,7 +34,19 @@ export class EventTypesRepository_2024_06_14 {
async getEventTypeWithSeats(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { users: { select: { id: true } }, seatsPerTimeSlot: true },
select: {
users: { select: { id: true } },
seatsPerTimeSlot: true,
locations: true,
requiresConfirmation: true,
},
});
}
async getEventTypeWithMetaData(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { metadata: true },
});
}
@@ -67,7 +56,7 @@ export class EventTypesRepository_2024_06_14 {
id: eventTypeId,
userId,
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -76,7 +65,7 @@ export class EventTypesRepository_2024_06_14 {
where: {
userId,
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -98,7 +87,7 @@ export class EventTypesRepository_2024_06_14 {
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -117,7 +106,7 @@ export class EventTypesRepository_2024_06_14 {
slug: slug,
},
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -0,0 +1,39 @@
import { Injectable, PipeTransform } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import {
DatabaseEventType,
OutputEventTypesService_2024_06_14,
} from "../services/output-event-types.service";
type EventTypeResponse = DatabaseEventType & { ownerId: number };
@Injectable()
export class EventTypeResponseTransformPipe implements PipeTransform {
constructor(private readonly outputEventTypesService: OutputEventTypesService_2024_06_14) {}
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
return plainToClass(
EventTypeOutput_2024_06_14,
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
{ strategy: "exposeAll" }
);
}
// Implementing function overloading to ensure correct return types based on input type:
transform(value: EventTypeResponse[]): EventTypeOutput_2024_06_14[];
transform(value: EventTypeResponse): EventTypeOutput_2024_06_14;
transform(
value: EventTypeResponse | EventTypeResponse[]
): EventTypeOutput_2024_06_14 | EventTypeOutput_2024_06_14[] {
if (Array.isArray(value)) {
return value.map((item) => this.transformEventType(item));
} else {
return this.transformEventType(value);
}
}
}
@@ -1,7 +1,5 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
@@ -13,20 +11,13 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { dynamicEvent } from "@calcom/platform-libraries";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
GetEventTypesQuery_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
import { GetEventTypesQuery_2024_06_14, InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService,
@@ -35,22 +26,13 @@ export class EventTypesService_2024_06_14 {
private readonly schedulesRepository: SchedulesRepository_2024_06_11
) {}
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const {
bookingLimits,
durationLimits,
periodType = undefined,
periodDays = undefined,
periodCountCalendarDays = undefined,
periodStartDate = undefined,
periodEndDate = undefined,
recurrence = undefined,
...bodyTransformed
} = this.inputEventTypesService.transformInputCreateEventType(body);
const { destinationCalendar: _destinationCalendar, ...rest } = body;
const { eventType: eventTypeCreated } = await createEventType({
input: bodyTransformed,
input: rest,
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -62,15 +44,7 @@ export class EventTypesService_2024_06_14 {
await updateEventType({
input: {
id: eventTypeCreated.id,
bookingLimits,
durationLimits,
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
recurrence,
...bodyTransformed,
...body,
},
ctx: {
user: eventTypeUser,
@@ -86,10 +60,13 @@ export class EventTypesService_2024_06_14 {
throw new NotFoundException(`Event type with id ${eventTypeCreated.id} not found`);
}
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
return {
ownerId: user.id,
...eventType,
};
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_06_14) {
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
@@ -109,7 +86,10 @@ export class EventTypesService_2024_06_14 {
return null;
}
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
return {
ownerId: user.id,
...eventType,
};
}
async getEventTypesByUsername(username: string) {
@@ -117,7 +97,7 @@ export class EventTypesService_2024_06_14 {
if (!user) {
return [];
}
return this.getUserEventTypes(user.id);
return await this.getUserEventTypes(user.id);
}
async getUserToCreateEvent(user: UserWithProfile) {
@@ -147,17 +127,19 @@ export class EventTypesService_2024_06_14 {
}
this.checkUserOwnsEventType(userId, eventType);
return this.outputEventTypesService.getResponseEventType(userId, eventType);
return {
ownerId: userId,
...eventType,
};
}
async getUserEventTypes(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
const eventTypePromises = eventTypes.map(async (eventType) => {
return await this.outputEventTypesService.getResponseEventType(userId, eventType);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
});
return await Promise.all(eventTypePromises);
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
@@ -190,7 +172,7 @@ export class EventTypesService_2024_06_14 {
return await getEventTypesPublic(user.id);
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14): Promise<EventTypeOutput_2024_06_14[]> {
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
const { username, eventSlug, usernames } = queryParams;
if (username && eventSlug) {
@@ -218,12 +200,12 @@ export class EventTypesService_2024_06_14 {
usersFiltered.push(user);
}
}
return this.outputEventTypesService.getResponseEventType(0, {
return {
ownerId: 0,
...dynamicEvent,
users: usersFiltered,
isInstantEvent: false,
});
};
}
async createUserDefaultEventTypes(userId: number) {
@@ -239,12 +221,12 @@ export class EventTypesService_2024_06_14 {
return defaultEventTypes;
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
async updateEventType(eventTypeId: number, body: InputEventTransformed_2024_06_14, user: UserWithProfile) {
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
await updateEventType({
input: { id: eventTypeId, ...bodyTransformed },
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -259,7 +241,10 @@ export class EventTypesService_2024_06_14 {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
return {
ownerId: user.id,
...eventType,
};
}
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
@@ -1,4 +1,8 @@
import { Injectable } from "@nestjs/common";
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import {
transformBookingFieldsApiToInternal,
@@ -9,12 +13,85 @@ import {
systemBeforeFieldName,
systemBeforeFieldEmail,
systemAfterFieldRescheduleReason,
EventTypeMetaDataSchema,
systemBeforeFieldLocation,
transformBookerLayoutsApiToInternal,
transformConfirmationPolicyApiToInternal,
transformEventColorsApiToInternal,
validateCustomEventName,
transformSeatsApiToInternal,
} from "@calcom/platform-libraries";
import { systemBeforeFieldLocation } from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import {
CreateEventTypeInput_2024_06_14,
DestinationCalendar_2024_06_14,
InputEventTransformed_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { OutputEventTypesService_2024_06_14 } from "./output-event-types.service";
interface ValidationContext {
eventTypeId?: number;
seatsPerTimeSlot?: number | null;
locations?: InputEventTransformed_2024_06_14["locations"];
requiresConfirmation?: boolean;
eventName?: string;
}
@Injectable()
export class InputEventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly calendarsService: CalendarsService
) {}
async transformAndValidateCreateEventTypeInput(
userId: UserWithProfile["id"],
inputEventType: CreateEventTypeInput_2024_06_14
) {
const transformedBody = this.transformInputCreateEventType(inputEventType);
await this.validateEventTypeInputs({
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
locations: transformedBody.locations,
requiresConfirmation: transformedBody.requiresConfirmation,
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
return transformedBody;
}
async transformAndValidateUpdateEventTypeInput(
inputEventType: UpdateEventTypeInput_2024_06_14,
userId: UserWithProfile["id"],
eventTypeId: number
) {
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
await this.validateEventTypeInputs({
eventTypeId: eventTypeId,
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
locations: transformedBody.locations,
requiresConfirmation: transformedBody.requiresConfirmation,
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
return transformedBody;
}
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
{
@@ -30,9 +107,16 @@ export class InputEventTypesService_2024_06_14 {
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookerLayouts,
confirmationPolicy,
color,
recurrence,
seats,
customName,
useDestinationCalendarEmail,
...rest
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const hasMultipleLocations = (locations || defaultLocations).length > 1;
const eventType = {
@@ -45,25 +129,47 @@ export class InputEventTypesService_2024_06_14 {
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
metadata: {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
},
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
eventTypeColor: this.transformInputEventTypeColor(color),
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
...this.transformInputSeatOptions(seats),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
};
return eventType;
}
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
locations,
bookingFields,
scheduleId,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookerLayouts,
confirmationPolicy,
color,
recurrence,
seats,
customName,
useDestinationCalendarEmail,
...rest
} = inputEventType;
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
const metadataTransformed = !!eventTypeDb?.metadata
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
: {};
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const hasMultipleLocations = !!(locations && locations?.length > 1);
const eventType = {
@@ -73,13 +179,25 @@ export class InputEventTypesService_2024_06_14 {
bookingFields: bookingFields
? this.transformInputBookingFields(bookingFields, hasMultipleLocations)
: undefined,
schedule: scheduleId,
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
metadata: {
...metadataTransformed,
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
},
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
eventTypeColor: this.transformInputEventTypeColor(color),
...this.transformInputSeatOptions(seats),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
};
return eventType;
@@ -114,7 +232,142 @@ export class InputEventTypesService_2024_06_14 {
return !!res ? res : {};
}
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
return transformBookerLayoutsApiToInternal(inputBookerLayouts);
}
transformInputConfirmationPolicy(
requiresConfirmation: CreateEventTypeInput_2024_06_14["confirmationPolicy"]
) {
return transformConfirmationPolicyApiToInternal(requiresConfirmation);
}
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
return transformRecurrenceApiToInternal(recurrence);
}
transformInputEventTypeColor(color: CreateEventTypeInput_2024_06_14["color"]) {
return transformEventColorsApiToInternal(color);
}
transformInputSeatOptions(seats: CreateEventTypeInput_2024_06_14["seats"]) {
return transformSeatsApiToInternal(seats);
}
async validateEventTypeInputs({
eventTypeId,
seatsPerTimeSlot,
locations,
requiresConfirmation,
eventName,
}: ValidationContext) {
let seatsPerTimeSlotDb: number | null = null;
let locationsDb: ReturnType<typeof this.transformInputLocations> = [];
let requiresConfirmationDb = false;
if (eventTypeId != null) {
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithSeats(eventTypeId);
seatsPerTimeSlotDb = eventTypeDb?.seatsPerTimeSlot ?? null;
locationsDb = this.outputEventTypesService.transformLocations(eventTypeDb?.locations) ?? [];
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
}
const seatsPerTimeSlotFinal = seatsPerTimeSlot !== undefined ? seatsPerTimeSlot : seatsPerTimeSlotDb;
const seatsEnabledFinal = seatsPerTimeSlotFinal != null && seatsPerTimeSlotFinal > 0;
const locationsFinal = locations !== undefined ? locations : locationsDb;
const requiresConfirmationFinal =
requiresConfirmation !== undefined ? requiresConfirmation : requiresConfirmationDb;
this.validateSeatsSingleLocationRule(seatsEnabledFinal, locationsFinal);
this.validateSeatsRequiresConfirmationFalseRule(seatsEnabledFinal, requiresConfirmationFinal);
this.validateMultipleLocationsSeatsDisabledRule(locationsFinal, seatsEnabledFinal);
this.validateRequiresConfirmationSeatsDisabledRule(requiresConfirmationFinal, seatsEnabledFinal);
if (eventName) {
await this.validateCustomEventNameInput(eventName);
}
}
validateSeatsSingleLocationRule(
seatsEnabled: boolean,
locations: ReturnType<typeof this.transformInputLocations>
) {
if (seatsEnabled && locations.length > 1) {
throw new BadRequestException(
"Seats Validation failed: Seats are enabled but more than one location provided."
);
}
}
validateSeatsRequiresConfirmationFalseRule(seatsEnabled: boolean, requiresConfirmation: boolean) {
if (seatsEnabled && requiresConfirmation) {
throw new BadRequestException(
"Seats Validation failed: Seats are enabled but requiresConfirmation is true."
);
}
}
validateMultipleLocationsSeatsDisabledRule(
locations: ReturnType<typeof this.transformInputLocations>,
seatsEnabled: boolean
) {
if (locations.length > 1 && seatsEnabled) {
throw new BadRequestException("Locations Validation failed: Multiple locations but seats are enabled.");
}
}
validateRequiresConfirmationSeatsDisabledRule(requiresConfirmation: boolean, seatsEnabled: boolean) {
if (requiresConfirmation && seatsEnabled) {
throw new BadRequestException(
"RequiresConfirmation Validation failed: Seats are enabled but requiresConfirmation is true."
);
}
}
async validateCustomEventNameInput(value: string) {
const validationResult = validateCustomEventName(value);
if (validationResult !== true) {
throw new BadRequestException(`Invalid event name variables: ${validationResult}`);
}
return;
}
async validateInputDestinationCalendar(
userId: number,
destinationCalendar: DestinationCalendar_2024_06_14
) {
const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId);
const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const matchedCalendar = allCals.find(
(cal) =>
cal.externalId === destinationCalendar.externalId &&
cal.integration === destinationCalendar.integration
);
if (!matchedCalendar) {
throw new BadRequestException("Invalid destinationCalendarId: Calendar does not exist");
}
if (matchedCalendar.readOnly) {
throw new BadRequestException("Invalid destinationCalendarId: Calendar does not have write permission");
}
return;
}
async validateInputUseDestinationCalendarEmail(userId: number) {
const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId);
const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const primaryCalendar = allCals.find((cal) => cal.primary);
if (!primaryCalendar) {
throw new BadRequestException(
"Validation failed: A primary connected calendar is required to set useDestinationCalendarEmail"
);
}
return;
}
}
@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule } from "@prisma/client";
import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client";
import {
EventTypeMetaDataSchema,
@@ -15,11 +15,25 @@ import {
transformIntervalLimitsInternalToApi,
transformFutureBookingLimitsInternalToApi,
transformRecurrenceInternalToApi,
transformBookerLayoutsInternalToApi,
transformRequiresConfirmationInternalToApi,
transformEventTypeColorsInternalToApi,
parseEventTypeColor,
transformSeatsInternalToApi,
} from "@calcom/platform-libraries";
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
NoticeThresholdTransformedSchema,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
type EventTypeRelations = {
users: User[];
schedule: Schedule | null;
destinationCalendar?: DestinationCalendar | null;
};
export type DatabaseEventType = EventType & EventTypeRelations;
type Input = Pick<
DatabaseEventType,
@@ -58,11 +72,19 @@ type Input = Pick<
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "requiresBookerEmailVerification"
| "hideCalendarNotes"
| "eventTypeColor"
| "seatsShowAttendees"
| "requiresConfirmationWillBlockSlot"
| "eventName"
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
>;
@Injectable()
export class OutputEventTypesService_2024_06_14 {
async getResponseEventType(ownerId: number, databaseEventType: Input) {
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
const {
id,
length,
@@ -74,8 +96,6 @@ export class OutputEventTypesService_2024_06_14 {
beforeEventBuffer,
afterEventBuffer,
slug,
schedulingType,
requiresConfirmation,
price,
currency,
lockTimeZoneToggleOnBookingPage,
@@ -87,17 +107,34 @@ export class OutputEventTypesService_2024_06_14 {
scheduleId,
onlyShowFirstAvailableSlot,
offsetStart,
requiresBookerEmailVerification,
hideCalendarNotes,
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
: [];
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users);
const users = this.transformUsers(databaseEventType.users || []);
const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits);
const bookingLimitsDuration = this.transformIntervalLimits(databaseEventType.durationLimits);
const color = this.transformEventTypeColor(databaseEventType.eventTypeColor);
const bookerLayouts = this.transformBookerLayouts(
metadata.bookerLayouts as unknown as BookerLayoutsTransformedSchema
);
const confirmationPolicy = this.transformRequiresConfirmation(
!!databaseEventType.requiresConfirmation,
!!databaseEventType.requiresConfirmationWillBlockSlot,
metadata.requiresConfirmationThreshold as NoticeThresholdTransformedSchema
);
delete metadata["bookerLayouts"];
delete metadata["requiresConfirmationThreshold"];
const seats = this.transformSeats(seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount);
const bookingWindow = this.transformBookingWindow({
periodType: databaseEventType.periodType,
periodDays: databaseEventType.periodDays,
@@ -105,6 +142,7 @@ export class OutputEventTypesService_2024_06_14 {
periodStartDate: databaseEventType.periodStartDate,
periodEndDate: databaseEventType.periodEndDate,
} as TransformFutureBookingsLimitSchema_2024_06_14);
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
return {
id,
@@ -121,16 +159,12 @@ export class OutputEventTypesService_2024_06_14 {
minimumBookingNotice,
beforeEventBuffer,
afterEventBuffer,
schedulingType,
metadata,
requiresConfirmation,
price,
currency,
lockTimeZoneToggleOnBookingPage,
seatsPerTimeSlot,
forwardParamsSuccessRedirect,
successRedirectUrl,
seatsShowAvailabilityCount,
isInstantEvent,
users,
scheduleId,
@@ -139,6 +173,15 @@ export class OutputEventTypesService_2024_06_14 {
onlyShowFirstAvailableSlot,
offsetStart,
bookingWindow,
bookerLayouts,
confirmationPolicy,
requiresBookerEmailVerification,
hideCalendarNotes,
color,
seats,
customName,
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
};
}
@@ -147,6 +190,14 @@ export class OutputEventTypesService_2024_06_14 {
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
}
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
if (!destinationCalendar) return undefined;
return {
integration: destinationCalendar.integration,
externalId: destinationCalendar.externalId,
};
}
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
if (!bookingFields) return [];
@@ -189,4 +240,39 @@ export class OutputEventTypesService_2024_06_14 {
transformBookingWindow(bookingLimits: TransformFutureBookingsLimitSchema_2024_06_14) {
return transformFutureBookingLimitsInternalToApi(bookingLimits);
}
transformBookerLayouts(bookerLayouts: BookerLayoutsTransformedSchema) {
if (!bookerLayouts) return undefined;
return transformBookerLayoutsInternalToApi(bookerLayouts);
}
transformRequiresConfirmation(
requiresConfirmation: boolean,
requiresConfirmationWillBlockSlot: boolean,
requiresConfirmationThreshold?: NoticeThresholdTransformedSchema
) {
return transformRequiresConfirmationInternalToApi(
requiresConfirmation,
requiresConfirmationWillBlockSlot,
requiresConfirmationThreshold
);
}
transformEventTypeColor(eventTypeColor: any) {
if (!eventTypeColor) return undefined;
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
return transformEventTypeColorsInternalToApi(parsedeventTypeColor);
}
transformSeats(
seatsPerTimeSlot: number | null,
seatsShowAttendees: boolean | null,
seatsShowAvailabilityCount: boolean | null
) {
return transformSeatsInternalToApi({
seatsPerTimeSlot,
seatsShowAttendees: !!seatsShowAttendees,
seatsShowAvailabilityCount: !!seatsShowAvailabilityCount,
});
}
}
@@ -1,5 +1,6 @@
import { CreatePhoneCallInput } from "@/ee/event-types/event-types_2024_06_14/inputs/create-phone-call.input";
import { CreatePhoneCallOutput } from "@/ee/event-types/event-types_2024_06_14/outputs/create-phone-call.output";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -15,7 +16,10 @@ import { DeleteTeamEventTypeOutput } from "@/modules/organizations/controllers/e
import { GetTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output";
import { GetTeamEventTypesOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output";
import { UpdateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -34,22 +38,33 @@ import {
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { handleCreatePhoneCall } from "@calcom/platform-libraries";
import {
CreateTeamEventTypeInput_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
export type EventTypeHandlerResponse = {
data: DatabaseTeamEventType[] | DatabaseTeamEventType;
status: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
};
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@DocsTags("Organizations Event Types")
export class OrganizationsEventTypesController {
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
constructor(
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService,
private readonly inputUserEventTypesService: InputEventTypesService_2024_06_14,
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
) {}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@@ -61,16 +76,22 @@ export class OrganizationsEventTypesController {
@Param("orgId", ParseIntPipe) orgId: number,
@Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14
): Promise<CreateTeamEventTypeOutput> {
const transformedBody = await this.inputService.transformAndValidateCreateTeamEventTypeInput(
user.id,
teamId,
bodyEventType
);
const eventType = await this.organizationsEventTypesService.createTeamEventType(
user,
teamId,
orgId,
bodyEventType
transformedBody
);
return {
status: SUCCESS_STATUS,
data: eventType,
data: await this.outputTeamEventTypesResponsePipe.transform(eventType),
};
}
@@ -90,7 +111,9 @@ export class OrganizationsEventTypesController {
return {
status: SUCCESS_STATUS,
data: eventType,
data: (await this.outputTeamEventTypesResponsePipe.transform(
eventType
)) as TeamEventTypeOutput_2024_06_14,
};
}
@@ -125,12 +148,13 @@ export class OrganizationsEventTypesController {
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug } = queryParams;
if (eventSlug) {
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
return {
status: SUCCESS_STATUS,
data: eventType ? [eventType] : [],
data: await this.outputTeamEventTypesResponsePipe.transform(eventType ? [eventType] : []),
};
}
@@ -138,7 +162,7 @@ export class OrganizationsEventTypesController {
return {
status: SUCCESS_STATUS,
data: eventTypes,
data: await this.outputTeamEventTypesResponsePipe.transform(eventTypes),
};
}
@@ -155,7 +179,7 @@ export class OrganizationsEventTypesController {
return {
status: SUCCESS_STATUS,
data: eventTypes,
data: await this.outputTeamEventTypesResponsePipe.transform(eventTypes),
};
}
@@ -169,16 +193,23 @@ export class OrganizationsEventTypesController {
@GetUser() user: UserWithProfile,
@Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
): Promise<UpdateTeamEventTypeOutput> {
const transformedBody = await this.inputService.transformAndValidateUpdateTeamEventTypeInput(
user.id,
eventTypeId,
teamId,
bodyEventType
);
const eventType = await this.organizationsEventTypesService.updateTeamEventType(
eventTypeId,
teamId,
bodyEventType,
transformedBody,
user
);
return {
status: SUCCESS_STATUS,
data: eventType,
data: await this.outputTeamEventTypesResponsePipe.transform(eventType),
};
}
@@ -189,7 +220,7 @@ export class OrganizationsEventTypesController {
@HttpCode(HttpStatus.OK)
async deleteTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId") eventTypeId: number
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<DeleteTeamEventTypeOutput> {
const eventType = await this.organizationsEventTypesService.deleteTeamEventType(teamId, eventTypeId);
@@ -17,7 +17,12 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
} from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
@@ -276,6 +281,30 @@ describe("Organizations Event Types Endpoints", () => {
value: 30,
rolling: true,
},
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
count: 60,
unit: NoticeThresholdUnitEnum.MINUTES,
},
blockUnconfirmedBookingsInBooker: true,
},
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
};
return request(app.getHttpServer())
@@ -297,6 +326,12 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(data.offsetStart).toEqual(body.offsetStart);
expect(data.bookingWindow).toEqual(body.bookingWindow);
expect(data.bookerLayouts).toEqual(body.bookerLayouts);
expect(data.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
collectiveEventType = responseBody.data;
});
@@ -444,7 +479,7 @@ describe("Organizations Event Types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
.send(body)
.expect(404);
.expect(400);
});
it("should update collective event-type", async () => {
@@ -0,0 +1,38 @@
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { Injectable, PipeTransform } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class OutputTeamEventTypesResponsePipe implements PipeTransform {
constructor(private readonly outputOrganizationsEventTypesService: OutputOrganizationsEventTypesService) {}
private async transformEventType(item: DatabaseTeamEventType): Promise<TeamEventTypeOutput_2024_06_14> {
return plainToClass(
TeamEventTypeOutput_2024_06_14,
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item),
{ strategy: "exposeAll" }
);
}
// Implementing function overloading to ensure correct return types based on input type:
async transform(value: DatabaseTeamEventType[]): Promise<TeamEventTypeOutput_2024_06_14[]>;
async transform(value: DatabaseTeamEventType): Promise<TeamEventTypeOutput_2024_06_14>;
async transform(
value: DatabaseTeamEventType | DatabaseTeamEventType[]
): Promise<TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[]>;
async transform(
value: DatabaseTeamEventType | DatabaseTeamEventType[]
): Promise<TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[]> {
if (Array.isArray(value)) {
return await Promise.all(value.map((item) => this.transformEventType(item)));
} else {
return await this.transformEventType(value);
}
}
}
@@ -7,6 +7,7 @@ import { OrganizationsOptionsAttributesController } from "@/modules/organization
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
@@ -79,6 +80,7 @@ import { Module } from "@nestjs/common";
OrganizationsWebhooksService,
WebhooksRepository,
WebhooksService,
OutputTeamEventTypesResponsePipe,
],
exports: [
OrganizationsService,
@@ -12,7 +12,7 @@ export class OrganizationsEventTypesRepository {
id: eventTypeId,
teamId,
},
include: { users: true, schedule: true, hosts: true },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
@@ -24,7 +24,7 @@ export class OrganizationsEventTypesRepository {
slug: eventTypeSlug,
},
},
include: { users: true, schedule: true, hosts: true },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
@@ -33,21 +33,21 @@ export class OrganizationsEventTypesRepository {
where: {
teamId,
},
include: { users: true, schedule: true, hosts: true },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { users: true, schedule: true, hosts: true },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
async getEventTypeChildren(eventTypeId: number) {
return this.dbRead.prisma.eventType.findMany({
where: { parentId: eventTypeId },
include: { users: true, schedule: true, hosts: true },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
@@ -60,7 +60,7 @@ export class OrganizationsEventTypesRepository {
},
skip,
take,
include: { users: true, schedule: true, hosts: true },
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
});
}
@@ -2,7 +2,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import {
CreateTeamEventTypeInput_2024_06_14,
@@ -19,37 +19,89 @@ export class InputOrganizationsEventTypesService {
private readonly usersRepository: UsersRepository,
private readonly orgEventTypesRepository: OrganizationsEventTypesRepository
) {}
async transformAndValidateCreateTeamEventTypeInput(
userId: number,
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
await this.validateHosts(teamId, inputEventType.hosts);
const transformedBody = await this.transformInputCreateTeamEventType(teamId, inputEventType);
await this.inputEventTypesService.validateEventTypeInputs({
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
locations: transformedBody.locations,
requiresConfirmation: transformedBody.requiresConfirmation,
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.inputEventTypesService.validateInputDestinationCalendar(
userId,
transformedBody.destinationCalendar
));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
return transformedBody;
}
async transformAndValidateUpdateTeamEventTypeInput(
userId: number,
eventTypeId: number,
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
await this.validateHosts(teamId, inputEventType.hosts);
const transformedBody = await this.transformInputUpdateTeamEventType(eventTypeId, teamId, inputEventType);
await this.inputEventTypesService.validateEventTypeInputs({
eventTypeId: eventTypeId,
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
locations: transformedBody.locations,
requiresConfirmation: transformedBody.requiresConfirmation,
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.inputEventTypesService.validateInputDestinationCalendar(
userId,
transformedBody.destinationCalendar
));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
return transformedBody;
}
async transformInputCreateTeamEventType(
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
const {
hosts,
assignAllTeamMembers,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
...rest
} = inputEventType;
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
const children = await this.getChildEventTypesForManagedEventType(null, inputEventType, teamId);
const metadata = rest.schedulingType === "MANAGED" ? { managedEventConfig: {} } : undefined;
const metadata =
rest.schedulingType === "MANAGED"
? { managedEventConfig: {}, ...eventType.metadata }
: eventType.metadata;
const teamEventType = {
...eventType,
hosts: assignAllTeamMembers
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
: this.transformInputHosts(hosts, inputEventType.schedulingType),
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
hosts: !(rest.schedulingType === "MANAGED")
? assignAllTeamMembers
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
: this.transformInputHosts(hosts, inputEventType.schedulingType)
: undefined,
assignAllTeamMembers,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
metadata,
children,
};
return teamEventType;
@@ -62,7 +114,7 @@ export class InputOrganizationsEventTypesService {
) {
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest);
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
const dbEventType = await this.orgEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!dbEventType) {
@@ -86,14 +138,16 @@ export class InputOrganizationsEventTypesService {
}
async getChildEventTypesForManagedEventType(
eventTypeId: number,
eventTypeId: number | null,
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
teamId: number
) {
const eventType = await this.orgEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
if (!eventType || eventType.schedulingType !== "MANAGED") {
return undefined;
let eventType = null;
if (eventTypeId) {
eventType = await this.orgEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
if (!eventType || eventType.schedulingType !== "MANAGED") {
return undefined;
}
}
const ownersIds = await this.getOwnersIdsForManagedEventType(teamId, inputEventType, eventType);
@@ -110,7 +164,7 @@ export class InputOrganizationsEventTypesService {
async getOwnersIdsForManagedEventType(
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
eventType: { children: { userId: number | null }[] }
eventType: { children: { userId: number | null }[] } | null
) {
if (inputEventType.assignAllTeamMembers) {
return await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
@@ -122,7 +176,7 @@ export class InputOrganizationsEventTypesService {
}
// note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update
return eventType.children.map((child) => child.userId).filter((id) => !!id) as number[];
return eventType?.children.map((child) => child.userId).filter((id) => !!id) as number[];
}
async getOwnersForManagedEventType(userIds: number[]) {
@@ -170,6 +224,16 @@ export class InputOrganizationsEventTypesService {
),
}));
}
async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
if (hosts && hosts.length) {
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId));
if (invalidHosts.length) {
throw new NotFoundException(`Invalid hosts: ${invalidHosts.join(", ")}`);
}
}
}
}
function getPriorityValue(priority: keyof typeof HostPriority): number {
@@ -2,31 +2,23 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesService {
constructor(
private readonly inputService: InputOrganizationsEventTypesService,
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly outputService: OutputOrganizationsEventTypesService,
private readonly membershipsRepository: MembershipsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService
) {}
@@ -34,24 +26,14 @@ export class OrganizationsEventTypesService {
user: UserWithProfile,
teamId: number,
orgId: number,
body: CreateTeamEventTypeInput_2024_06_14
) {
await this.validateHosts(teamId, body.hosts);
body: InputTeamEventTransformed_2024_06_14
): Promise<DatabaseTeamEventType | DatabaseTeamEventType[]> {
const eventTypeUser = await this.getUserToCreateTeamEvent(user, orgId);
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hosts,
assignAllTeamMembers,
locations,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
...rest
} = await this.inputService.transformInputCreateTeamEventType(teamId, body);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { hosts, children, destinationCalendar, ...rest } = body;
const { eventType: eventTypeCreated } = await createEventType({
input: { teamId: teamId, locations, ...rest },
input: { teamId: teamId, ...rest },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -60,31 +42,7 @@ export class OrganizationsEventTypesService {
},
});
return await this.updateTeamEventType(
eventTypeCreated.id,
teamId,
{
hosts: body.hosts,
assignAllTeamMembers,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
...rest,
},
user
);
}
async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
if (hosts && hosts.length) {
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId));
if (invalidHosts.length) {
throw new NotFoundException(`Invalid hosts: ${invalidHosts.join(", ")}`);
}
}
return this.updateTeamEventType(eventTypeCreated.id, teamId, body, user);
}
async validateEventTypeExists(teamId: number, eventTypeId: number) {
@@ -110,17 +68,17 @@ export class OrganizationsEventTypesService {
};
}
async getTeamEventType(teamId: number, eventTypeId: number) {
async getTeamEventType(teamId: number, eventTypeId: number): Promise<DatabaseTeamEventType | null> {
const eventType = await this.organizationEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
return null;
}
return this.outputService.getResponseTeamEventType(eventType);
return eventType;
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string): Promise<DatabaseTeamEventType | null> {
const eventType = await this.organizationEventTypesRepository.getTeamEventTypeBySlug(
teamId,
eventTypeSlug
@@ -130,46 +88,28 @@ export class OrganizationsEventTypesService {
return null;
}
return this.outputService.getResponseTeamEventType(eventType);
return eventType;
}
async getTeamEventTypes(teamId: number) {
const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
const eventTypePromises = eventTypes.map(async (eventType) => {
return await this.outputService.getResponseTeamEventType(eventType);
});
return await Promise.all(eventTypePromises);
async getTeamEventTypes(teamId: number): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
}
async getTeamsEventTypes(orgId: number, skip = 0, take = 250) {
const eventTypes = await this.organizationEventTypesRepository.getTeamsEventTypes(orgId, skip, take);
const eventTypePromises = eventTypes.map(async (eventType) => {
return await this.outputService.getResponseTeamEventType(eventType);
});
return await Promise.all(eventTypePromises);
async getTeamsEventTypes(orgId: number, skip = 0, take = 250): Promise<DatabaseTeamEventType[]> {
return await this.organizationEventTypesRepository.getTeamsEventTypes(orgId, skip, take);
}
async updateTeamEventType(
eventTypeId: number,
teamId: number,
body: UpdateTeamEventTypeInput_2024_06_14,
body: InputTeamEventTransformed_2024_06_14,
user: UserWithProfile
) {
): Promise<DatabaseTeamEventType | DatabaseTeamEventType[]> {
await this.validateEventTypeExists(teamId, eventTypeId);
await this.validateHosts(teamId, body.hosts);
const eventTypeUser = await this.eventTypesService.getUserToUpdateEvent(user);
const bodyTransformed = await this.inputService.transformInputUpdateTeamEventType(
eventTypeId,
teamId,
body
);
await updateEventType({
input: { id: eventTypeId, ...bodyTransformed },
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -185,18 +125,12 @@ export class OrganizationsEventTypesService {
}
if (eventType.schedulingType !== "MANAGED") {
return this.outputService.getResponseTeamEventType(eventType);
return eventType;
}
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventType.id);
const childrenEventTypes = await this.organizationEventTypesRepository.getEventTypeChildren(eventType.id);
const eventTypes = [eventType, ...children];
const eventTypePromises = eventTypes.map(async (e) => {
return await this.outputService.getResponseTeamEventType(e);
});
return await Promise.all(eventTypePromises);
return [eventType, ...childrenEventTypes];
}
async deleteTeamEventType(teamId: number, eventTypeId: number) {
@@ -2,16 +2,21 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host } from "@prisma/client";
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
type DatabaseEventType = EventType & EventTypeRelations;
type EventTypeRelations = {
users: User[];
schedule: Schedule | null;
hosts: Host[];
destinationCalendar?: DestinationCalendar | null;
};
export type DatabaseTeamEventType = EventType & EventTypeRelations;
type Input = Pick<
DatabaseEventType,
DatabaseTeamEventType,
| "id"
| "length"
| "title"
@@ -52,6 +57,14 @@ type Input = Pick<
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "requiresBookerEmailVerification"
| "hideCalendarNotes"
| "lockTimeZoneToggleOnBookingPage"
| "eventTypeColor"
| "seatsShowAttendees"
| "requiresConfirmationWillBlockSlot"
| "eventName"
| "useEventTypeDestinationCalendarEmail"
>;
@Injectable()
@@ -65,7 +78,7 @@ export class OutputOrganizationsEventTypesService {
async getResponseTeamEventType(databaseEventType: Input) {
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = await this.outputEventTypesService.getResponseEventType(
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
0,
databaseEventType
);
@@ -80,6 +93,7 @@ export class OutputOrganizationsEventTypesService {
teamId,
ownerId: userId,
parentEventTypeId: parentId,
schedulingType: databaseEventType.schedulingType,
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
};
}