feat: v2 event-types POST, PATCH, DELETE endpoints (#14237)

* create event type: use createEventType from trpc handler

* create event type: simplify handler input and create e2e test

* update event type + test

* delete end point

* check if owner owns event type

* fix: starting v2

* fixes

* remove unused enum

* fix TS issue for update input

* docs refresh

* docs

* docs

* simplify event types docs

* docs refactor

* fix: check if user already has event type with existing slug

* refactor update into a service

* fix tests

* remove unused import

* update create event link

* refactor: create event handler process passed locations

* move input out of folder

* reuse eventtype location for inputs

* docs: example POST event-type return

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2024-04-02 15:50:12 +02:00
committed by GitHub
co-authored by Rajiv Sahal Morgan
parent 2cbf9e1ac8
commit 3b263e9282
27 changed files with 1329 additions and 80 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "dtoFileNameSuffix": ".input.ts", "classValidatorShim": true }
"options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true }
}
]
}
@@ -1,6 +1,8 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -61,7 +63,7 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const name = "bob";
const name = "bob the builder";
const username = name;
let eventType: EventType;
let user: User;
@@ -86,6 +88,7 @@ describe("Event types Endpoints", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -95,17 +98,6 @@ describe("Event types Endpoints", () => {
username,
});
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
eventType = await eventTypesRepositoryFixture.create(
{
length: 60,
title: "peer coding session",
slug: "peer-coding",
},
user.id
);
await app.init();
});
@@ -129,6 +121,50 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined();
});
it("should create an event type", async () => {
const body: CreateEventTypeInput = {
title: "Test Event Type",
slug: "test-event-type",
description: "A description of the test event type.",
length: 60,
hidden: false,
locations: [
{
type: "Online",
link: "https://example.com/meet",
displayLocationPublicly: true,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data.title).toEqual(body.title);
eventType = responseBody.data;
});
});
it("should update event type", async () => {
const newTitle = "Updated title";
const body: UpdateEventTypeInput = {
title: newTitle,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async () => {
eventType.title = newTitle;
});
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -154,10 +190,8 @@ describe("Event types Endpoints", () => {
.expect(200);
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
@@ -178,7 +212,6 @@ describe("Event types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(eventType.id);
@@ -192,10 +225,18 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await eventTypesRepositoryFixture.delete(eventType.id);
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
@@ -1,4 +1,6 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -6,12 +8,29 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common";
import {
Controller,
UseGuards,
Get,
Param,
Post,
Body,
NotFoundException,
Patch,
HttpCode,
HttpStatus,
Delete,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EventType } from "@prisma/client";
import { EventTypesByViewer } from "@calcom/lib";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventType as AtomEventType, EventTypesPublic } from "@calcom/platform-libraries";
import type {
EventType as AtomEventType,
EventTypesPublic,
UpdateEventTypeReturn,
} from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
@@ -20,6 +39,7 @@ import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
version: "2",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
@@ -29,8 +49,8 @@ export class EventTypesController {
async createEventType(
@Body() body: CreateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<EventType>> {
const eventType = await this.eventTypesService.createUserEventType(user.id, body);
): Promise<CreateEventTypeOutput> {
const eventType = await this.eventTypesService.createUserEventType(user, body);
return {
status: SUCCESS_STATUS,
@@ -89,4 +109,36 @@ export class EventTypesController {
data: eventTypes,
};
}
@Patch("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.OK)
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Body() body: UpdateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<UpdateEventTypeReturn["eventType"]>> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Delete("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
): Promise<ApiResponse<EventType>> {
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
}
@@ -3,12 +3,13 @@ import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule],
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
providers: [EventTypesRepository, EventTypesService],
controllers: [EventTypesController],
exports: [EventTypesService, EventTypesRepository],
@@ -10,7 +10,10 @@ import { getEventTypeById } from "@calcom/platform-libraries";
export class EventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
async createUserEventType(
userId: number,
body: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">
) {
return this.dbWrite.prisma.eventType.create({
data: {
...body,
@@ -59,4 +62,19 @@ export class EventTypesRepository {
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } });
}
async getUserEventTypeBySlug(userId: number, slug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
userId_slug: {
userId: userId,
slug: slug,
},
},
});
}
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
}
@@ -1,13 +1,53 @@
import { IsNumber, IsString, Min } from "class-validator";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator";
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!";
export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
"Discover the culinary wonders of the Argentina by making the best flan ever!";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class CreateEventTypeInput {
@IsNumber()
@Min(1)
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsOptional()
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
hidden?: boolean;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations?: EventTypeLocation[];
// @ApiHideProperty()
// @IsOptional()
// @IsNumber()
// teamId?: number;
// @ApiHideProperty()
// @IsOptional()
// @IsEnum(SchedulingType)
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
}
@@ -0,0 +1,7 @@
export enum Editable {
system = "system",
systemButOptional = "system-but-optional",
systemButHidden = "system-but-hidden",
user = "user",
userReadonly = "user-readonly",
}
@@ -0,0 +1,16 @@
export enum BaseField {
number = "number",
boolean = "boolean",
address = "address",
name = "name",
text = "text",
textarea = "textarea",
email = "email",
phone = "phone",
multiemail = "multiemail",
select = "select",
multiselect = "multiselect",
checkbox = "checkbox",
radio = "radio",
radioInput = "radioInput",
}
@@ -0,0 +1,9 @@
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
SECONDLY = 6,
}
@@ -0,0 +1,5 @@
export enum PeriodType {
UNLIMITED = "UNLIMITED",
ROLLING = "ROLLING",
RANGE = "RANGE",
}
@@ -0,0 +1,5 @@
export enum SchedulingType {
ROUND_ROBIN = "ROUND_ROBIN",
COLLECTIVE = "COLLECTIVE",
MANAGED = "MANAGED",
}
@@ -0,0 +1,41 @@
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class EventTypeLocation {
@IsString()
@DocsProperty({ example: "link" })
type!: string;
@IsOptional()
@IsString()
@ApiHideProperty()
address?: string;
@IsOptional()
@IsUrl()
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
link?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
displayLocationPublicly?: boolean;
@IsOptional()
@IsString()
@ApiHideProperty()
hostPhoneNumber?: string;
@IsOptional()
@IsNumber()
@ApiHideProperty()
credentialId?: number;
@IsOptional()
@IsString()
@ApiHideProperty()
teamName?: string;
}
@@ -0,0 +1,412 @@
import { Editable } from "@/ee/event-types/inputs/enums/editable";
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
import { Frequency } from "@/ee/event-types/inputs/enums/frequency";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { Type } from "class-transformer";
import {
IsString,
IsBoolean,
IsOptional,
ValidateNested,
Min,
IsInt,
IsEnum,
IsArray,
IsDate,
IsNumber,
} from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
class Option {
@IsString()
value!: string;
@IsString()
label!: string;
}
class Source {
@IsString()
id!: string;
@IsString()
type!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
editUrl?: string;
@IsOptional()
@IsBoolean()
fieldRequired?: boolean;
}
class View {
@IsString()
id!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
description?: string;
}
class OptionsInput {
@IsString()
type!: "address" | "text" | "phone";
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
placeholder?: string;
}
class VariantField {
@IsString()
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
}
class Variant {
@ValidateNested({ each: true })
@Type(() => VariantField)
fields!: VariantField[];
}
class VariantsConfig {
variants!: Record<string, Variant>;
}
export class BookingField {
@IsEnum(BaseField)
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Option)
options?: Option[];
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
defaultLabel?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
getOptionsAt?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OptionsInput)
optionsInputs?: Record<string, OptionsInput>;
@IsOptional()
@IsString()
variant?: string;
@IsOptional()
@ValidateNested()
@Type(() => VariantsConfig)
variantsConfig?: VariantsConfig;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => View)
views?: View[];
@IsOptional()
@IsBoolean()
hideWhenJustOneOption?: boolean;
@IsOptional()
@IsBoolean()
hidden?: boolean;
@IsOptional()
@IsEnum(Editable)
editable?: Editable;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Source)
sources?: Source[];
}
export class RecurringEvent {
@IsDate()
@IsOptional()
dtstart?: Date;
@IsInt()
interval!: number;
@IsInt()
count!: number;
@IsEnum(Frequency)
freq!: Frequency;
@IsDate()
@IsOptional()
until?: Date;
@IsString()
@IsOptional()
tzid?: string;
}
export class IntervalLimits {
@IsNumber()
@IsOptional()
PER_DAY?: number;
@IsNumber()
@IsOptional()
PER_WEEK?: number;
@IsNumber()
@IsOptional()
PER_MONTH?: number;
@IsNumber()
@IsOptional()
PER_YEAR?: number;
}
export class UpdateEventTypeInput {
@IsInt()
@Min(1)
@IsOptional()
length?: number;
@IsString()
@IsOptional()
slug?: string;
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
hidden?: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsOptional()
locations?: EventTypeLocation[];
// @IsInt()
// @IsOptional()
// position?: number;
// @IsInt()
// @IsOptional()
// offsetStart?: number;
// @IsInt()
// @IsOptional()
// userId?: number;
// @IsInt()
// @IsOptional()
// profileId?: number;
// @IsInt()
// @IsOptional()
// teamId?: number;
// @IsString()
// @IsOptional()
// eventName?: string;
// @IsInt()
// @IsOptional()
// parentId?: number;
// @IsOptional()
// @IsArray()
// @ValidateNested({ each: true })
// @Type(() => BookingField)
// bookingFields?: BookingField[];
// @IsString()
// @IsOptional()
// timeZone?: string;
// @IsEnum(PeriodType)
// @IsOptional()
// periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
// @IsDate()
// @IsOptional()
// periodStartDate?: Date;
// @IsDate()
// @IsOptional()
// periodEndDate?: Date;
// @IsInt()
// @IsOptional()
// periodDays?: number;
// @IsBoolean()
// @IsOptional()
// periodCountCalendarDays?: boolean;
// @IsBoolean()
// @IsOptional()
// lockTimeZoneToggleOnBookingPage?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresConfirmation?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresBookerEmailVerification?: boolean;
// @ValidateNested()
// @Type(() => RecurringEvent)
// @IsOptional()
// recurringEvent?: RecurringEvent;
// @IsBoolean()
// @IsOptional()
// disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
// hideCalendarNotes?: boolean;
// @IsInt()
// @Min(0)
// @IsOptional()
// minimumBookingNotice?: number;
// @IsInt()
// @IsOptional()
// beforeEventBuffer?: number;
// @IsInt()
// @IsOptional()
// afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
// seatsPerTimeSlot?: number;
// @IsBoolean()
// @IsOptional()
// onlyShowFirstAvailableSlot?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAttendees?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAvailabilityCount?: boolean;
// @IsEnum(SchedulingType)
// @IsOptional()
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
// @IsInt()
// @IsOptional()
// scheduleId?: number;
// @IsInt()
// @IsOptional()
// price?: number;
// @IsString()
// @IsOptional()
// currency?: string;
// @IsInt()
// @IsOptional()
// slotInterval?: number;
// @IsString()
// @IsOptional()
// @IsUrl()
// successRedirectUrl?: string;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// bookingLimits?: IntervalLimits;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// durationLimits?: IntervalLimits;
// @IsBoolean()
// @IsOptional()
// isInstantEvent?: boolean;
// @IsBoolean()
// @IsOptional()
// assignAllTeamMembers?: boolean;
// @IsBoolean()
// @IsOptional()
// useEventTypeDestinationCalendarEmail?: boolean;
// @IsInt()
// @IsOptional()
// secondaryEmailId?: number;
}
@@ -0,0 +1,20 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => EventTypeOutput)
data!: EventTypeOutput;
}
@@ -0,0 +1,227 @@
import {
CREATE_EVENT_DESCRIPTION_EXAMPLE,
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import {
BookingField,
IntervalLimits,
RecurringEvent,
} from "@/ee/event-types/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsInt,
IsJSON,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export class EventTypeOutput {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description!: string | null;
@IsBoolean()
@ApiHideProperty()
hidden!: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations!: EventTypeLocation | null;
@IsInt()
@ApiHideProperty()
position!: number;
@IsInt()
@ApiHideProperty()
offsetStart!: number;
@IsInt()
@ApiHideProperty()
userId!: number | null;
@IsInt()
@ApiHideProperty()
profileId!: number | null;
@IsInt()
@ApiHideProperty()
teamId!: number | null;
@IsString()
@ApiHideProperty()
eventName!: string | null;
@IsInt()
@ApiHideProperty()
parentId!: number | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
@ApiHideProperty()
bookingFields!: BookingField[] | null;
@IsString()
@ApiHideProperty()
timeZone!: string | null;
@IsEnum(PeriodType)
@ApiHideProperty()
periodType!: PeriodType | null;
@IsDate()
@ApiHideProperty()
periodStartDate!: Date | null;
@IsDate()
@ApiHideProperty()
periodEndDate!: Date | null;
@IsInt()
@ApiHideProperty()
periodDays!: number | null;
@IsBoolean()
@ApiHideProperty()
periodCountCalendarDays!: boolean | null;
@IsBoolean()
@ApiHideProperty()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresConfirmation!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresBookerEmailVerification!: boolean;
@ValidateNested()
@Type(() => RecurringEvent)
@IsOptional()
@ApiHideProperty()
recurringEvent!: RecurringEvent | null;
@IsBoolean()
@ApiHideProperty()
disableGuests!: boolean;
@IsBoolean()
@ApiHideProperty()
hideCalendarNotes!: boolean;
@IsInt()
@ApiHideProperty()
minimumBookingNotice!: number;
@IsInt()
@ApiHideProperty()
beforeEventBuffer!: number;
@IsInt()
@ApiHideProperty()
afterEventBuffer!: number;
@IsInt()
@ApiHideProperty()
seatsPerTimeSlot!: number | null;
@IsBoolean()
@ApiHideProperty()
onlyShowFirstAvailableSlot!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAttendees!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAvailabilityCount!: boolean;
@IsEnum(SchedulingType)
@ApiHideProperty()
schedulingType!: SchedulingType | null;
@IsInt()
@ApiHideProperty()
scheduleId!: number | null;
@IsNumber()
@ApiHideProperty()
price!: number;
@IsString()
@ApiHideProperty()
currency!: string;
@IsInt()
@ApiHideProperty()
slotInterval!: number | null;
@IsJSON()
@ApiHideProperty()
metadata!: Record<string, any> | null;
@IsString()
@ApiHideProperty()
successRedirectUrl!: string | null;
@ValidateNested()
@Type(() => IntervalLimits)
@IsOptional()
@ApiHideProperty()
bookingLimits!: IntervalLimits;
@ValidateNested()
@Type(() => IntervalLimits)
@ApiHideProperty()
durationLimits!: IntervalLimits;
@IsBoolean()
@ApiHideProperty()
isInstantEvent!: boolean;
@IsBoolean()
@ApiHideProperty()
assignAllTeamMembers!: boolean;
@IsBoolean()
@ApiHideProperty()
useEventTypeDestinationCalendarEmail!: boolean;
@IsInt()
@ApiHideProperty()
secondaryEmailId!: number | null;
}
@@ -1,26 +1,73 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypesService {
constructor(
private readonly eventTypesRepository: EventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
return this.eventTypesRepository.createUserEventType(userId, body);
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { eventType } = await createEventType({
input: body,
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return eventType;
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
}
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
metadata: user.metadata,
};
}
async getUserEventType(userId: number, eventTypeId: number) {
return this.eventTypesRepository.getUserEventType(userId, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(userId, eventType);
return eventType;
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
@@ -30,7 +77,18 @@ export class EventTypesService {
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
return this.eventTypesRepository.getUserEventTypeForAtom(user, isUserOrganizationAdmin, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
user,
isUserOrganizationAdmin,
eventTypeId
);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -58,4 +116,51 @@ export class EventTypesService {
return defaultEventTypes;
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const { eventType } = await updateEventType({
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return eventType;
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
const existingEventType = await this.getUserEventType(userId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.checkUserOwnsEventType(userId, existingEventType);
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
async deleteEventType(eventTypeId: number, userId: number) {
const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
}
this.checkUserOwnsEventType(userId, existingEventType);
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
checkUserOwnsEventType(userId: number, eventType: Pick<EventType, "id" | "userId">) {
if (userId !== eventType.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
}
}
}
@@ -1,9 +1,10 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [],
imports: [PrismaModule],
providers: [SelectedCalendarsRepository],
exports: [SelectedCalendarsRepository],
})
export class CredentialsModule {}
export class SelectedCalendarsModule {}
@@ -16,4 +16,12 @@ export class SelectedCalendarsRepository {
},
});
}
getUserSelectedCalendars(userId: number) {
return this.dbRead.prisma.selectedCalendar.findMany({
where: {
userId,
},
});
}
}
+211 -14
View File
@@ -533,12 +533,15 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/CreateEventTypeOutput"
}
}
}
}
}
},
"tags": [
"Event types"
]
},
"get": {
"operationId": "EventTypesController_getEventTypes",
@@ -554,7 +557,10 @@
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/event-types/{eventTypeId}": {
@@ -581,7 +587,76 @@
}
}
}
}
},
"tags": [
"Event types"
]
},
"patch": {
"operationId": "EventTypesController_updateEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateEventTypeInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Event types"
]
},
"delete": {
"operationId": "EventTypesController_deleteEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/event-types/{username}/public": {
@@ -608,7 +683,10 @@
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/ee/gcal/oauth/auth-url": {
@@ -1324,11 +1402,11 @@
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
],
"example": "success"
},
"data": {
"example": {
@@ -1367,7 +1445,8 @@
"example": 3
},
"logo": {
"type": "object",
"type": "string",
"nullable": true,
"example": "https://example.com/logo.png"
},
"redirectUris": {
@@ -1530,7 +1609,118 @@
"refreshToken"
]
},
"EventTypeLocation": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "link"
},
"link": {
"type": "string",
"example": "https://masterchief.com/argentina/flan/video/9129412"
}
},
"required": [
"type"
]
},
"CreateEventTypeInput": {
"type": "object",
"properties": {
"length": {
"type": "number",
"minimum": 1,
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
}
},
"required": [
"length",
"slug",
"title"
]
},
"EventTypeOutput": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"length": {
"type": "number",
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"nullable": true,
"example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
"locations": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/EventTypeLocation"
}
]
}
},
"required": [
"id",
"length",
"slug",
"title",
"description",
"locations"
]
},
"CreateEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/EventTypeOutput"
}
},
"required": [
"status",
"data"
]
},
"UpdateEventTypeInput": {
"type": "object",
"properties": {
"length": {
@@ -1542,13 +1732,20 @@
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
}
},
"required": [
"length",
"slug",
"title"
]
}
},
"CreateAvailabilityInput": {
"type": "object",
@@ -21,7 +21,7 @@ export class EventTypesRepositoryFixture {
});
}
async create(data: CreateEventTypeInput, userId: number) {
async create(data: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">, userId: number) {
return this.prismaWriteClient.eventType.create({
data: {
...data,
+4 -1
View File
@@ -2,7 +2,10 @@ import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
export async function getUsersCredentials(user: NonNullable<TrpcSessionUser>) {
type SessionUser = NonNullable<TrpcSessionUser>;
type User = { id: SessionUser["id"] };
export async function getUsersCredentials(user: User) {
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
+5
View File
@@ -19,10 +19,15 @@ export type UpdateScheduleOutputType = Awaited<
export { getEventTypeById } from "@calcom/lib/event-types/getEventTypeById";
export { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
export { getEventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
export { createHandler as createEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/create.handler";
export { updateHandler as updateEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/update.handler";
export { SchedulingType, PeriodType } from "@calcom/prisma/enums";
export type { EventType } from "@calcom/lib/event-types/getEventTypeById";
export type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
export type { EventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
export type { UpdateEventTypeReturn } from "@calcom/trpc/server/routers/viewer/eventTypes/update.handler";
export type PublicEventType = Awaited<ReturnType<typeof getPublicEvent>>;
export { getPublicEvent };
+8 -6
View File
@@ -318,6 +318,11 @@ export const createdEventSchema = z
})
.passthrough();
const schemaDefaultConferencingApp = z.object({
appSlug: z.string().default("daily-video").optional(),
appLink: z.string().optional(),
});
export const userMetadata = z
.object({
proPaidForByTeamId: z.number().optional(),
@@ -325,12 +330,7 @@ export const userMetadata = z
vitalSettings: vitalSettingsUpdateSchema.optional(),
isPremium: z.boolean().optional(),
sessionTimeout: z.number().optional(), // Minutes
defaultConferencingApp: z
.object({
appSlug: z.string().default("daily-video").optional(),
appLink: z.string().optional(),
})
.optional(),
defaultConferencingApp: schemaDefaultConferencingApp.optional(),
defaultBookerLayouts: bookerLayouts.optional(),
emailChangeWaitingForVerification: z
.string()
@@ -347,6 +347,8 @@ export const userMetadata = z
})
.nullable();
export type DefaultConferencingApp = z.infer<typeof schemaDefaultConferencingApp>;
export const orgSettingsSchema = z
.object({
isOrganizationVerified: z.boolean().optional(),
+2
View File
@@ -28,3 +28,5 @@ export const createEventTypeInput = z.object({
description: z.string(),
length: z.number(),
}).strict();
export type EventTypeLocation = (z.infer<typeof imports.eventTypeLocations>)[number];
@@ -7,9 +7,15 @@ import { TRPCError } from "@trpc/server";
import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
type SessionUser = NonNullable<TrpcSessionUser>;
type User = {
id: SessionUser["id"];
selectedCalendars: SessionUser["selectedCalendars"];
};
type SetDestinationCalendarOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: User;
};
input: TSetDestinationCalendarInputSchema;
};
@@ -9,49 +9,43 @@ import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import type { PrismaClient } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeLocation } from "@calcom/prisma/zod/custom/eventtype";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCreateInputSchema } from "./create.schema";
type SessionUser = NonNullable<TrpcSessionUser>;
type User = {
id: SessionUser["id"];
organizationId: SessionUser["organizationId"];
organization: {
isOrgAdmin: SessionUser["organization"]["isOrgAdmin"];
};
profile: {
id: SessionUser["id"] | null;
};
metadata: SessionUser["metadata"];
};
type CreateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: User;
prisma: PrismaClient;
};
input: TCreateInputSchema;
};
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { schedulingType, teamId, metadata, ...rest } = input;
const { schedulingType, teamId, metadata, locations: inputLocations, ...rest } = input;
const userId = ctx.user.id;
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
const isOrgAdmin = !!ctx.user?.organization?.isOrgAdmin;
// Get Users default conferencing app
const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp;
const appKeys = await getAppKeysFromSlug("daily-video");
let locations: { type: string; link?: string }[] = [];
// If no locations are passed in and the user has a daily api key then default to daily
if (
(typeof rest?.locations === "undefined" || rest.locations?.length === 0) &&
typeof appKeys.api_key === "string"
) {
locations = [{ type: DailyLocationType }];
}
if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") {
const credentials = await getUsersCredentials(ctx.user);
const foundApp = getApps(credentials, true).filter(
(app) => app.slug === defaultConferencingData.appSlug
)[0]; // There is only one possible install here so index [0] is the one we are looking for ;
const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found
locations = [{ type: locationType, link: defaultConferencingData.appLink }];
}
const locations: EventTypeLocation[] =
inputLocations && inputLocations.length !== 0 ? inputLocations : await getDefaultLocations(ctx.user);
const data: Prisma.EventTypeCreateInput = {
...rest,
@@ -122,3 +116,23 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
throw new TRPCError({ code: "BAD_REQUEST" });
}
};
async function getDefaultLocations(user: User): Promise<EventTypeLocation[]> {
const defaultConferencingData = userMetadataSchema.parse(user.metadata)?.defaultConferencingApp;
const appKeys = await getAppKeysFromSlug("daily-video");
if (typeof appKeys.api_key === "string") {
return [{ type: DailyLocationType }];
}
if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") {
const credentials = await getUsersCredentials(user);
const foundApp = getApps(credentials, true).filter(
(app) => app.slug === defaultConferencingData.appSlug
)[0]; // There is only one possible install here so index [0] is the one we are looking for ;
const locationType = foundApp?.locationOption?.value ?? DailyLocationType; // Default to Daily if no location type is found
return [{ type: locationType, link: defaultConferencingData.appLink }];
}
return [];
}
@@ -18,15 +18,27 @@ import { setDestinationCalendarHandler } from "../../loggedInViewer/setDestinati
import type { TUpdateInputSchema } from "./update.schema";
import { ensureUniqueBookingFields, handleCustomInputs, handlePeriodType } from "./util";
type SessionUser = NonNullable<TrpcSessionUser>;
type User = {
id: SessionUser["id"];
username: SessionUser["username"];
profile: {
id: SessionUser["profile"]["id"] | null;
};
selectedCalendars: SessionUser["selectedCalendars"];
};
type UpdateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: User;
res?: NextApiResponse | GetServerSidePropsContext["res"];
prisma: PrismaClient;
};
input: TUpdateInputSchema;
};
export type UpdateEventTypeReturn = Awaited<ReturnType<typeof updateHandler>>;
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const {
schedule,