diff --git a/apps/api/v2/nest-cli.json b/apps/api/v2/nest-cli.json index 69cce1547b..1eecbdbf68 100644 --- a/apps/api/v2/nest-cli.json +++ b/apps/api/v2/nest-cli.json @@ -7,7 +7,7 @@ "plugins": [ { "name": "@nestjs/swagger", - "options": { "dtoFileNameSuffix": ".input.ts", "classValidatorShim": true } + "options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true } } ] } diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts index 4c7df52fa4..7fa074ef0c 100644 --- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts @@ -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 = 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 = 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) { diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts index 9c6df0ff19..4529c296b6 100644 --- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts @@ -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> { - const eventType = await this.eventTypesService.createUserEventType(user.id, body); + ): Promise { + 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> { + 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> { + const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId); + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } } diff --git a/apps/api/v2/src/ee/event-types/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types.module.ts index 1000f637e9..5163794ae9 100644 --- a/apps/api/v2/src/ee/event-types/event-types.module.ts +++ b/apps/api/v2/src/ee/event-types/event-types.module.ts @@ -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], diff --git a/apps/api/v2/src/ee/event-types/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types.repository.ts index ce658bf562..eaf2282b79 100644 --- a/apps/api/v2/src/ee/event-types/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types.repository.ts @@ -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 + ) { 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 } }); + } } diff --git a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts index 9e58159fba..469ea1f4ef 100644 --- a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts +++ b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts @@ -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"; } diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts b/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts new file mode 100644 index 0000000000..819f010f12 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/editable.ts @@ -0,0 +1,7 @@ +export enum Editable { + system = "system", + systemButOptional = "system-but-optional", + systemButHidden = "system-but-hidden", + user = "user", + userReadonly = "user-readonly", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts b/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts new file mode 100644 index 0000000000..e24f7ad635 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/field-type.ts @@ -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", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts b/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts new file mode 100644 index 0000000000..830bb7abfc --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/frequency.ts @@ -0,0 +1,9 @@ +export enum Frequency { + YEARLY = 0, + MONTHLY = 1, + WEEKLY = 2, + DAILY = 3, + HOURLY = 4, + MINUTELY = 5, + SECONDLY = 6, +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts b/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts new file mode 100644 index 0000000000..95c0e138b7 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/period-type.ts @@ -0,0 +1,5 @@ +export enum PeriodType { + UNLIMITED = "UNLIMITED", + ROLLING = "ROLLING", + RANGE = "RANGE", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts b/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts new file mode 100644 index 0000000000..cc581daa4f --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/enums/scheduling-type.ts @@ -0,0 +1,5 @@ +export enum SchedulingType { + ROUND_ROBIN = "ROUND_ROBIN", + COLLECTIVE = "COLLECTIVE", + MANAGED = "MANAGED", +} diff --git a/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts b/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts new file mode 100644 index 0000000000..a2584ec20e --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/event-type-location.input.ts @@ -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; +} diff --git a/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts new file mode 100644 index 0000000000..d03e451d44 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/update-event-type.input.ts @@ -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; +} + +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; + + @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; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts new file mode 100644 index 0000000000..1a77102d4c --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/create-event-type.output.ts @@ -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; +} diff --git a/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts b/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts new file mode 100644 index 0000000000..cd10478575 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/outputs/event-type.output.ts @@ -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 | 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; +} diff --git a/apps/api/v2/src/ee/event-types/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/services/event-types.service.ts index 08c9da3c15..9edee1a43f 100644 --- a/apps/api/v2/src/ee/event-types/services/event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/services/event-types.service.ts @@ -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 { @@ -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) { + if (userId !== eventType.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`); + } + } } diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts index ef13910396..8153f2ac4f 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts @@ -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 {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts index a4a1bb5e7b..a0a220e48d 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -16,4 +16,12 @@ export class SelectedCalendarsRepository { }, }); } + + getUserSelectedCalendars(userId: number) { + return this.dbRead.prisma.selectedCalendar.findMany({ + where: { + userId, + }, + }); + } } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 917e88b7dd..ec595b86be 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -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", diff --git a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts index 03e98a4dd4..46e3cc0c03 100644 --- a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts @@ -21,7 +21,7 @@ export class EventTypesRepositoryFixture { }); } - async create(data: CreateEventTypeInput, userId: number) { + async create(data: Pick, userId: number) { return this.prismaWriteClient.eventType.create({ data: { ...data, diff --git a/packages/lib/server/getUsersCredentials.ts b/packages/lib/server/getUsersCredentials.ts index d547b0378f..77560e97cf 100644 --- a/packages/lib/server/getUsersCredentials.ts +++ b/packages/lib/server/getUsersCredentials.ts @@ -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) { +type SessionUser = NonNullable; +type User = { id: SessionUser["id"] }; + +export async function getUsersCredentials(user: User) { const credentials = await prisma.credential.findMany({ where: { userId: user.id, diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 57f745c148..170739ca00 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -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>; export { getPublicEvent }; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 8f9c5498c7..902686d421 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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; + export const orgSettingsSchema = z .object({ isOrganizationVerified: z.boolean().optional(), diff --git a/packages/prisma/zod/custom/eventtype.ts b/packages/prisma/zod/custom/eventtype.ts index 51e5c6fd06..f3a9f49d2d 100755 --- a/packages/prisma/zod/custom/eventtype.ts +++ b/packages/prisma/zod/custom/eventtype.ts @@ -28,3 +28,5 @@ export const createEventTypeInput = z.object({ description: z.string(), length: z.number(), }).strict(); + +export type EventTypeLocation = (z.infer)[number]; \ No newline at end of file diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts index e555327ddc..2c6655dca9 100644 --- a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts @@ -7,9 +7,15 @@ import { TRPCError } from "@trpc/server"; import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; +type SessionUser = NonNullable; +type User = { + id: SessionUser["id"]; + selectedCalendars: SessionUser["selectedCalendars"]; +}; + type SetDestinationCalendarOptions = { ctx: { - user: NonNullable; + user: User; }; input: TSetDestinationCalendarInputSchema; }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 16734d86af..cae1570a41 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -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; +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; + 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 { + 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 []; +} diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index baaedd8d74..1299ef7338 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -18,15 +18,27 @@ import { setDestinationCalendarHandler } from "../../loggedInViewer/setDestinati import type { TUpdateInputSchema } from "./update.schema"; import { ensureUniqueBookingFields, handleCustomInputs, handlePeriodType } from "./util"; +type SessionUser = NonNullable; +type User = { + id: SessionUser["id"]; + username: SessionUser["username"]; + profile: { + id: SessionUser["profile"]["id"] | null; + }; + selectedCalendars: SessionUser["selectedCalendars"]; +}; + type UpdateOptions = { ctx: { - user: NonNullable; + user: User; res?: NextApiResponse | GetServerSidePropsContext["res"]; prisma: PrismaClient; }; input: TUpdateInputSchema; }; +export type UpdateEventTypeReturn = Awaited>; + export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const { schedule,