diff --git a/.github/workflows/e2e-api-v2.yml b/.github/workflows/e2e-api-v2.yml index 91682e2fd6bbc3..dcd7c483228498 100644 --- a/.github/workflows/e2e-api-v2.yml +++ b/.github/workflows/e2e-api-v2.yml @@ -15,7 +15,7 @@ env: IS_E2E: true NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }} NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }} - NODE_OPTIONS: --max-old-space-size=4096 + NODE_OPTIONS: --max-old-space-size=29000 REDIS_URL: "redis://localhost:6379" STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} diff --git a/apps/api/v2/jest-e2e.json b/apps/api/v2/jest-e2e.json index a0c04f1046cf49..fe8f6ba838c8db 100644 --- a/apps/api/v2/jest-e2e.json +++ b/apps/api/v2/jest-e2e.json @@ -11,5 +11,7 @@ "^.+\\.(t|j)s$": "ts-jest" }, "setupFiles": ["/test/setEnvVars.ts"], - "reporters": ["default", "jest-summarizing-reporter"] + "reporters": ["default", "jest-summarizing-reporter"], + "workerIdleMemoryLimit": "512MB", + "maxWorkers": 2 } diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 1e3b4f1c8af040..c2cad589ac0f96 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -19,7 +19,7 @@ "test:watch": "yarn dev:build && jest --watch", "test:cov": "yarn dev:build && jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json", + "test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json", "test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch", "prisma": "yarn workspace @calcom/prisma prisma", "generate-schemas": "yarn prisma generate && yarn prisma format", @@ -28,7 +28,7 @@ "dependencies": { "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.36", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.37", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts similarity index 80% rename from apps/api/v2/src/ee/bookings/bookings.module.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts index b91c65392ec13c..de0b63ddf377cb 100644 --- a/apps/api/v2/src/ee/bookings/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts @@ -1,4 +1,4 @@ -import { BookingsController } from "@/ee/bookings/controllers/bookings.controller"; +import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { BillingModule } from "@/modules/billing/billing.module"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; @@ -12,6 +12,6 @@ import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule, RedisModule, TokensModule, BillingModule], providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository], - controllers: [BookingsController], + controllers: [BookingsController_2024_04_15], }) -export class BookingsModule {} +export class BookingsModule_2024_04_15 {} diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts similarity index 95% rename from apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts index 698c869e9f08da..0e249c11fdb636 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts @@ -1,8 +1,8 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; -import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; -import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; -import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output"; +import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; @@ -24,7 +24,7 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; import { handleNewBooking } from "@calcom/platform-libraries"; import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types"; -describe("Bookings Endpoints", () => { +describe("Bookings Endpoints 2024-04-15", () => { describe("User Authenticated", () => { let app: INestApplication; @@ -111,7 +111,7 @@ describe("Bookings Endpoints", () => { guests: [], }; - const body: CreateBookingInput = { + const body: CreateBookingInput_2024_04_15 = { start: bookingStart, end: bookingEnd, eventTypeId: bookingEventTypeId, @@ -166,7 +166,7 @@ describe("Bookings Endpoints", () => { guests: [], }; - const body: CreateBookingInput = { + const body: CreateBookingInput_2024_04_15 = { start: bookingStart, end: bookingEnd, eventTypeId: bookingEventTypeId, @@ -209,7 +209,7 @@ describe("Bookings Endpoints", () => { guests: [], }; - const body: CreateBookingInput = { + const body: CreateBookingInput_2024_04_15 = { start: bookingStart, end: bookingEnd, eventTypeId: bookingEventTypeId, @@ -266,7 +266,7 @@ describe("Bookings Endpoints", () => { guests: [], }; - const body: CreateBookingInput = { + const body: CreateBookingInput_2024_04_15 = { rescheduleUid: createdBooking.uid, start: newBookingStart, end: newBookingEnd, @@ -305,7 +305,7 @@ describe("Bookings Endpoints", () => { return request(app.getHttpServer()) .get("/v2/bookings?filters[status]=upcoming") .then((response) => { - const responseBody: GetBookingsOutput = response.body; + const responseBody: GetBookingsOutput_2024_04_15 = response.body; expect(responseBody.data.bookings.length).toEqual(2); const fetchedBooking = responseBody.data.bookings.find( @@ -329,7 +329,7 @@ describe("Bookings Endpoints", () => { return request(app.getHttpServer()) .get(`/v2/bookings/${createdBooking.uid}`) .then((response) => { - const responseBody: GetBookingOutput = response.body; + const responseBody: GetBookingOutput_2024_04_15 = response.body; const bookingInfo = responseBody.data; expect(responseBody.status).toEqual(SUCCESS_STATUS); diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts similarity index 87% rename from apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index a23bf5dd8c8a1d..069b1a52bfe52e 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -1,11 +1,11 @@ -import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; -import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input"; -import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input"; -import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; -import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; -import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input"; +import { MarkNoShowInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/mark-no-show.input"; +import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output"; +import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; +import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output"; import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; -import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; @@ -31,7 +31,7 @@ import { UseGuards, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger"; +import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; import { User } from "@prisma/client"; import { Request } from "express"; import { NextApiRequest } from "next/types"; @@ -40,10 +40,10 @@ import { v4 as uuidv4 } from "uuid"; import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants"; import { + handleNewRecurringBooking, handleNewBooking, BookingResponse, HttpError, - handleNewRecurringBooking, handleInstantMeeting, handleMarkNoShow, getAllUserBookings, @@ -52,7 +52,11 @@ import { getBookingForReschedule, ErrorCode, } from "@calcom/platform-libraries"; -import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types"; +import { + GetBookingsInput_2024_04_15, + CancelBookingInput_2024_04_15, + Status_2024_04_15, +} from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; @@ -80,11 +84,11 @@ const DEFAULT_PLATFORM_PARAMS = { @Controller({ path: "/v2/bookings", - version: API_VERSIONS_VALUES, + version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14], }) @UseGuards(PermissionsGuard) -@DocsTags("Bookings") -export class BookingsController { +@DocsExcludeController(true) +export class BookingsController_2024_04_15 { private readonly logger = new Logger("BookingsController"); constructor( @@ -99,16 +103,16 @@ export class BookingsController { @Get("/") @UseGuards(ApiAuthGuard) @Permissions([BOOKING_READ]) - @ApiQuery({ name: "filters[status]", enum: Status, required: true }) + @ApiQuery({ name: "filters[status]", enum: Status_2024_04_15, required: true }) @ApiQuery({ name: "limit", type: "number", required: false }) @ApiQuery({ name: "cursor", type: "number", required: false }) async getBookings( @GetUser() user: User, - @Query() queryParams: GetBookingsInput - ): Promise { + @Query() queryParams: GetBookingsInput_2024_04_15 + ): Promise { const { filters, cursor, limit } = queryParams; const bookings = await getAllUserBookings({ - bookingListingByStatus: filters.status, + bookingListingByStatus: [filters.status], skip: cursor ?? 0, take: limit ?? 10, filters, @@ -125,7 +129,7 @@ export class BookingsController { } @Get("/:bookingUid") - async getBooking(@Param("bookingUid") bookingUid: string): Promise { + async getBooking(@Param("bookingUid") bookingUid: string): Promise { const { bookingInfo } = await getBookingInfo(bookingUid); if (!bookingInfo) { @@ -155,7 +159,7 @@ export class BookingsController { @Post("/") async createBooking( @Req() req: BookingRequest, - @Body() body: CreateBookingInput, + @Body() body: CreateBookingInput_2024_04_15, @Headers(X_CAL_CLIENT_ID) clientId?: string ): Promise>> { const oAuthClientId = clientId?.toString(); @@ -186,7 +190,7 @@ export class BookingsController { async cancelBooking( @Req() req: BookingRequest, @Param("bookingId") bookingId: string, - @Body() _: CancelBookingInput, + @Body() _: CancelBookingInput_2024_04_15, @Headers(X_CAL_CLIENT_ID) clientId?: string ): Promise> { const oAuthClientId = clientId?.toString(); @@ -219,9 +223,9 @@ export class BookingsController { @UseGuards(ApiAuthGuard) async markNoShow( @GetUser("id") userId: number, - @Body() body: MarkNoShowInput, + @Body() body: MarkNoShowInput_2024_04_15, @Param("bookingUid") bookingUid: string - ): Promise { + ): Promise { try { const markNoShowResponse = await handleMarkNoShow({ bookingUid: bookingUid, @@ -240,7 +244,7 @@ export class BookingsController { @Post("/recurring") async createRecurringBooking( @Req() req: BookingRequest, - @Body() _: CreateRecurringBookingInput[], + @Body() _: CreateRecurringBookingInput_2024_04_15[], @Headers(X_CAL_CLIENT_ID) clientId?: string ): Promise> { const oAuthClientId = clientId?.toString(); @@ -278,7 +282,7 @@ export class BookingsController { @Post("/instant") async createInstantBooking( @Req() req: BookingRequest, - @Body() _: CreateBookingInput, + @Body() _: CreateBookingInput_2024_04_15, @Headers(X_CAL_CLIENT_ID) clientId?: string ): Promise>>> { const oAuthClientId = clientId?.toString(); @@ -369,7 +373,12 @@ export class BookingsController { const oAuthParams = oAuthClientId ? await this.getOAuthClientsParams(oAuthClientId) : DEFAULT_PLATFORM_PARAMS; - Object.assign(req, { userId, ...oAuthParams, platformBookingLocation }); + Object.assign(req, { + userId, + ...oAuthParams, + platformBookingLocation, + noEmail: !oAuthParams.arePlatformEmailsEnabled, + }); return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; } diff --git a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts similarity index 97% rename from apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 0feeb0cc19a77b..ed0275acf06152 100644 --- a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -40,7 +40,7 @@ class Response { notes?: string; } -export class CreateBookingInput { +export class CreateBookingInput_2024_04_15 { @IsString() @IsOptional() end?: string; diff --git a/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-recurring-booking.input.ts similarity index 72% rename from apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-recurring-booking.input.ts index 658e12aaa2293a..7201519bf7f0d2 100644 --- a/apps/api/v2/src/ee/bookings/inputs/create-recurring-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-recurring-booking.input.ts @@ -1,9 +1,9 @@ -import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator"; import type { AppsStatus } from "@calcom/platform-libraries"; -export class CreateRecurringBookingInput extends CreateBookingInput { +export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 { @IsBoolean() @IsOptional() noEmail?: boolean; diff --git a/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/mark-no-show.input.ts similarity index 90% rename from apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/inputs/mark-no-show.input.ts index 0630f08fcc3395..017f9edd78c8ce 100644 --- a/apps/api/v2/src/ee/bookings/inputs/mark-no-show.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/mark-no-show.input.ts @@ -9,7 +9,7 @@ class Attendee { noShow!: boolean; } -export class MarkNoShowInput { +export class MarkNoShowInput_2024_04_15 { @IsBoolean() @IsOptional() noShowHost?: boolean; diff --git a/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-booking.output.ts similarity index 92% rename from apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-booking.output.ts index c770a583059624..07e352736e00d9 100644 --- a/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-booking.output.ts @@ -88,7 +88,7 @@ class EventType { timeZone!: string | null; } -class GetBookingData { +class GetBookingData_2024_04_15 { @IsString() title!: string; @@ -159,15 +159,15 @@ class GetBookingData { eventType!: EventType | null; } -export class GetBookingOutput { +export class GetBookingOutput_2024_04_15 { @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: GetBookingData, + type: GetBookingData_2024_04_15, }) @ValidateNested() - @Type(() => GetBookingData) - data!: GetBookingData; + @Type(() => GetBookingData_2024_04_15) + data!: GetBookingData_2024_04_15; } diff --git a/apps/api/v2/src/ee/bookings/outputs/get-bookings.output.ts b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-bookings.output.ts similarity index 94% rename from apps/api/v2/src/ee/bookings/outputs/get-bookings.output.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-bookings.output.ts index 09e555fbb135f5..7e27580bc70bd0 100644 --- a/apps/api/v2/src/ee/bookings/outputs/get-bookings.output.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/get-bookings.output.ts @@ -202,7 +202,7 @@ class GetBookingsDataEntry { rescheduled?: any; } -class GetBookingsData { +class GetBookingsData_2024_04_15 { @ValidateNested() @Type(() => GetBookingsDataEntry) @IsArray() @@ -215,15 +215,15 @@ class GetBookingsData { nextCursor!: number | null; } -export class GetBookingsOutput { +export class GetBookingsOutput_2024_04_15 { @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: GetBookingsData, + type: GetBookingsData_2024_04_15, }) @ValidateNested() - @Type(() => GetBookingsData) - data!: GetBookingsData; + @Type(() => GetBookingsData_2024_04_15) + data!: GetBookingsData_2024_04_15; } diff --git a/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/mark-no-show.output.ts similarity index 78% rename from apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts rename to apps/api/v2/src/ee/bookings/2024-04-15/outputs/mark-no-show.output.ts index a321848b139bf2..c951987c7c1a47 100644 --- a/apps/api/v2/src/ee/bookings/outputs/mark-no-show.output.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/outputs/mark-no-show.output.ts @@ -12,7 +12,7 @@ class Attendee { noShow!: boolean; } -class HandleMarkNoShowData { +class HandleMarkNoShowData_2024_04_15 { @IsString() message!: string; @@ -27,15 +27,15 @@ class HandleMarkNoShowData { attendees?: Attendee[]; } -export class MarkNoShowOutput { +export class MarkNoShowOutput_2024_04_15 { @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: HandleMarkNoShowData, + type: HandleMarkNoShowData_2024_04_15, }) @ValidateNested() - @Type(() => HandleMarkNoShowData) - data!: HandleMarkNoShowData; + @Type(() => HandleMarkNoShowData_2024_04_15) + data!: HandleMarkNoShowData_2024_04_15; } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts new file mode 100644 index 00000000000000..21737c4717fe9f --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -0,0 +1,33 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller"; +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service"; +import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { BillingModule } from "@/modules/billing/billing.module"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule], + providers: [ + TokensRepository, + OAuthFlowService, + OAuthClientRepository, + BookingsService_2024_08_13, + InputBookingsService_2024_08_13, + OutputBookingsService_2024_08_13, + BookingsRepository_2024_08_13, + EventTypesRepository_2024_06_14, + ApiKeyRepository, + ], + controllers: [BookingsController_2024_08_13], +}) +export class BookingsModule_2024_08_13 {} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts new file mode 100644 index 00000000000000..fea95d8b845628 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.repository.ts @@ -0,0 +1,86 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class BookingsRepository_2024_08_13 { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getById(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { + id, + }, + }); + } + + async getByIdsWithAttendeesAndUser(ids: number[]) { + return this.dbRead.prisma.booking.findMany({ + where: { + id: { + in: ids, + }, + }, + include: { + attendees: true, + user: true, + }, + }); + } + + async getByUid(bookingUid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + }); + } + + async getByIdWithAttendeesAndUser(id: number) { + return this.dbRead.prisma.booking.findUnique({ + where: { + id, + }, + include: { + attendees: true, + user: true, + }, + }); + } + + async getByUidWithAttendeesAndUser(uid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid, + }, + include: { + attendees: true, + user: true, + }, + }); + } + + async getRecurringByUidWithAttendeesAndUser(uid: string) { + return this.dbRead.prisma.booking.findMany({ + where: { + recurringEventId: uid, + }, + include: { + attendees: true, + user: true, + }, + }); + } + + async getByFromReschedule(fromReschedule: string) { + return this.dbRead.prisma.booking.findFirst({ + where: { + fromReschedule, + }, + include: { + attendees: true, + user: true, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/api-key-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/api-key-bookings.controller.e2e-spec.ts new file mode 100644 index 00000000000000..f28af04e4e63eb --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/api-key-bookings.controller.e2e-spec.ts @@ -0,0 +1,160 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("With api key", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let apiKeyString: string; + + const userEmail = "bookings-controller-e2e@api.com"; + let user: User; + + let eventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ name: "organization bookings" }); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = keyString; + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { title: "peer coding", slug: "peer-coding", length: 60 }, + user.id + ); + eventTypeId = event.id; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + describe("create bookings", () => { + it("should create a booking with api key", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Key", + email: "mr_key@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(eventTypeId); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts new file mode 100644 index 00000000000000..d7f84c4a010154 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -0,0 +1,219 @@ +import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { GetBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-booking.output"; +import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.output"; +import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { + Controller, + Post, + Logger, + Body, + UseGuards, + Req, + Get, + Param, + Query, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { + ApiOperation, + ApiTags as DocsTags, + ApiHeader, + getSchemaPath, + ApiBody, + ApiExtraModels, +} from "@nestjs/swagger"; +import { User } from "@prisma/client"; +import { Request } from "express"; + +import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + CreateBookingInputPipe, + CreateBookingInput, + GetBookingsInput_2024_08_13, + RescheduleBookingInput_2024_08_13, + CancelBookingInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, +} from "@calcom/platform-types"; + +@Controller({ + path: "/v2/bookings", + version: VERSION_2024_08_13_VALUE, +}) +@UseGuards(PermissionsGuard) +@DocsTags("Bookings") +@ApiHeader({ + name: "cal-api-version", + description: `Must be set to \`2024-08-13\``, + required: true, +}) +export class BookingsController_2024_08_13 { + private readonly logger = new Logger("BookingsController"); + + constructor(private readonly bookingsService: BookingsService_2024_08_13) {} + + @Post("/") + @ApiOperation({ + summary: "Create booking", + description: ` + POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except: + If eventTypeId in the request body is id of a regular event, then regular booking is created. + + If it is an id of a recurring event type, then recurring booking is created. + + Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible. + + For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body. + + The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone. + `, + }) + @ApiBody({ + schema: { + oneOf: [ + { $ref: getSchemaPath(CreateBookingInput_2024_08_13) }, + { $ref: getSchemaPath(CreateInstantBookingInput_2024_08_13) }, + { $ref: getSchemaPath(CreateRecurringBookingInput_2024_08_13) }, + ], + }, + description: + "Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13", + }) + @ApiExtraModels( + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13 + ) + async createBooking( + @Body(new CreateBookingInputPipe()) + body: CreateBookingInput, + @Req() request: Request + ): Promise { + const booking = await this.bookingsService.createBooking(request, body); + + if (Array.isArray(booking)) { + await this.bookingsService.billBookings(booking); + } else { + await this.bookingsService.billBooking(booking); + } + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Get("/:bookingUid") + @UseGuards(BookingUidGuard) + @ApiOperation({ + summary: "Get booking", + description: `\`:bookingUid\` can be + + 1. uid of a normal booking + + 2. uid of one of the recurring booking recurrences + + 3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`, + }) + async getBooking(@Param("bookingUid") bookingUid: string): Promise { + const booking = await this.bookingsService.getBooking(bookingUid); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } + + @Get("/") + @UseGuards(ApiAuthGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + @Permissions([BOOKING_READ]) + async getBookings( + @Query() queryParams: GetBookingsInput_2024_08_13, + @GetUser() user: User + ): Promise { + const bookings = await this.bookingsService.getBookings(queryParams, user); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } + + @Post("/:bookingUid/reschedule") + @UseGuards(BookingUidGuard) + @ApiOperation({ + summary: "Reschedule booking", + description: + "Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.", + }) + async rescheduleBooking( + @Param("bookingUid") bookingUid: string, + @Body() body: RescheduleBookingInput_2024_08_13, + @Req() request: Request + ): Promise { + const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body); + await this.bookingsService.billRescheduledBooking(newBooking, bookingUid); + + return { + status: SUCCESS_STATUS, + data: newBooking, + }; + } + + @Post("/:bookingUid/cancel") + @UseGuards(BookingUidGuard) + @HttpCode(HttpStatus.OK) + async cancelBooking( + @Req() request: Request, + @Param("bookingUid") bookingUid: string, + @Body() body: CancelBookingInput_2024_08_13 + ): Promise { + const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body); + + return { + status: SUCCESS_STATUS, + data: cancelledBooking, + }; + } + + @Post("/:bookingUid/mark-absent") + @HttpCode(HttpStatus.OK) + @Permissions([BOOKING_WRITE]) + @UseGuards(ApiAuthGuard, BookingUidGuard) + @ApiHeader({ + name: "Authorization", + description: + "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + required: true, + }) + async markNoShow( + @Param("bookingUid") bookingUid: string, + @Body() body: MarkAbsentBookingInput_2024_08_13, + @GetUser("id") ownerId: number + ): Promise { + const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body); + + return { + status: SUCCESS_STATUS, + data: booking, + }; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts new file mode 100644 index 00000000000000..1f3139e60c4a50 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/team-bookings.controller.e2e-spec.ts @@ -0,0 +1,435 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("Team bookings", () => { + let app: INestApplication; + let organization: Team; + let team1: Team; + let team2: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const teamUserEmail = "orgUser1team1@api.com"; + const teamUserEmail2 = "orgUser2team1@api.com"; + let teamUser: User; + let teamUser2: User; + + let team1EventTypeId: number; + let team2EventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamUserEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" }); + team1 = await teamRepositoryFixture.create({ + name: "team 1", + isOrganization: false, + parent: { connect: { id: organization.id } }, + }); + + team2 = await teamRepositoryFixture.create({ + name: "team 2", + isOrganization: false, + parent: { connect: { id: organization.id } }, + }); + + oAuthClient = await createOAuthClient(organization.id); + + teamUser = await userRepositoryFixture.create({ + email: teamUserEmail, + locale: "it", + name: "orgUser1team1", + }); + + teamUser2 = await userRepositoryFixture.create({ + email: teamUserEmail2, + locale: "es", + name: "orgUser2team1", + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(teamUser.id, userSchedule); + await schedulesService.createUserSchedule(teamUser2.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser.id}`, + username: teamUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser2.id}`, + username: teamUserEmail2, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team2.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teamUser2.id } }, + team: { connect: { id: team2.id } }, + accepted: true, + }); + + const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team1EventTypeId = team1EventType.id; + + const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team2.id }, + }, + title: "Collective Event Type 2", + slug: "collective-event-type-2", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team2EventTypeId = team2EventType.id; + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team2EventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser2.id, + }, + }, + eventType: { + connect: { + id: team2EventType.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("create team bookings", () => { + it("should create a team 1 booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: team1EventTypeId, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Madrid", + language: "es", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team1EventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should create a team 2 booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: team2EventTypeId, + attendee: { + name: "bob", + email: "bob@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team2EventTypeId); + expect(data.attendees.length).toEqual(2); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.attendees[1]).toEqual({ + name: teamUser2.name, + timeZone: teamUser2.timeZone, + language: teamUser2.locale, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + }); + + describe("get team bookings", () => { + it("should should get bookings by teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team1.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].eventTypeId).toEqual(team1EventTypeId); + }); + }); + + it("should should get bookings by teamId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamId=${team2.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].eventTypeId).toEqual(team2EventTypeId); + }); + }); + + it("should should get bookings by teamIds", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?teamIds=${team1.id},${team2.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined(); + expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined(); + }); + }); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(teamUser.email); + await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts new file mode 100644 index 00000000000000..1203c8bf6a0a5e --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/user-bookings.controller.e2e-spec.ts @@ -0,0 +1,1034 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { GetBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-booking.output"; +import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.output"; +import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output"; +import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { + CAL_API_VERSION_HEADER, + SUCCESS_STATUS, + VERSION_2024_08_13, + X_CAL_CLIENT_ID, +} from "@calcom/platform-constants"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + RecurringBookingOutput_2024_08_13, + RescheduleBookingInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, +} from "@calcom/platform-types"; +import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; +import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Bookings Endpoints 2024-08-13", () => { + describe("User bookings", () => { + let app: INestApplication; + let organization: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + + const userEmail = "bookings-controller-e2e@api.com"; + let user: User; + + let eventTypeId: number; + let recurringEventTypeId: number; + + let createdBooking: BookingOutput_2024_08_13; + let rescheduledBooking: BookingOutput_2024_08_13; + let createdRecurringBooking: RecurringBookingOutput_2024_08_13[]; + + let bookingInThePast: Booking; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await teamRepositoryFixture.create({ name: "organization bookings" }); + oAuthClient = await createOAuthClient(organization.id); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + const event = await eventTypesRepositoryFixture.create( + { title: "peer coding", slug: "peer-coding", length: 60 }, + user.id + ); + eventTypeId = event.id; + + const recurringEvent = await eventTypesRepositoryFixture.create( + // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row + { + title: "peer coding recurring", + slug: "peer-coding-recurring", + length: 60, + recurringEvent: { freq: 2, count: 3, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + bookingInThePast = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: user.id, + }, + }, + startTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)), + endTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)), + title: "peer coding lets goo", + uid: "booking-in-the-past", + eventType: { + connect: { + id: eventTypeId, + }, + }, + location: "integrations:daily", + customInputs: {}, + metadata: {}, + responses: { + name: "Oldie", + email: "oldie@gmail.com", + }, + attendees: { + create: { + email: "oldie@gmail.com", + name: "Oldie", + locale: "lv", + timeZone: "Europe/Rome", + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + describe("create bookings", () => { + it("should create a booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(eventTypeId); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + createdBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should create a recurring booking", async () => { + const body: CreateRecurringBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Mr Proper Recurring", + email: "mr_proper_recurring@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(3); + + const firstBooking = data[0]; + expect(firstBooking.id).toBeDefined(); + expect(firstBooking.uid).toBeDefined(); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual("accepted"); + expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString()); + expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString()); + expect(firstBooking.duration).toEqual(60); + expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(firstBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(firstBooking.meetingUrl).toEqual(body.meetingUrl); + expect(firstBooking.recurringBookingUid).toBeDefined(); + expect(firstBooking.absentHost).toEqual(false); + + const secondBooking = data[1]; + expect(secondBooking.id).toBeDefined(); + expect(secondBooking.uid).toBeDefined(); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual("accepted"); + expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString()); + expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString()); + expect(secondBooking.duration).toEqual(60); + expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(secondBooking.recurringBookingUid).toBeDefined(); + expect(secondBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(secondBooking.meetingUrl).toEqual(body.meetingUrl); + expect(secondBooking.absentHost).toEqual(false); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toBeDefined(); + expect(thirdBooking.uid).toBeDefined(); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual("accepted"); + expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString()); + expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString()); + expect(thirdBooking.duration).toEqual(60); + expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId); + expect(thirdBooking.recurringBookingUid).toBeDefined(); + expect(thirdBooking.attendees[0]).toEqual({ + name: body.attendee.name, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(thirdBooking.meetingUrl).toEqual(body.meetingUrl); + expect(thirdBooking.absentHost).toEqual(false); + + createdRecurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + describe("get individual booking", () => { + it("should should get a booking", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdBooking.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toEqual(createdBooking.id); + expect(data.uid).toEqual(createdBooking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(createdBooking.status); + expect(data.start).toEqual(createdBooking.start); + expect(data.end).toEqual(createdBooking.end); + expect(data.duration).toEqual(createdBooking.duration); + expect(data.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(data.attendees[0]).toEqual(createdBooking.attendees[0]); + expect(data.meetingUrl).toEqual(createdBooking.meetingUrl); + expect(data.absentHost).toEqual(createdBooking.absentHost); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should should get 1 recurrence of a recurring booking", async () => { + const recurrenceUid = createdRecurringBooking[0].uid; + return request(app.getHttpServer()) + .get(`/v2/bookings/${recurrenceUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurranceBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurranceBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toEqual(createdRecurringBooking[0].id); + expect(data.uid).toEqual(createdRecurringBooking[0].uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(createdRecurringBooking[0].status); + expect(data.start).toEqual(createdRecurringBooking[0].start); + expect(data.end).toEqual(createdRecurringBooking[0].end); + expect(data.duration).toEqual(createdRecurringBooking[0].duration); + expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId); + expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid); + expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]); + expect(data.meetingUrl).toEqual(createdRecurringBooking[0].meetingUrl); + expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibily recurring bookings" + ); + } + }); + }); + + it("should should get all recurrences of the recurring bookings", async () => { + const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid; + return request(app.getHttpServer()) + .get(`/v2/bookings/${recurringBookingUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(3); + + const firstBooking = data[0]; + expect(firstBooking.id).toEqual(createdRecurringBooking[0].id); + expect(firstBooking.uid).toEqual(createdRecurringBooking[0].uid); + expect(firstBooking.hosts[0].id).toEqual(user.id); + expect(firstBooking.status).toEqual(createdRecurringBooking[0].status); + expect(firstBooking.start).toEqual(createdRecurringBooking[0].start); + expect(firstBooking.end).toEqual(createdRecurringBooking[0].end); + expect(firstBooking.duration).toEqual(createdRecurringBooking[0].duration); + expect(firstBooking.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId); + expect(firstBooking.recurringBookingUid).toEqual(recurringBookingUid); + expect(firstBooking.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]); + expect(firstBooking.meetingUrl).toEqual(createdRecurringBooking[0].meetingUrl); + expect(firstBooking.absentHost).toEqual(createdRecurringBooking[0].absentHost); + + const secondBooking = data[1]; + expect(secondBooking.id).toEqual(createdRecurringBooking[1].id); + expect(secondBooking.uid).toEqual(createdRecurringBooking[1].uid); + expect(secondBooking.hosts[0].id).toEqual(user.id); + expect(secondBooking.status).toEqual(createdRecurringBooking[1].status); + expect(secondBooking.start).toEqual(createdRecurringBooking[1].start); + expect(secondBooking.end).toEqual(createdRecurringBooking[1].end); + expect(secondBooking.duration).toEqual(createdRecurringBooking[1].duration); + expect(secondBooking.eventTypeId).toEqual(createdRecurringBooking[1].eventTypeId); + expect(secondBooking.recurringBookingUid).toEqual(recurringBookingUid); + expect(secondBooking.attendees[0]).toEqual(createdRecurringBooking[1].attendees[0]); + expect(secondBooking.meetingUrl).toEqual(createdRecurringBooking[1].meetingUrl); + expect(secondBooking.absentHost).toEqual(createdRecurringBooking[1].absentHost); + + const thirdBooking = data[2]; + expect(thirdBooking.id).toEqual(createdRecurringBooking[2].id); + expect(thirdBooking.uid).toEqual(createdRecurringBooking[2].uid); + expect(thirdBooking.hosts[0].id).toEqual(user.id); + expect(thirdBooking.status).toEqual(createdRecurringBooking[2].status); + expect(thirdBooking.start).toEqual(createdRecurringBooking[2].start); + expect(thirdBooking.end).toEqual(createdRecurringBooking[2].end); + expect(thirdBooking.duration).toEqual(createdRecurringBooking[2].duration); + expect(thirdBooking.eventTypeId).toEqual(createdRecurringBooking[2].eventTypeId); + expect(thirdBooking.recurringBookingUid).toEqual(recurringBookingUid); + expect(thirdBooking.attendees[0]).toEqual(createdRecurringBooking[2].attendees[0]); + expect(thirdBooking.meetingUrl).toEqual(createdRecurringBooking[2].meetingUrl); + expect(thirdBooking.absentHost).toEqual(createdRecurringBooking[2].absentHost); + + createdRecurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + describe("get bookings", () => { + it("should should get all bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(5); + }); + }); + + it("should should take bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?take=3`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should skip bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?skip=2`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should get upcoming bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=upcoming`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(4); + }); + }); + + it("should should get past bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=past`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(1); + }); + }); + + it("should should get upcoming and past bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=upcoming,past`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(5); + }); + }); + + it("should should get recurring booking recurrences", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?status=recurring`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should get bookings by attendee email", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?attendeeEmail=mr_proper@gmail.com`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(1); + }); + }); + + it("should should get bookings by attendee name", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?attendeeName=Mr Proper Recurring`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should get bookings by eventTypeId", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + }); + }); + + it("should should get bookings by eventTypeIds", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeIds=${eventTypeId},${recurringEventTypeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(5); + }); + }); + + it("should should get bookings by after specified start time", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?afterStart=${createdRecurringBooking[1].start}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + }); + }); + + it("should should get bookings by before specified end time", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?afterStart=${createdRecurringBooking[0].start}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(3); + }); + }); + + it("should should sort bookings by start in descending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortStart=desc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + console.log("asap data", JSON.stringify(data, null, 2)); + expect(data[0].start).toEqual(createdBooking.start); + expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); + }); + }); + + it("should should sort bookings by start in ascending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortStart=asc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); + expect(data[1].start).toEqual(createdBooking.start); + }); + }); + + it("should should sort bookings by end in descending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortEnd=desc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(createdBooking.start); + expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); + }); + }); + + it("should should sort bookings by end in ascending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortEnd=asc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); + expect(data[1].start).toEqual(createdBooking.start); + }); + }); + + it("should should sort bookings by created in descending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortCreated=desc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(createdBooking.start); + expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString()); + }); + }); + + it("should should sort bookings by created in ascending order", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings?eventTypeId=${eventTypeId}&sortCreated=asc`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data; + expect(data.length).toEqual(2); + expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString()); + expect(data[1].start).toEqual(createdBooking.start); + }); + }); + }); + + describe("reschedule bookings", () => { + it("should should reschedule normal booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdBooking.uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.reschedulingReason).toEqual(body.reschedulingReason); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString()); + expect(data.rescheduledFromUid).toEqual(createdBooking.uid); + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(createdBooking.status); + expect(data.duration).toEqual(createdBooking.duration); + expect(data.eventTypeId).toEqual(createdBooking.eventTypeId); + expect(data.attendees[0]).toEqual(createdBooking.attendees[0]); + expect(data.meetingUrl).toEqual(createdBooking.meetingUrl); + expect(data.absentHost).toEqual(createdBooking.absentHost); + + rescheduledBooking = data; + }); + }); + + it("should set rescheduled booking status to cancelled", async () => { + return request(app.getHttpServer()) + .get(`/v2/bookings/${createdBooking.uid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.status).toEqual("cancelled"); + + createdBooking = data; + }); + }); + + it("should reschedule recurrence of a recurring booking", async () => { + const body: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars again", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[0].uid}/reschedule`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(createdRecurringBooking[0].status); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 9, 15, 0, 0)).toISOString()); + expect(data.duration).toEqual(createdRecurringBooking[0].duration); + expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid); + expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId); + expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]); + expect(data.meetingUrl).toEqual(createdRecurringBooking[0].meetingUrl); + expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost); + + const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid); + expect(oldBooking).toBeDefined(); + expect(oldBooking?.status).toEqual("CANCELLED"); + }); + }); + + it("should get recurring booking recurrences after rescheduling one", async () => { + const recurringBookingUid = createdRecurringBooking[0].recurringBookingUid; + return request(app.getHttpServer()) + .get(`/v2/bookings/${recurringBookingUid}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + const cancelled = data.find((booking) => booking.status === "cancelled"); + expect(cancelled).toBeDefined(); + const rescheduledNew = data.find( + (booking) => booking.start === new Date(Date.UTC(2035, 0, 9, 14, 0, 0)).toISOString() + ); + expect(rescheduledNew).toBeDefined(); + createdRecurringBooking = data; + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + describe("mark absent", () => { + it("should mark host absent", async () => { + const body: MarkAbsentBookingInput_2024_08_13 = { + host: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[1].uid}/mark-absent`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: MarkAbsentBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + const booking = createdRecurringBooking[1]; + expect(data.absentHost).toEqual(true); + + expect(data.id).toEqual(booking.id); + expect(data.uid).toEqual(booking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(booking.status); + expect(data.start).toEqual(booking.start); + expect(data.end).toEqual(booking.end); + expect(data.duration).toEqual(booking.duration); + expect(data.eventTypeId).toEqual(booking.eventTypeId); + expect(data.attendees[0]).toEqual(booking.attendees[0]); + expect(data.meetingUrl).toEqual(booking.meetingUrl); + }); + }); + + it("should mark attendee absent", async () => { + const body: MarkAbsentBookingInput_2024_08_13 = { + attendees: [{ email: "mr_proper_recurring@gmail.com", absent: true }], + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[2].uid}/mark-absent`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: MarkAbsentBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: RecurringBookingOutput_2024_08_13 = responseBody.data; + const booking = createdRecurringBooking[2]; + + expect(data.id).toEqual(booking.id); + expect(data.uid).toEqual(booking.uid); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual(booking.status); + expect(data.start).toEqual(booking.start); + expect(data.end).toEqual(booking.end); + expect(data.duration).toEqual(booking.duration); + expect(data.eventTypeId).toEqual(booking.eventTypeId); + expect(data.attendees[0].absent).toEqual(true); + expect(data.absentHost).toEqual(booking.absentHost); + expect(data.meetingUrl).toEqual(booking.meetingUrl); + }); + }); + }); + describe("cancel bookings", () => { + it("should cancel booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid); + expect(booking).toBeDefined(); + expect(booking?.status).toEqual("ACCEPTED"); + + return request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBooking.uid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("cancelled"); + expect(data.cancellationReason).toEqual(body.cancellationReason); + expect(data.start).toEqual(rescheduledBooking.start); + expect(data.end).toEqual(rescheduledBooking.end); + expect(data.duration).toEqual(rescheduledBooking.duration); + expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId); + expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]); + expect(data.meetingUrl).toEqual(rescheduledBooking.meetingUrl); + expect(data.absentHost).toEqual(rescheduledBooking.absentHost); + + const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid); + expect(cancelledBooking).toBeDefined(); + expect(cancelledBooking?.status).toEqual("CANCELLED"); + }); + }); + + it("should cancel recurring booking", async () => { + const body: CancelBookingInput_2024_08_13 = { + cancellationReason: "Going on a vacation", + }; + + return request(app.getHttpServer()) + .post(`/v2/bookings/${createdRecurringBooking[1].recurringBookingUid}/cancel`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(200) + .then(async (response) => { + const responseBody: CancelBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true); + + if (responseDataIsRecurringBooking(responseBody.data)) { + const data: RecurringBookingOutput_2024_08_13[] = responseBody.data; + expect(data.length).toEqual(4); + + const firstBooking = data[0]; + expect(firstBooking.status).toEqual("cancelled"); + + const secondBooking = data[1]; + expect(secondBooking.status).toEqual("cancelled"); + + const thirdBooking = data[2]; + expect(thirdBooking.status).toEqual("cancelled"); + + const fourthBooking = data[3]; + expect(fourthBooking.status).toEqual("cancelled"); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { + return ( + !Array.isArray(data) && + typeof data === "object" && + data && + "id" in data && + "recurringBookingUid" in data + ); + } + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-uid.guard.ts b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-uid.guard.ts new file mode 100644 index 00000000000000..05d508a4fb38e9 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-uid.guard.ts @@ -0,0 +1,16 @@ +import { Injectable, CanActivate, ExecutionContext, BadRequestException } from "@nestjs/common"; + +@Injectable() +export class BookingUidGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + const bookingUid = request.params.bookingUid; + + if (!bookingUid) { + throw new BadRequestException("Booking UID missing in the request path"); + } + + return true; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts new file mode 100644 index 00000000000000..f463f698611f18 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/cancel-booking.output.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class CancelBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + ], + description: + "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects", + }) + @Type(() => Object) + data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts new file mode 100644 index 00000000000000..deab5e452ac518 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/create-booking.output.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class CreateBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + ], + description: + "Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects", + }) + @Type(() => Object) + data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-booking.output.ts new file mode 100644 index 00000000000000..aa4745785e7c2d --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-booking.output.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class GetBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ValidateNested() + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + { type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } }, + ], + description: + "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects", + }) + @Type(() => Object) + data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-bookings.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-bookings.output.ts new file mode 100644 index 00000000000000..3f3c0623a2e392 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-bookings.output.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class GetBookingsOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: "array", + items: { + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + ], + }, + description: + "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects", + }) + @ValidateNested({ each: true }) + data!: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[]; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/mark-absent.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/mark-absent.output.ts new file mode 100644 index 00000000000000..16dcaae6a1a7e4 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/mark-absent.output.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class MarkAbsentBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + ], + description: + "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object", + }) + @ValidateNested() + @Type(() => Object) + data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts new file mode 100644 index 00000000000000..2c45b9dd3ca5e7 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/reschedule-booking.output.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; + +@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13) +export class RescheduleBookingOutput_2024_08_13 { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(BookingOutput_2024_08_13) }, + { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) }, + ], + description: + "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object", + }) + @ValidateNested() + @Type(() => Object) + data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts new file mode 100644 index 00000000000000..e9956fcbe419f2 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -0,0 +1,274 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service"; +import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common"; +import { Request } from "express"; + +import { + handleNewBooking, + handleNewRecurringBooking, + getAllUserBookings, + handleInstantMeeting, + handleCancelBooking, + handleMarkNoShow, +} from "@calcom/platform-libraries"; +import { + CreateBookingInput_2024_08_13, + RescheduleBookingInput_2024_08_13, + CreateBookingInput, + CreateRecurringBookingInput_2024_08_13, + GetBookingsInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CancelBookingInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +type CreatedBooking = { + hosts: { id: number }[]; + uid: string; + start: string; +}; + +@Injectable() +export class BookingsService_2024_08_13 { + private readonly logger = new Logger("BookingsService"); + constructor( + private readonly inputService: InputBookingsService_2024_08_13, + private readonly outputService: OutputBookingsService_2024_08_13, + private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly prismaReadService: PrismaReadService, + private readonly billingService: BillingService + ) {} + + async createBooking(request: Request, body: CreateBookingInput) { + try { + if ("instant" in body && body.instant) { + return await this.createInstantBooking(request, body); + } + + if (await this.isRecurring(body)) { + return await this.createRecurringBooking(request, body); + } + + return await this.createRegularBooking(request, body); + } catch (error) { + if (error instanceof Error) { + if (error.message === "no_available_users_found_error") { + throw new BadRequestException("User either already has booking at this time or is not available"); + } + } + throw error; + } + } + + async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createBookingRequest(request, body); + const booking = await handleInstantMeeting(bookingRequest); + + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.bookingId); + if (!databaseBooking) { + throw new Error(`Booking with id=${booking.bookingId} was not found in the database`); + } + + return this.outputService.getOutputBooking(databaseBooking); + } + + async isRecurring(body: CreateBookingInput) { + const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId); + return !!eventType?.recurringEvent; + } + + async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); + const bookings = await handleNewRecurringBooking(bookingRequest); + const uid = bookings[0].recurringEventId; + if (!uid) { + throw new Error("Recurring booking was not created"); + } + + const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(uid); + return this.outputService.getOutputRecurringBookings(recurringBooking); + } + + async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createBookingRequest(request, body); + const booking = await handleNewBooking(bookingRequest); + + if (!booking.id) { + throw new Error("Booking was not created"); + } + + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.id); + if (!databaseBooking) { + throw new Error(`Booking with id=${booking.id} was not found in the database`); + } + + return this.outputService.getOutputBooking(databaseBooking); + } + + async getBooking(uid: string) { + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUser(uid); + + if (booking) { + const isRecurring = !!booking.recurringEventId; + if (isRecurring) { + return this.outputService.getOutputRecurringBooking(booking); + } + return this.outputService.getOutputBooking(booking); + } + + const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(uid); + if (!recurringBooking.length) { + throw new NotFoundException(`Booking with uid=${uid} was not found in the database`); + } + + return this.outputService.getOutputRecurringBookings(recurringBooking); + } + + async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) { + const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({ + bookingListingByStatus: queryParams.status || [], + skip: queryParams.skip ?? 0, + // note(Lauris): we substract -1 because getAllUSerBookings child function adds +1 for some reason + take: queryParams.take ? queryParams.take - 1 : 100, + filters: this.inputService.transformGetBookingsFilters(queryParams), + ctx: { + user, + prisma: this.prismaReadService.prisma as unknown as PrismaClient, + }, + sort: this.inputService.transformGetBookingsSort(queryParams), + }); + // note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query, + // because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids. + const ids = fetchedBookings.bookings.map((booking) => booking.id); + const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUser(ids); + + const bookingMap = new Map(bookings.map((booking) => [booking.id, booking])); + const orderedBookings = ids.map((id) => bookingMap.get(id)); + + const formattedBookings: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = []; + for (const booking of orderedBookings) { + if (!booking) { + continue; + } + + const formatted = { + ...booking, + eventTypeId: booking.eventTypeId, + startTime: new Date(booking.startTime), + endTime: new Date(booking.endTime), + absentHost: !!booking.noShowHost, + }; + + const isRecurring = !!formatted.recurringEventId; + if (isRecurring) { + formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted)); + } else { + formattedBookings.push(this.outputService.getOutputBooking(formatted)); + } + } + + return formattedBookings; + } + + async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput_2024_08_13) { + try { + const bookingRequest = await this.inputService.createRescheduleBookingRequest( + request, + bookingUid, + body + ); + const booking = await handleNewBooking(bookingRequest); + if (!booking.id) { + throw new Error("Booking was not created"); + } + + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.id); + if (!databaseBooking) { + throw new Error(`Booking with id=${booking.id} was not found in the database`); + } + + if (databaseBooking.recurringEventId) { + return this.outputService.getOutputRecurringBooking(databaseBooking); + } + return this.outputService.getOutputBooking(databaseBooking); + } catch (error) { + if (error instanceof Error) { + if (error.message === "no_available_users_found_error") { + throw new BadRequestException("User either already has booking at this time or is not available"); + } + } + throw error; + } + } + + async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput_2024_08_13) { + const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body); + await handleCancelBooking(bookingRequest); + return this.getBooking(bookingUid); + } + + async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) { + const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body); + + await handleMarkNoShow({ + bookingUid, + attendees: bodyTransformed.attendees, + noShowHost: bodyTransformed.noShowHost, + userId: bookingOwnerId, + }); + + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUser(bookingUid); + + if (!booking) { + throw new Error(`Booking with uid=${bookingUid} was not found in the database`); + } + + const isRecurring = !!booking.recurringEventId; + if (isRecurring) { + return this.outputService.getOutputRecurringBooking(booking); + } + return this.outputService.getOutputBooking(booking); + } + + async billBookings(bookings: CreatedBooking[]) { + for (const booking of bookings) { + await this.billBooking(booking); + } + } + + async billBooking(booking: CreatedBooking) { + const hostId = booking.hosts[0].id; + if (!hostId) { + this.logger.error(`Booking with uid=${booking.uid} has no host`); + return; + } + + await this.billingService.increaseUsageByUserId(hostId, { + uid: booking.uid, + startTime: new Date(booking.start), + }); + } + + async billRescheduledBooking(newBooking: CreatedBooking, oldBookingUid: string) { + const hostId = newBooking.hosts[0].id; + if (!hostId) { + this.logger.error(`Booking with uid=${newBooking.uid} has no host`); + return; + } + + await this.billingService.increaseUsageByUserId(hostId, { + uid: newBooking.uid, + startTime: new Date(newBooking.start), + fromReschedule: oldBookingUid, + }); + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts new file mode 100644 index 00000000000000..a74123242bec98 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -0,0 +1,418 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { bookingResponsesSchema } from "@/ee/bookings/2024-08-13/services/output.service"; +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Request } from "express"; +import { DateTime } from "luxon"; +import { NextApiRequest } from "next/types"; +import { v4 as uuidv4 } from "uuid"; +import { z } from "zod"; + +import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +import { + CancelBookingInput_2024_08_13, + CreateBookingInput_2024_08_13, + CreateInstantBookingInput_2024_08_13, + CreateRecurringBookingInput_2024_08_13, + GetBookingsInput_2024_08_13, + MarkAbsentBookingInput_2024_08_13, + RescheduleBookingInput_2024_08_13, +} from "@calcom/platform-types"; + +type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams; + +const DEFAULT_PLATFORM_PARAMS = { + platformClientId: "", + platformCancelUrl: "", + platformRescheduleUrl: "", + platformBookingUrl: "", + arePlatformEmailsEnabled: false, + platformBookingLocation: undefined, +}; + +type OAuthRequestParams = { + platformClientId: string; + platformRescheduleUrl: string; + platformCancelUrl: string; + platformBookingUrl: string; + platformBookingLocation?: string; + arePlatformEmailsEnabled: boolean; +}; + +export enum Frequency { + "YEARLY", + "MONTHLY", + "WEEKLY", + "DAILY", + "HOURLY", + "MINUTELY", + "SECONDLY", +} + +const recurringEventSchema = z.object({ + dtstart: z.string().optional(), + interval: z.number().int().optional(), + count: z.number().int().optional(), + freq: z.nativeEnum(Frequency).optional(), + until: z.string().optional(), +}); + +@Injectable() +export class InputBookingsService_2024_08_13 { + private readonly logger = new Logger("InputBookingsService_2024_08_13"); + + constructor( + private readonly oAuthFlowService: OAuthFlowService, + private readonly oAuthClientRepository: OAuthClientRepository, + private readonly eventTypesRepository: EventTypesRepository_2024_06_14, + private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly config: ConfigService, + private readonly apiKeyRepository: ApiKeyRepository + ) {} + + async createBookingRequest( + request: Request, + body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13 + ): Promise { + const bodyTransformed = await this.transformInputCreateBooking(body); + const oAuthClientId = request.get(X_CAL_CLIENT_ID); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + const oAuthParams = oAuthClientId + ? await this.createBookingRequestOAuthClientParams(oAuthClientId) + : DEFAULT_PLATFORM_PARAMS; + + const location = request.body.meetingUrl; + Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location }); + + newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled }; + + return newRequest as unknown as BookingRequest; + } + + async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) { + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam( + inputBooking.eventTypeId + ); + + if (!eventType) { + throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`); + } + + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone( + inputBooking.attendee.timeZone + ); + const endTime = startTime.plus({ minutes: eventType.length }); + + return { + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: inputBooking.eventTypeId, + timeZone: inputBooking.attendee.timeZone, + language: inputBooking.attendee.language || "en", + // todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c + // metadata: inputBooking.metadata || {}, + metadata: {}, + hasHashedBookingLink: false, + guests: inputBooking.guests, + // note(Lauris): responses with name and email are required by the handleNewBooking + responses: inputBooking.bookingFieldsResponses + ? { + ...inputBooking.bookingFieldsResponses, + name: inputBooking.attendee.name, + email: inputBooking.attendee.email, + } + : { name: inputBooking.attendee.name, email: inputBooking.attendee.email }, + }; + } + + async createRecurringBookingRequest( + request: Request, + body: CreateRecurringBookingInput_2024_08_13 + ): Promise { + // note(Lauris): update to this.transformInputCreate when rescheduling is implemented + const bodyTransformed = await this.transformInputCreateRecurringBooking(body); + const oAuthClientId = request.get(X_CAL_CLIENT_ID); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + const oAuthParams = oAuthClientId + ? await this.createBookingRequestOAuthClientParams(oAuthClientId) + : DEFAULT_PLATFORM_PARAMS; + + const location = request.body.meetingUrl; + Object.assign(newRequest, { + userId, + ...oAuthParams, + platformBookingLocation: location, + noEmail: !oAuthParams.arePlatformEmailsEnabled, + }); + + newRequest.body = bodyTransformed.map((event) => ({ + ...event, + })); + + return newRequest as unknown as BookingRequest; + } + + async transformInputCreateRecurringBooking(inputBooking: CreateRecurringBookingInput_2024_08_13) { + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam( + inputBooking.eventTypeId + ); + if (!eventType) { + throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`); + } + if (!eventType.recurringEvent) { + throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`); + } + + const occurrence = recurringEventSchema.parse(eventType.recurringEvent); + const repeatsEvery = occurrence.interval; + const repeatsTimes = occurrence.count; + // note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly + const timeBetween = occurrence.freq; + + if (!repeatsTimes) { + throw new Error("Repeats times is required"); + } + + const events = []; + const recurringEventId = uuidv4(); + + let startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone( + inputBooking.attendee.timeZone + ); + + for (let i = 0; i < repeatsTimes; i++) { + const endTime = startTime.plus({ minutes: eventType.length }); + + events.push({ + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: inputBooking.eventTypeId, + recurringEventId, + timeZone: inputBooking.attendee.timeZone, + language: inputBooking.attendee.language || "en", + // todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c + // metadata: inputBooking.metadata || {}, + metadata: {}, + hasHashedBookingLink: false, + guests: inputBooking.guests, + // note(Lauris): responses with name and email are required by the handleNewBooking + responses: inputBooking.bookingFieldsResponses + ? { + ...inputBooking.bookingFieldsResponses, + name: inputBooking.attendee.name, + email: inputBooking.attendee.email, + } + : { name: inputBooking.attendee.name, email: inputBooking.attendee.email }, + schedulingType: eventType.schedulingType, + }); + + switch (timeBetween) { + case 0: // Yearly + startTime = startTime.plus({ years: repeatsEvery }); + break; + case 1: // Monthly + startTime = startTime.plus({ months: repeatsEvery }); + break; + case 2: // Weekly + startTime = startTime.plus({ weeks: repeatsEvery }); + break; + default: + throw new Error("Unsupported timeBetween value"); + } + } + + return events; + } + + async createRescheduleBookingRequest( + request: Request, + bookingUid: string, + body: RescheduleBookingInput_2024_08_13 + ): Promise { + const bodyTransformed = await this.transformInputRescheduleBooking(bookingUid, body); + const oAuthClientId = request.get(X_CAL_CLIENT_ID); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + const oAuthParams = oAuthClientId + ? await this.createBookingRequestOAuthClientParams(oAuthClientId) + : DEFAULT_PLATFORM_PARAMS; + + const location = await this.getRescheduleBookingLocation(bookingUid); + Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location }); + + newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled }; + + return newRequest as unknown as BookingRequest; + } + + async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) { + const booking = await this.bookingsRepository.getByUidWithAttendeesAndUser(bookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} not found`); + } + if (!booking.eventTypeId) { + throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`); + } + const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId); + if (!eventType) { + throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`); + } + + const bookingResponses = bookingResponsesSchema.parse(booking.responses); + const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email); + + if (!attendee) { + throw new NotFoundException( + `Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} not found` + ); + } + + const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone); + const endTime = startTime.plus({ minutes: eventType.length }); + + return { + start: startTime.toISO(), + end: endTime.toISO(), + eventTypeId: eventType.id, + timeZone: attendee.timeZone, + language: attendee.locale, + // todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c + // metadata: booking.metadata || {}, + metadata: {}, + hasHashedBookingLink: false, + guests: bookingResponses.guests, + responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason }, + rescheduleUid: bookingUid, + }; + } + + async getRescheduleBookingLocation(rescheduleBookingUid: string) { + const booking = await this.bookingsRepository.getByUid(rescheduleBookingUid); + if (!booking) { + throw new NotFoundException(`Booking with uid=${rescheduleBookingUid} not found`); + } + return booking.location; + } + + private async createBookingRequestOwnerId(req: Request): Promise { + try { + const bearerToken = req.get("Authorization")?.replace("Bearer ", ""); + if (bearerToken) { + if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) { + const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix")); + const apiKeyHash = hashAPIKey(strippedApiKey); + const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); + return keyData?.userId; + } else { + // Access Token + return this.oAuthFlowService.getOwnerId(bearerToken); + } + } + } catch (err) { + this.logger.error(err); + } + } + + private async createBookingRequestOAuthClientParams(clientId: string) { + const params = DEFAULT_PLATFORM_PARAMS; + try { + const client = await this.oAuthClientRepository.getOAuthClient(clientId); + if (client) { + params.platformClientId = clientId; + params.platformCancelUrl = client.bookingCancelRedirectUri ?? ""; + params.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? ""; + params.platformBookingUrl = client.bookingRedirectUri ?? ""; + params.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false; + } + return params; + } catch (err) { + this.logger.error(err); + return params; + } + } + + transformGetBookingsFilters(queryParams: GetBookingsInput_2024_08_13) { + return { + attendeeEmail: queryParams.attendeeEmail, + attendeeName: queryParams.attendeeName, + afterStartDate: queryParams.afterStart, + beforeEndDate: queryParams.beforeEnd, + teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined), + eventTypeIds: + queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined), + }; + } + + transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) { + if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) { + return undefined; + } + + return { + sortStart: queryParams.sortStart, + sortEnd: queryParams.sortEnd, + sortCreated: queryParams.sortCreated, + }; + } + + async createCancelBookingRequest( + request: Request, + bookingUid: string, + body: CancelBookingInput_2024_08_13 + ): Promise { + const bodyTransformed = await this.transformInputCancelBooking(bookingUid, body); + const oAuthClientId = request.get(X_CAL_CLIENT_ID); + + const newRequest = { ...request }; + const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; + const oAuthParams = oAuthClientId + ? await this.createBookingRequestOAuthClientParams(oAuthClientId) + : DEFAULT_PLATFORM_PARAMS; + + Object.assign(newRequest, { userId, ...oAuthParams }); + + newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled }; + + return newRequest as unknown as BookingRequest; + } + + async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) { + let allRemainingBookings = false; + let uid = bookingUid; + const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(bookingUid); + + if (recurringBooking.length) { + // note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event + allRemainingBookings = true; + // note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id + uid = recurringBooking[0].uid; + } + + return { + uid, + cancellationReason: inputBooking.cancellationReason, + allRemainingBookings, + }; + } + + transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) { + return { + noShowHost: inputBooking.host, + attendees: inputBooking.attendees?.map((attendee) => ({ + email: attendee.email, + noShow: attendee.absent, + })), + }; + } +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts new file mode 100644 index 00000000000000..27a2318fd365e8 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts @@ -0,0 +1,114 @@ +import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { Injectable } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import { DateTime } from "luxon"; +import { z } from "zod"; + +import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types"; +import { Booking } from "@calcom/prisma/client"; + +export const bookingResponsesSchema = z.object({ + email: z.string(), + name: z.string(), + guests: z.array(z.string()).optional(), + rescheduledReason: z.string().optional(), +}); + +type DatabaseBooking = Booking & { + attendees: { + name: string; + email: string; + timeZone: string; + locale: string | null; + noShow: boolean | null; + }[]; +} & { user: { id: number; name: string | null; email: string } | null }; + +@Injectable() +export class OutputBookingsService_2024_08_13 { + constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {} + + getOutputBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + + const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses); + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + hosts: [databaseBooking.user], + status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, + reschedulingReason: bookingResponses?.rescheduledReason, + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventTypeId: databaseBooking.eventTypeId, + attendees: databaseBooking.attendees.map((attendee) => ({ + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + })), + guests: bookingResponses.guests, + meetingUrl: databaseBooking.location, + absentHost: !!databaseBooking.noShowHost, + }; + + return plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); + } + + async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) { + const transformed = []; + + for (const booking of databaseBookings) { + const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.id); + if (!databaseBooking) { + throw new Error(`Booking with id=${booking.id} was not found in the database`); + } + + transformed.push(this.getOutputRecurringBooking(databaseBooking)); + } + + return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + } + + getOutputRecurringBooking(databaseBooking: DatabaseBooking) { + const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); + const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); + const duration = dateEnd.diff(dateStart, "minutes").minutes; + + const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses); + + const booking = { + id: databaseBooking.id, + uid: databaseBooking.uid, + hosts: [databaseBooking.user], + status: databaseBooking.status.toLowerCase(), + cancellationReason: databaseBooking.cancellationReason || undefined, + reschedulingReason: bookingResponses?.rescheduledReason, + rescheduledFromUid: databaseBooking.fromReschedule || undefined, + start: databaseBooking.startTime, + end: databaseBooking.endTime, + duration, + eventTypeId: databaseBooking.eventTypeId, + attendees: databaseBooking.attendees.map((attendee) => ({ + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + })), + guests: bookingResponses.guests, + meetingUrl: databaseBooking.location, + recurringBookingUid: databaseBooking.recurringEventId, + absentHost: !!databaseBooking.noShowHost, + }; + + return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts index c7bf2bd7bae1e0..1c0ce0f0c5896f 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts @@ -102,6 +102,13 @@ export class EventTypesRepository_2024_06_14 { }); } + async getEventTypeByIdWithOwnerAndTeam(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + include: { owner: true, team: true }, + }); + } + async getUserEventTypeBySlug(userId: number, slug: string) { return this.dbRead.prisma.eventType.findUnique({ where: { diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts index 0e7df6730a5f31..b02b611399c5cd 100644 --- a/apps/api/v2/src/ee/platform-endpoints-module.ts +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -1,4 +1,5 @@ -import { BookingsModule } from "@/ee/bookings/bookings.module"; +import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module"; +import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module"; import { CalendarsModule } from "@/ee/calendars/calendars.module"; import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module"; import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; @@ -21,7 +22,8 @@ import { Module } from "@nestjs/common"; EventTypesModule_2024_04_15, EventTypesModule_2024_06_14, CalendarsModule, - BookingsModule, + BookingsModule_2024_04_15, + BookingsModule_2024_08_13, SlotsModule, ], }) diff --git a/apps/api/v2/src/lib/api-versions.ts b/apps/api/v2/src/lib/api-versions.ts index 62a70a4b83a372..ab9fcdae091c31 100644 --- a/apps/api/v2/src/lib/api-versions.ts +++ b/apps/api/v2/src/lib/api-versions.ts @@ -5,13 +5,16 @@ import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14, + VERSION_2024_08_13, } from "@calcom/platform-constants"; export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue; export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unknown as VersionValue; export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue; export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue; +export const VERSION_2024_08_13_VALUE: VersionValue = VERSION_2024_08_13 as unknown as VersionValue; export { VERSION_2024_04_15 }; export { VERSION_2024_06_11 }; export { VERSION_2024_06_14 }; +export { VERSION_2024_08_13 }; diff --git a/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts index 80e4b7037dc6ef..aea7ef04d6110e 100644 --- a/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts @@ -98,6 +98,7 @@ describe("Platform Destination Calendar Endpoints", () => { error: { message: "" }, }, ], + destinationCalendar: null, }) ); app = moduleRef.createNestApplication(); diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts index 0520556e7ff401..3a4acaf386e148 100644 --- a/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts @@ -18,6 +18,14 @@ export class OrganizationsTeamsRepository { }); } + async findTeamById(teamId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: teamId, + }, + }); + } + async findOrgTeams(organizationId: number) { return this.dbRead.prisma.team.findMany({ where: { diff --git a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts index e23bbe0e29587a..2eef2c4ccea2ee 100644 --- a/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts +++ b/apps/api/v2/src/modules/organizations/services/event-types/organizations-event-types.service.ts @@ -105,7 +105,7 @@ export class OrganizationsEventTypesService { role: user.role, organizationId: user.organizationId, organization: { isOrgAdmin }, - profile: { id: profileId }, + profile: { id: profileId || null }, metadata: user.metadata, }; } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 3f6e1df7724347..eb711680109843 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3755,64 +3755,16 @@ } }, "/v2/bookings": { - "get": { - "operationId": "BookingsController_getBookings", - "parameters": [ - { - "name": "cursor", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, - { - "name": "limit", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, - { - "name": "filters[status]", - "required": true, - "in": "query", - "schema": { - "enum": [ - "upcoming", - "recurring", - "past", - "cancelled", - "unconfirmed" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetBookingsOutput" - } - } - } - } - }, - "tags": [ - "Bookings" - ] - }, "post": { - "operationId": "BookingsController_createBooking", + "operationId": "BookingsController_2024_08_13_createBooking", + "summary": "Create booking", + "description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n \n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n ", "parameters": [ { - "name": "x-cal-client-id", - "required": true, + "name": "cal-api-version", "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, "schema": { "type": "string" } @@ -3820,10 +3772,21 @@ ], "requestBody": { "required": true, + "description": "Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateBookingInput" + "oneOf": [ + { + "$ref": "#/components/schemas/CreateBookingInput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateInstantBookingInput_2024_08_13" + }, + { + "$ref": "#/components/schemas/CreateRecurringBookingInput_2024_08_13" + } + ] } } } @@ -3834,7 +3797,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/CreateBookingOutput_2024_08_13" } } } @@ -3843,16 +3806,186 @@ "tags": [ "Bookings" ] - } - }, - "/v2/bookings/{bookingUid}": { + }, "get": { - "operationId": "BookingsController_getBooking", + "operationId": "BookingsController_2024_08_13_getBookings", "parameters": [ { - "name": "bookingUid", + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + "example": "?status=upcoming,past", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "upcoming", + "recurring", + "past", + "cancelled", + "unconfirmed" + ] + } + } + }, + { + "name": "attendeeEmail", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's email address.", + "example": "example@domain.com", + "schema": { + "type": "string" + } + }, + { + "name": "attendeeName", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's name.", + "example": "John Doe", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeIds", + "required": false, + "in": "query", + "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", + "example": "?eventTypeIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": false, + "in": "query", + "description": "Filter bookings by event type id belonging to the user.", + "example": "?eventTypeId=100", + "schema": { + "type": "string" + } + }, + { + "name": "teamsIds", + "required": false, + "in": "query", + "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", + "example": "?teamIds=50,60", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": false, + "in": "query", + "description": "Filter bookings by team id that user is part of", + "example": "?teamId=50", + "schema": { + "type": "string" + } + }, + { + "name": "afterStart", + "required": false, + "in": "query", + "description": "Filter bookings with start after this date string.", + "example": "?afterStart=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeEnd", + "required": false, + "in": "query", + "description": "Filter bookings with end before this date string.", + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortCreated", + "required": false, + "in": "query", + "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", "required": true, - "in": "path", "schema": { "type": "string" } @@ -3864,7 +3997,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetBookingOutput" + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" } } } @@ -3875,10 +4008,21 @@ ] } }, - "/v2/bookings/{bookingUid}/reschedule": { + "/v2/bookings/{bookingUid}": { "get": { - "operationId": "BookingsController_getBookingForReschedule", + "operationId": "BookingsController_2024_08_13_getBooking", + "summary": "Get booking", + "description": "`:bookingUid` can be\n \n 1. uid of a normal booking\n \n 2. uid of one of the recurring booking recurrences\n \n 3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).", "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "bookingUid", "required": true, @@ -3894,7 +4038,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/GetBookingOutput_2024_08_13" } } } @@ -3905,22 +4049,25 @@ ] } }, - "/v2/bookings/{bookingId}/cancel": { + "/v2/bookings/{bookingUid}/reschedule": { "post": { - "operationId": "BookingsController_cancelBooking", + "operationId": "BookingsController_2024_08_13_rescheduleBooking", + "summary": "Reschedule booking", + "description": "Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.", "parameters": [ { - "name": "bookingId", + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", "required": true, - "in": "path", "schema": { "type": "string" } }, { - "name": "x-cal-client-id", + "name": "bookingUid", "required": true, - "in": "header", + "in": "path", "schema": { "type": "string" } @@ -3931,7 +4078,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CancelBookingInput" + "$ref": "#/components/schemas/RescheduleBookingInput_2024_08_13" } } } @@ -3942,7 +4089,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/RescheduleBookingOutput_2024_08_13" } } } @@ -3953,10 +4100,19 @@ ] } }, - "/v2/bookings/{bookingUid}/mark-no-show": { + "/v2/bookings/{bookingUid}/cancel": { "post": { - "operationId": "BookingsController_markNoShow", + "operationId": "BookingsController_2024_08_13_cancelBooking", "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "bookingUid", "required": true, @@ -3971,18 +4127,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MarkNoShowInput" + "$ref": "#/components/schemas/CancelBookingInput_2024_08_13" } } } }, "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MarkNoShowOutput" + "$ref": "#/components/schemas/CancelBookingOutput_2024_08_13" } } } @@ -3993,14 +4149,32 @@ ] } }, - "/v2/bookings/recurring": { + "/v2/bookings/{bookingUid}/mark-absent": { "post": { - "operationId": "BookingsController_createRecurringBooking", + "operationId": "BookingsController_2024_08_13_markNoShow", "parameters": [ { - "name": "x-cal-client-id", + "name": "cal-api-version", + "in": "header", + "description": "Must be set to `2024-08-13`", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", "in": "header", + "description": "value must be `Bearer ` where `` either managed user access token or api key prefixed with cal_", + "required": true, "schema": { "type": "string" } @@ -4011,21 +4185,18 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "string" - } + "$ref": "#/components/schemas/MarkAbsentBookingInput_2024_08_13" } } } }, "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/MarkAbsentBookingOutput_2024_08_13" } } } @@ -4036,25 +4207,16 @@ ] } }, - "/v2/bookings/instant": { + "/v2/slots/reserve": { "post": { - "operationId": "BookingsController_createInstantBooking", - "parameters": [ - { - "name": "x-cal-client-id", - "required": true, - "in": "header", - "schema": { - "type": "string" - } - } - ], + "operationId": "SlotsController_reserveSlot", + "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateBookingInput" + "$ref": "#/components/schemas/ReserveSlotInput" } } } @@ -4072,44 +4234,13 @@ } }, "tags": [ - "Bookings" + "Slots" ] } }, - "/v2/slots/reserve": { - "post": { - "operationId": "SlotsController_reserveSlot", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReserveSlotInput" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - } - }, - "tags": [ - "Slots" - ] - } - }, - "/v2/slots/selected-slot": { - "delete": { - "operationId": "SlotsController_deleteSelectedSlot", + "/v2/slots/selected-slot": { + "delete": { + "operationId": "SlotsController_deleteSelectedSlot", "parameters": [], "responses": { "200": { @@ -10982,375 +11113,411 @@ "Attendee": { "type": "object", "properties": { - "id": { - "type": "number" + "name": { + "type": "string", + "description": "The name of the attendee.", + "example": "John Doe" }, "email": { - "type": "string" - }, - "name": { - "type": "string" + "type": "string", + "description": "The email of the attendee.", + "example": "john.doe@example.com" }, "timeZone": { - "type": "string" - }, - "locale": { "type": "string", - "nullable": true + "description": "The time zone of the attendee.", + "example": "America/New_York" }, - "bookingId": { - "type": "number", - "nullable": true + "language": { + "type": "string", + "enum": [ + "ar", + "ca", + "de", + "es", + "eu", + "he", + "id", + "ja", + "lv", + "pl", + "ro", + "sr", + "th", + "vi", + "az", + "cs", + "el", + "es-419", + "fi", + "hr", + "it", + "km", + "nl", + "pt", + "ru", + "sv", + "tr", + "zh-CN", + "bg", + "da", + "en", + "et", + "fr", + "hu", + "iw", + "ko", + "no", + "pt-BR", + "sk", + "ta", + "uk", + "zh-TW" + ], + "description": "The preferred language of the attendee. Used for booking confirmation.", + "example": "it", + "default": "en" } }, "required": [ - "id", - "email", "name", - "timeZone", - "locale", - "bookingId" + "email", + "timeZone" ] }, - "EventType": { + "CreateBookingInput_2024_08_13": { "type": "object", "properties": { - "slug": { - "type": "string" - }, - "id": { - "type": "number" - }, - "eventName": { + "start": { "type": "string", - "nullable": true - }, - "price": { - "type": "number" - }, - "recurringEvent": { - "type": "object" + "description": "The start time of the booking in ISO 8601 format in UTC timezone.", + "example": "2024-08-13T09:00:00Z" }, - "currency": { - "type": "string" + "eventTypeId": { + "type": "number", + "description": "The ID of the event type that is booked.", + "example": 123 }, - "metadata": { - "type": "object" + "attendee": { + "description": "The attendee's details.", + "allOf": [ + { + "$ref": "#/components/schemas/Attendee" + } + ] }, - "seatsShowAttendees": { - "type": "object" + "guests": { + "description": "An optional list of guest emails attending the event.", + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } }, - "seatsShowAvailabilityCount": { - "type": "object" + "meetingUrl": { + "type": "string", + "description": "Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + "example": "https://example.com/meeting" }, - "team": { + "bookingFieldsResponses": { "type": "object", - "nullable": true + "description": "Booking field responses.", + "example": { + "customField": "customValue" + } } }, "required": [ - "price", - "currency", - "metadata" + "start", + "eventTypeId", + "attendee" ] }, - "Reference": { + "CreateInstantBookingInput_2024_08_13": { "type": "object", "properties": { - "id": { - "type": "number" + "start": { + "type": "string", + "description": "The start time of the booking in ISO 8601 format in UTC timezone.", + "example": "2024-08-13T09:00:00Z" }, - "type": { - "type": "string" + "eventTypeId": { + "type": "number", + "description": "The ID of the event type that is booked.", + "example": 123 }, - "uid": { - "type": "string" + "attendee": { + "description": "The attendee's details.", + "allOf": [ + { + "$ref": "#/components/schemas/Attendee" + } + ] }, - "meetingId": { - "type": "string", - "nullable": true + "guests": { + "description": "An optional list of guest emails attending the event.", + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } }, - "thirdPartyRecurringEventId": { + "meetingUrl": { "type": "string", - "nullable": true + "description": "Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + "example": "https://example.com/meeting" }, - "meetingPassword": { - "type": "string", - "nullable": true + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses.", + "example": { + "customField": "customValue" + } }, - "meetingUrl": { + "instant": { + "type": "boolean", + "description": "Flag indicating if the booking is an instant booking. Only available for team events.", + "example": true + } + }, + "required": [ + "start", + "eventTypeId", + "attendee", + "instant" + ] + }, + "CreateRecurringBookingInput_2024_08_13": { + "type": "object", + "properties": { + "start": { "type": "string", - "nullable": true + "description": "The start time of the booking in ISO 8601 format in UTC timezone.", + "example": "2024-08-13T09:00:00Z" }, - "bookingId": { + "eventTypeId": { "type": "number", - "nullable": true + "description": "The ID of the event type that is booked.", + "example": 123 }, - "externalCalendarId": { - "type": "string", - "nullable": true + "attendee": { + "description": "The attendee's details.", + "allOf": [ + { + "$ref": "#/components/schemas/Attendee" + } + ] }, - "deleted": { - "type": "object" + "guests": { + "description": "An optional list of guest emails attending the event.", + "example": [ + "guest1@example.com", + "guest2@example.com" + ], + "type": "array", + "items": { + "type": "string" + } }, - "credentialId": { - "type": "number", - "nullable": true + "meetingUrl": { + "type": "string", + "description": "Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + "example": "https://example.com/meeting" + }, + "bookingFieldsResponses": { + "type": "object", + "description": "Booking field responses.", + "example": { + "customField": "customValue" + } } }, "required": [ - "id", - "type", - "uid", - "meetingPassword", - "bookingId", - "externalCalendarId", - "credentialId" + "start", + "eventTypeId", + "attendee" ] }, - "User": { + "Host": { "type": "object", "properties": { "id": { - "type": "number" + "type": "number", + "example": 1 }, "name": { "type": "string", - "nullable": true + "example": "Jane Doe" }, - "email": { - "type": "string" + "timeZone": { + "type": "string", + "example": "America/Los_Angeles" } }, "required": [ "id", "name", - "email" + "timeZone" ] }, - "GetBookingsDataEntry": { + "BookingOutput_2024_08_13": { "type": "object", "properties": { "id": { - "type": "number" - }, - "title": { - "type": "string" - }, - "userPrimaryEmail": { - "type": "string", - "nullable": true + "type": "number", + "example": 123 }, - "description": { + "uid": { "type": "string", - "nullable": true - }, - "customInputs": { - "type": "object" - }, - "startTime": { - "type": "string" - }, - "endTime": { - "type": "string" + "example": "booking_uid_123" }, - "attendees": { + "hosts": { "type": "array", "items": { - "$ref": "#/components/schemas/Attendee" + "$ref": "#/components/schemas/Host" } }, - "metadata": { - "type": "object" + "status": { + "type": "string", + "enum": [ + "cancelled", + "accepted", + "rejected", + "pending", + "rescheduled" + ], + "example": "accepted" }, - "uid": { - "type": "string" + "cancellationReason": { + "type": "string", + "example": "User requested cancellation" }, - "recurringEventId": { + "reschedulingReason": { "type": "string", - "nullable": true + "example": "User rescheduled the event" }, - "location": { + "rescheduledFromUid": { "type": "string", - "nullable": true + "example": "previous_uid_123" }, - "eventType": { - "$ref": "#/components/schemas/EventType" + "start": { + "type": "string", + "example": "2024-08-13T15:30:00Z" }, - "status": { - "type": "object" + "end": { + "type": "string", + "example": "2024-08-13T16:30:00Z" }, - "paid": { - "type": "boolean" + "duration": { + "type": "number", + "example": 60 }, - "payment": { - "type": "array", - "items": { - "type": "object" - } + "eventTypeId": { + "type": "number", + "example": 45 }, - "references": { + "attendees": { "type": "array", "items": { - "$ref": "#/components/schemas/Reference" + "$ref": "#/components/schemas/Attendee" } }, - "isRecorded": { - "type": "boolean" - }, - "seatsReferences": { + "guests": { + "example": [ + "guest1@example.com", + "guest2@example.com" + ], "type": "array", "items": { - "type": "object" + "type": "string" } }, - "user": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/User" - } - ] + "meetingUrl": { + "type": "string", + "example": "https://example.com/meeting" }, - "rescheduled": { - "type": "object" + "absentHost": { + "type": "boolean", + "example": true } }, "required": [ "id", - "title", - "description", - "customInputs", - "startTime", - "endTime", - "attendees", - "metadata", "uid", - "recurringEventId", - "location", - "eventType", + "hosts", "status", - "paid", - "payment", - "references", - "isRecorded", - "seatsReferences", - "user" + "start", + "end", + "duration", + "eventTypeId", + "attendees", + "absentHost" ] }, - "GetBookingsData": { + "RecurringBookingOutput_2024_08_13": { "type": "object", "properties": { - "bookings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GetBookingsDataEntry" - } + "id": { + "type": "number", + "example": 456 }, - "recurringInfo": { + "uid": { + "type": "string", + "example": "recurring_uid_123" + }, + "hosts": { "type": "array", "items": { - "type": "object" + "$ref": "#/components/schemas/Host" } }, - "nextCursor": { - "type": "number", - "nullable": true - } - }, - "required": [ - "bookings", - "recurringInfo", - "nextCursor" - ] - }, - "GetBookingsOutput": { - "type": "object", - "properties": { "status": { "type": "string", - "example": "success", "enum": [ - "success", - "error" - ] - }, - "data": { - "$ref": "#/components/schemas/GetBookingsData" - } - }, - "required": [ - "status", - "data" - ] - }, - "GetBookingData": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "number" - }, - "uid": { - "type": "string" + "cancelled", + "accepted", + "rejected", + "pending" + ], + "example": "pending" }, - "description": { + "cancellationReason": { "type": "string", - "nullable": true - }, - "customInputs": { - "type": "object" + "example": "Event was cancelled" }, - "smsReminderNumber": { + "reschedulingReason": { "type": "string", - "nullable": true + "example": "Event was rescheduled" }, - "recurringEventId": { + "rescheduledFromUid": { "type": "string", - "nullable": true - }, - "startTime": { - "format": "date-time", - "type": "string" - }, - "endTime": { - "format": "date-time", - "type": "string" + "example": "previous_recurring_uid_123" }, - "location": { + "start": { "type": "string", - "nullable": true - }, - "status": { - "type": "string" - }, - "metadata": { - "type": "object" + "example": "2024-08-13T15:30:00Z" }, - "cancellationReason": { + "end": { "type": "string", - "nullable": true + "example": "2024-08-13T16:30:00Z" }, - "responses": { - "type": "object" + "duration": { + "type": "number", + "example": 30 }, - "rejectionReason": { - "type": "string", - "nullable": true + "eventTypeId": { + "type": "number", + "example": 50 }, - "userPrimaryEmail": { + "recurringBookingUid": { "type": "string", - "nullable": true - }, - "user": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/User" - } - ] + "example": "recurring_uid_987" }, "attendees": { "type": "array", @@ -11358,43 +11525,40 @@ "$ref": "#/components/schemas/Attendee" } }, - "eventTypeId": { - "type": "number", - "nullable": true + "guests": { + "example": [ + "guest3@example.com", + "guest4@example.com" + ], + "type": "array", + "items": { + "type": "string" + } }, - "eventType": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/EventType" - } - ] + "meetingUrl": { + "type": "string", + "example": "https://example.com/recurring-meeting" + }, + "absentHost": { + "type": "boolean", + "example": false } }, "required": [ - "title", "id", "uid", - "description", - "customInputs", - "smsReminderNumber", - "recurringEventId", - "startTime", - "endTime", - "location", + "hosts", "status", - "metadata", - "cancellationReason", - "responses", - "rejectionReason", - "userPrimaryEmail", - "user", - "attendees", + "start", + "end", + "duration", "eventTypeId", - "eventType" + "recurringBookingUid", + "attendees", + "absentHost" ] }, - "GetBookingOutput": { + "CreateBookingOutput_2024_08_13": { "type": "object", "properties": { "status": { @@ -11406,7 +11570,18 @@ ] }, "data": { - "$ref": "#/components/schemas/GetBookingData" + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + } + ], + "description": "Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects" } }, "required": [ @@ -11414,177 +11589,190 @@ "data" ] }, - "Location": { + "GetBookingOutput_2024_08_13": { "type": "object", "properties": { - "optionValue": { - "type": "string" + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] }, - "value": { - "type": "string" + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + } + ], + "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects" } }, "required": [ - "optionValue", - "value" + "status", + "data" ] }, - "Response": { + "GetBookingsOutput_2024_08_13": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string" + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] }, - "guests": { + "data": { "type": "array", "items": { - "type": "string" - } - }, - "location": { - "$ref": "#/components/schemas/Location" - }, - "notes": { - "type": "string" + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + ] + }, + "description": "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects" } }, "required": [ - "name", - "email", - "guests" + "status", + "data" ] }, - "CreateBookingInput": { + "RescheduleBookingInput_2024_08_13": { "type": "object", "properties": { - "end": { - "type": "string" - }, "start": { - "type": "string" - }, - "eventTypeId": { - "type": "number" - }, - "eventTypeSlug": { - "type": "string" - }, - "rescheduleUid": { - "type": "string" - }, - "timeZone": { - "type": "string" - }, - "user": { - "type": "array", - "items": { - "type": "string" - } - }, - "language": { - "type": "string" - }, - "bookingUid": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "hasHashedBookingLink": { - "type": "boolean" - }, - "hashedLink": { "type": "string", - "nullable": true - }, - "seatReferenceUid": { - "type": "string" - }, - "responses": { - "$ref": "#/components/schemas/Response" + "description": "Start time in ISO 8601 format for the new booking", + "example": "2024-08-13T10:00:00Z" }, - "orgSlug": { - "type": "string" - }, - "locationUrl": { - "type": "string" + "reschedulingReason": { + "type": "string", + "example": "User requested reschedule", + "description": "Reason for rescheduling the booking" } }, "required": [ - "start", - "eventTypeId", - "timeZone", - "language", - "metadata", - "hashedLink", - "responses" + "start" ] }, - "CancelBookingInput": { + "RescheduleBookingOutput_2024_08_13": { "type": "object", "properties": { - "id": { - "type": "number" - }, - "uid": { - "type": "string" - }, - "allRemainingBookings": { - "type": "boolean" + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + ], + "description": "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object" + } + }, + "required": [ + "status", + "data" + ] + }, + "CancelBookingInput_2024_08_13": { + "type": "object", + "properties": { "cancellationReason": { - "type": "string" - }, - "seatReferenceUid": { - "type": "string" + "type": "string", + "example": "User requested cancellation" } }, "required": [ - "id", - "uid", - "allRemainingBookings", - "cancellationReason", - "seatReferenceUid" + "cancellationReason" ] }, - "MarkNoShowInput": { + "CancelBookingOutput_2024_08_13": { "type": "object", "properties": { - "noShowHost": { - "type": "boolean" + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] }, - "attendees": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Attendee" - } + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + } + ], + "description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects" } - } + }, + "required": [ + "status", + "data" + ] }, - "HandleMarkNoShowData": { + "MarkAbsentBookingInput_2024_08_13": { "type": "object", "properties": { - "message": { - "type": "string" - }, - "noShowHost": { - "type": "boolean" + "host": { + "type": "boolean", + "example": false, + "description": "Whether the host was absent" }, "attendees": { + "description": "Toggle whether an attendee was absent or not.", + "example": [ + { + "absent": true, + "email": "someone@gmail.com" + } + ], "type": "array", "items": { - "$ref": "#/components/schemas/Attendee" + "type": "string" } } }, "required": [ - "message" + "attendees" ] }, - "MarkNoShowOutput": { + "MarkAbsentBookingOutput_2024_08_13": { "type": "object", "properties": { "status": { @@ -11596,7 +11784,15 @@ ] }, "data": { - "$ref": "#/components/schemas/HandleMarkNoShowData" + "oneOf": [ + { + "$ref": "#/components/schemas/BookingOutput_2024_08_13" + }, + { + "$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13" + } + ], + "description": "Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object" } }, "required": [ diff --git a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts index 83b7e5f21a6664..5bc6ccacb554da 100644 --- a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -3,6 +3,8 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { TestingModule } from "@nestjs/testing"; import { Booking, User } from "@prisma/client"; +import { Prisma } from "@calcom/prisma/client"; + export class BookingsRepositoryFixture { private prismaReadClient: PrismaReadService["prisma"]; private prismaWriteClient: PrismaWriteService["prisma"]; @@ -16,6 +18,14 @@ export class BookingsRepositoryFixture { return this.prismaReadClient.booking.findFirst({ where: { id: bookingId } }); } + async getByUid(bookingUid: Booking["uid"]) { + return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } }); + } + + async create(booking: Prisma.BookingCreateInput) { + return this.prismaWriteClient.booking.create({ data: booking }); + } + async deleteById(bookingId: Booking["id"]) { return this.prismaWriteClient.booking.delete({ where: { id: bookingId } }); } diff --git a/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts new file mode 100644 index 00000000000000..304fdf1415a690 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts @@ -0,0 +1,25 @@ +import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { EventType } from "@prisma/client"; + +import { Prisma } from "@calcom/prisma/client"; + +export class HostsRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.HostCreateInput) { + return this.prismaWriteClient.host.create({ + data: { + ...data, + }, + }); + } +} diff --git a/packages/features/bookings/lib/handleNewRecurringBooking.ts b/packages/features/bookings/lib/handleNewRecurringBooking.ts index 9319abbd803650..3185046f38f77a 100644 --- a/packages/features/bookings/lib/handleNewRecurringBooking.ts +++ b/packages/features/bookings/lib/handleNewRecurringBooking.ts @@ -6,7 +6,15 @@ import { SchedulingType } from "@calcom/prisma/client"; import type { AppsStatus } from "@calcom/types/Calendar"; export const handleNewRecurringBooking = async ( - req: NextApiRequest & { userId?: number } + req: NextApiRequest & { + userId?: number | undefined; + platformClientId?: string; + platformRescheduleUrl?: string; + platformCancelUrl?: string; + platformBookingUrl?: string; + platformBookingLocation?: string; + noEmail?: boolean; + } ): Promise => { const data: RecurringBookingCreateBody[] = req.body; const createdBookings: BookingResponse[] = []; @@ -73,7 +81,7 @@ export const handleNewRecurringBooking = async ( thirdPartyRecurringEventId, numSlotsToCheckForAvailability, currentRecurringIndex: key, - noEmail: key !== 0, + noEmail: req.noEmail !== undefined ? req.noEmail : key !== 0, luckyUsers, }; diff --git a/packages/lib/bookings/getAllUserBookings.ts b/packages/lib/bookings/getAllUserBookings.ts index 4aa1c70cfa4540..82ca861b77957d 100644 --- a/packages/lib/bookings/getAllUserBookings.ts +++ b/packages/lib/bookings/getAllUserBookings.ts @@ -4,26 +4,34 @@ import { BookingStatus } from "@calcom/prisma/enums"; import { getBookings } from "@calcom/trpc/server/routers/viewer/bookings/get.handler"; type InputByStatus = "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed"; +type SortOptions = { + sortStart?: "asc" | "desc"; + sortEnd?: "asc" | "desc"; + sortCreated?: "asc" | "desc"; +}; type GetOptions = { ctx: { user: { id: number; email: string }; prisma: PrismaClient; }; - bookingListingByStatus: InputByStatus; + bookingListingByStatus: InputByStatus[]; take: number; skip: number; filters: { - status: InputByStatus; + status?: InputByStatus; teamIds?: number[] | undefined; userIds?: number[] | undefined; eventTypeIds?: number[] | undefined; + attendeeEmail?: string; + attendeeName?: string; }; + sort?: SortOptions; }; -const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, skip }: GetOptions) => { +const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, skip, sort }: GetOptions) => { const { prisma, user } = ctx; - const bookingListingFilters: Record = { + const bookingListingFilters: Record = { upcoming: { endTime: { gte: new Date() }, // These changes are needed to not show confirmed recurring events, @@ -62,24 +70,17 @@ const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, status: { equals: BookingStatus.PENDING }, }, }; - const bookingListingOrderby: Record< - typeof bookingListingByStatus, - Prisma.BookingOrderByWithAggregationInput - > = { - upcoming: { startTime: "asc" }, - recurring: { startTime: "asc" }, - past: { startTime: "desc" }, - cancelled: { startTime: "desc" }, - unconfirmed: { startTime: "asc" }, - }; - const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus]; - const orderBy = bookingListingOrderby[bookingListingByStatus]; + const orderBy = getOrderBy(bookingListingByStatus, sort); + + const combinedFilters = bookingListingByStatus.map((status) => bookingListingFilters[status]); const { bookings, recurringInfo } = await getBookings({ user, prisma, - passedBookingsStatusFilter, + passedBookingsStatusFilter: { + OR: combinedFilters, + }, filters: filters, orderBy, take, @@ -101,4 +102,33 @@ const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, }; }; +function getOrderBy( + bookingListingByStatus: InputByStatus[], + sort?: SortOptions +): Prisma.BookingOrderByWithAggregationInput { + const bookingListingOrderby: Record = { + upcoming: { startTime: "asc" }, + recurring: { startTime: "asc" }, + past: { startTime: "desc" }, + cancelled: { startTime: "desc" }, + unconfirmed: { startTime: "asc" }, + }; + + if (bookingListingByStatus?.length === 1 && !sort) { + return bookingListingOrderby[bookingListingByStatus[0]]; + } + + if (sort?.sortStart) { + return { startTime: sort.sortStart }; + } + if (sort?.sortEnd) { + return { endTime: sort.sortEnd }; + } + if (sort?.sortCreated) { + return { createdAt: sort.sortCreated }; + } + + return { startTime: "asc" }; +} + export default getAllUserBookings; diff --git a/packages/platform/atoms/hooks/useGetBookings.ts b/packages/platform/atoms/hooks/useGetBookings.ts index afa8578f3dc371..70edd64c1e8dcc 100644 --- a/packages/platform/atoms/hooks/useGetBookings.ts +++ b/packages/platform/atoms/hooks/useGetBookings.ts @@ -3,13 +3,13 @@ import { useQuery } from "@tanstack/react-query"; import { SUCCESS_STATUS, V2_ENDPOINTS } from "@calcom/platform-constants"; import type { getAllUserBookings } from "@calcom/platform-libraries"; import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; -import type { GetBookingsInput } from "@calcom/platform-types/bookings"; +import type { GetBookingsInput_2024_04_15 } from "@calcom/platform-types/bookings"; import http from "../lib/http"; export const QUERY_KEY = "user-bookings"; -export const useGetBookings = (input: GetBookingsInput) => { +export const useGetBookings = (input: GetBookingsInput_2024_04_15) => { const pathname = `/${V2_ENDPOINTS.bookings}`; const bookingsQuery = useQuery({ diff --git a/packages/platform/constants/api.ts b/packages/platform/constants/api.ts index 4a3e2b9c365aae..2ebbdebdd5f11e 100644 --- a/packages/platform/constants/api.ts +++ b/packages/platform/constants/api.ts @@ -54,8 +54,14 @@ export const HTTP_CODE_TOKEN_EXPIRED = 498; export const VERSION_2024_06_14 = "2024-06-14"; export const VERSION_2024_06_11 = "2024-06-11"; export const VERSION_2024_04_15 = "2024-04-15"; +export const VERSION_2024_08_13 = "2024-08-13"; -export const API_VERSIONS = [VERSION_2024_06_14, VERSION_2024_06_11, VERSION_2024_04_15] as const; +export const API_VERSIONS = [ + VERSION_2024_06_14, + VERSION_2024_06_11, + VERSION_2024_04_15, + VERSION_2024_08_13, +] as const; export type API_VERSIONS_ENUM = (typeof API_VERSIONS)[number]; export type API_VERSIONS_TYPE = typeof API_VERSIONS; diff --git a/packages/platform/libraries/CHANGELOG.md b/packages/platform/libraries/CHANGELOG.md index 3fe0463f792139..9b28b53210cec8 100644 --- a/packages/platform/libraries/CHANGELOG.md +++ b/packages/platform/libraries/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.0.37 +Released to support PR https://github.com/calcom/cal.com/pull/16200 + ## 0.0.36 Released to support PR https://github.com/calcom/cal.com/pull/16685 diff --git a/packages/platform/types/bookings/2024-04-15/index.ts b/packages/platform/types/bookings/2024-04-15/index.ts new file mode 100644 index 00000000000000..1ac83e88a736ab --- /dev/null +++ b/packages/platform/types/bookings/2024-04-15/index.ts @@ -0,0 +1 @@ +export * from "./inputs"; diff --git a/packages/platform/types/bookings.ts b/packages/platform/types/bookings/2024-04-15/inputs/index.ts similarity index 87% rename from packages/platform/types/bookings.ts rename to packages/platform/types/bookings/2024-04-15/inputs/index.ts index 600dee7425091c..970650e5fabad7 100644 --- a/packages/platform/types/bookings.ts +++ b/packages/platform/types/bookings/2024-04-15/inputs/index.ts @@ -12,7 +12,7 @@ import { IsString, } from "class-validator"; -export enum Status { +export enum Status_2024_04_15 { upcoming = "upcoming", recurring = "recurring", past = "past", @@ -20,7 +20,7 @@ export enum Status { unconfirmed = "unconfirmed", } -type BookingStatus = `${Status}`; +type BookingStatus = `${Status_2024_04_15}`; class Filters { @IsOptional() @@ -33,7 +33,7 @@ class Filters { @Type(() => Number) userIds?: number[]; - @IsEnum(Status) + @IsEnum(Status_2024_04_15) status!: BookingStatus; @IsOptional() @@ -42,7 +42,7 @@ class Filters { eventTypeIds?: number[]; } -export class GetBookingsInput { +export class GetBookingsInput_2024_04_15 { @ValidateNested({ each: true }) @Type(() => Filters) filters!: Filters; @@ -60,7 +60,7 @@ export class GetBookingsInput { cursor?: number | null; } -export class CancelBookingInput { +export class CancelBookingInput_2024_04_15 { @IsNumber() @IsOptional() @ApiProperty() diff --git a/packages/platform/types/bookings/2024-08-13/index.ts b/packages/platform/types/bookings/2024-08-13/index.ts new file mode 100644 index 00000000000000..775f90ba48c3fa --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/index.ts @@ -0,0 +1,2 @@ +export * from "./inputs"; +export * from "./outputs"; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts new file mode 100644 index 00000000000000..1fa5b5f51eb21f --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/cancel-booking.input.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; + +export class CancelBookingInput_2024_08_13 { + @IsString() + @IsOptional() + @ApiProperty({ example: "User requested cancellation" }) + cancellationReason?: string; +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/create-booking-input.pipe.ts b/packages/platform/types/bookings/2024-08-13/inputs/create-booking-input.pipe.ts new file mode 100644 index 00000000000000..891e53b1bb4903 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/create-booking-input.pipe.ts @@ -0,0 +1,111 @@ +import type { PipeTransform } from "@nestjs/common"; +import { Injectable, BadRequestException } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import type { ValidationError } from "class-validator"; +import { validateSync } from "class-validator"; + +import { CreateRecurringBookingInput_2024_08_13 } from "./create-booking.input"; +import { CreateBookingInput_2024_08_13 } from "./create-booking.input"; +import { CreateInstantBookingInput_2024_08_13 } from "./create-booking.input"; + +export type CreateBookingInput = + | CreateBookingInput_2024_08_13 + | CreateRecurringBookingInput_2024_08_13 + | CreateInstantBookingInput_2024_08_13; + +@Injectable() +export class CreateBookingInputPipe implements PipeTransform { + // note(Lauris): we need empty constructor otherwise v2 can't be started due to error: + // CreateBookingInputPipe is not a constructor + + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor() {} + + transform(value: CreateBookingInput): CreateBookingInput { + if (!value) { + throw new BadRequestException("Body is required"); + } + if (typeof value !== "object") { + throw new BadRequestException("Body should be an object"); + } + + if (this.isRecurringBookingInput(value)) { + return this.validateRecurringBooking(value); + } + + if (this.isInstantBookingInput(value)) { + return this.validateInstantBooking(value); + } + + return this.validateBooking(value); + } + + validateBooking(value: CreateBookingInput_2024_08_13) { + const object = plainToClass(CreateBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + validateRecurringBooking(value: CreateRecurringBookingInput_2024_08_13) { + const object = plainToClass(CreateRecurringBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + validateInstantBooking(value: CreateInstantBookingInput_2024_08_13) { + const object = plainToClass(CreateInstantBookingInput_2024_08_13, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new BadRequestException(this.formatErrors(errors)); + } + + return object; + } + + private formatErrors(errors: ValidationError[]): string { + return errors + .map((err) => { + const constraints = err.constraints ? Object.values(err.constraints).join(", ") : ""; + const childrenErrors = + err.children && err.children.length > 0 ? `${this.formatErrors(err.children)}` : ""; + return `${err.property} property is wrong,${constraints} ${childrenErrors}`; + }) + .join(", "); + } + + private isRecurringBookingInput( + value: CreateBookingInput + ): value is CreateRecurringBookingInput_2024_08_13 { + return value.hasOwnProperty("recurringEventTypeId"); + } + + private isInstantBookingInput(value: CreateBookingInput): value is CreateRecurringBookingInput_2024_08_13 { + return value.hasOwnProperty("instant") && "instant" in value && value.instant === true; + } +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts new file mode 100644 index 00000000000000..6982c1f474b492 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts @@ -0,0 +1,201 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsInt, + IsDateString, + IsTimeZone, + IsEnum, + IsEmail, + ValidateNested, + IsArray, + IsString, + IsOptional, + IsUrl, + IsObject, + IsBoolean, +} from "class-validator"; + +import type { BookingLanguageType } from "./language"; +import { BookingLanguage } from "./language"; + +class Attendee { + @ApiProperty({ + type: String, + description: "The name of the attendee.", + example: "John Doe", + }) + @IsString() + name!: string; + + @ApiProperty({ + type: String, + description: "The email of the attendee.", + example: "john.doe@example.com", + }) + @IsEmail() + email!: string; + + @ApiProperty({ + type: String, + description: "The time zone of the attendee.", + example: "America/New_York", + }) + @IsTimeZone() + timeZone!: string; + + @ApiPropertyOptional({ + enum: BookingLanguage, + description: "The preferred language of the attendee. Used for booking confirmation.", + example: BookingLanguage.it, + default: BookingLanguage.en, + }) + @IsEnum(BookingLanguage) + @IsOptional() + language?: BookingLanguageType; +} + +export class CreateBookingInput_2024_08_13 { + @ApiProperty({ + type: String, + description: "The start time of the booking in ISO 8601 format in UTC timezone.", + example: "2024-08-13T09:00:00Z", + }) + @IsDateString() + start!: string; + + @ApiProperty({ + type: Number, + description: "The ID of the event type that is booked.", + example: 123, + }) + @IsInt() + eventTypeId!: number; + + @ApiProperty({ + type: Attendee, + description: "The attendee's details.", + }) + @ValidateNested() + @Type(() => Attendee) + attendee!: Attendee; + + @ApiPropertyOptional({ + type: [String], + description: "An optional list of guest emails attending the event.", + example: ["guest1@example.com", "guest2@example.com"], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + guests?: string[]; + + @ApiPropertyOptional({ + type: String, + description: + "Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + example: "https://example.com/meeting", + }) + @IsUrl() + @IsOptional() + meetingUrl?: string; + + // todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c + // @ApiProperty({ + // type: Object, + // description: "Optional metadata for the booking.", + // example: { key: "value" }, + // required: false, + // }) + // @IsObject() + // @IsOptional() + // metadata!: Record; + + @ApiPropertyOptional({ + type: Object, + description: "Booking field responses.", + example: { customField: "customValue" }, + required: false, + }) + @IsObject() + @IsOptional() + bookingFieldsResponses?: Record; +} + +export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 { + @ApiProperty({ + type: Boolean, + description: "Flag indicating if the booking is an instant booking. Only available for team events.", + example: true, + }) + @IsBoolean() + instant!: boolean; +} + +export class CreateRecurringBookingInput_2024_08_13 { + @ApiProperty({ + type: String, + description: "The start time of the booking in ISO 8601 format in UTC timezone.", + example: "2024-08-13T09:00:00Z", + }) + @IsDateString() + start!: string; + + @ApiProperty({ + type: Number, + description: "The ID of the event type that is booked.", + example: 123, + }) + @IsInt() + eventTypeId!: number; + + @ApiProperty({ + type: Attendee, + description: "The attendee's details.", + }) + @ValidateNested() + @Type(() => Attendee) + attendee!: Attendee; + + @ApiProperty({ + type: [String], + description: "An optional list of guest emails attending the event.", + example: ["guest1@example.com", "guest2@example.com"], + required: false, + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + guests?: string[]; + + @ApiProperty({ + type: String, + description: + "Meeting URL just for this booking. Displayed in email and calendar event. If not provided then cal video link will be generated.", + example: "https://example.com/meeting", + required: false, + }) + @IsUrl() + @IsOptional() + meetingUrl?: string; + + // todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c + // @ApiProperty({ + // type: Object, + // description: "Optional metadata for the booking.", + // example: { key: "value" }, + // required: false, + // }) + // @IsObject() + // @IsOptional() + // metadata!: Record; + + @ApiProperty({ + type: Object, + description: "Booking field responses.", + example: { customField: "customValue" }, + required: false, + }) + @IsObject() + @IsOptional() + bookingFieldsResponses?: Record; +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts new file mode 100644 index 00000000000000..1a7307cf89f454 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/get-bookings.input.ts @@ -0,0 +1,208 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + ArrayMinSize, + ArrayNotEmpty, + IsArray, + IsEnum, + IsInt, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +enum Status { + upcoming = "upcoming", + recurring = "recurring", + past = "past", + cancelled = "cancelled", + unconfirmed = "unconfirmed", +} +type StatusType = keyof typeof Status; + +enum SortOrder { + asc = "asc", + desc = "desc", +} +type SortOrderType = keyof typeof SortOrder; + +export class GetBookingsInput_2024_08_13 { + // note(Lauris): filters + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((status: string) => status.trim()); + } + return value; + }) + @ArrayNotEmpty({ message: "status cannot be empty." }) + @IsEnum(Status, { + each: true, + message: "Invalid status. Allowed are upcoming, recurring, past, cancelled, unconfirmed", + }) + @ApiProperty({ + required: false, + description: + "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + example: "?status=upcoming,past", + enum: Status, + isArray: true, + }) + status?: StatusType[]; + + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by the attendee's email address.", + example: "example@domain.com", + }) + attendeeEmail?: string; + + @IsString() + @IsOptional() + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by the attendee's name.", + example: "John Doe", + }) + attendeeName?: string; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((eventTypeId: string) => parseInt(eventTypeId)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "eventTypeIds must contain at least 1 event type id" }) + @ApiProperty({ + type: String, + required: false, + description: + "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", + example: "?eventTypeIds=100,200", + }) + eventTypeIds?: number[]; + + @IsInt() + @IsOptional() + @Type(() => Number) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by event type id belonging to the user.", + example: "?eventTypeId=100", + }) + eventTypeId?: number; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((teamId: string) => parseInt(teamId)); + } + return value; + }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", + example: "?teamIds=50,60", + }) + teamsIds?: number[]; + + @IsInt() + @IsOptional() + @Type(() => Number) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by team id that user is part of", + example: "?teamId=50", + }) + teamId?: number; + + @IsOptional() + @IsISO8601({ strict: true }, { message: "fromDate must be a valid ISO 8601 date." }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings with start after this date string.", + example: "?afterStart=2025-03-07T10:00:00.000Z", + }) + afterStart?: string; + + @IsOptional() + @IsISO8601({ strict: true }, { message: "toDate must be a valid ISO 8601 date." }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings with end before this date string.", + example: "?beforeEnd=2025-03-07T11:00:00.000Z", + }) + beforeEnd?: string; + + // note(Lauris): sort + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortStart must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their start time in ascending or descending order.", + example: "?sortStart=asc OR ?sortStart=desc", + enum: SortOrder, + }) + sortStart?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortEnd must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: "Sort results by their end time in ascending or descending order.", + example: "?sortEnd=asc OR ?sortEnd=desc", + enum: SortOrder, + }) + sortEnd?: SortOrderType; + + @IsOptional() + @IsEnum(SortOrder, { + message: 'SortCreated must be either "asc" or "desc".', + }) + @ApiProperty({ + required: false, + description: + "Sort results by their creation time (when booking was made) in ascending or descending order.", + example: "?sortEnd=asc OR ?sortEnd=desc", + enum: SortOrder, + }) + sortCreated?: SortOrderType; + + // note(Lauris): pagination + @ApiProperty({ required: false, description: "The number of items to return", example: 10 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(1) + @Max(250) + @IsOptional() + take?: number; + + @ApiProperty({ required: false, description: "The number of items to skip", example: 0 }) + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(0) + @IsOptional() + skip?: number; +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/index.ts b/packages/platform/types/bookings/2024-08-13/inputs/index.ts new file mode 100644 index 00000000000000..58b3a49b0fc1d6 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/index.ts @@ -0,0 +1,6 @@ +export * from "./create-booking.input"; +export * from "./create-booking-input.pipe"; +export * from "./get-bookings.input"; +export * from "./reschedule-booking.input"; +export * from "./cancel-booking.input"; +export * from "./mark-absent.input"; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/language.ts b/packages/platform/types/bookings/2024-08-13/inputs/language.ts new file mode 100644 index 00000000000000..c67edfb6b32ea7 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/language.ts @@ -0,0 +1,46 @@ +export enum BookingLanguage { + "ar" = "ar", + "ca" = "ca", + "de" = "de", + "es" = "es", + "eu" = "eu", + "he" = "he", + "id" = "id", + "ja" = "ja", + "lv" = "lv", + "pl" = "pl", + "ro" = "ro", + "sr" = "sr", + "th" = "th", + "vi" = "vi", + "az" = "az", + "cs" = "cs", + "el" = "el", + "es-419" = "es-419", + "fi" = "fi", + "hr" = "hr", + "it" = "it", + "km" = "km", + "nl" = "nl", + "pt" = "pt", + "ru" = "ru", + "sv" = "sv", + "tr" = "tr", + "zh-CN" = "zh-CN", + "bg" = "bg", + "da" = "da", + "en" = "en", + "et" = "et", + "fr" = "fr", + "hu" = "hu", + "iw" = "iw", + "ko" = "ko", + "no" = "no", + "pt-BR" = "pt-BR", + "sk" = "sk", + "ta" = "ta", + "uk" = "uk", + "zh-TW" = "zh-TW", +} + +export type BookingLanguageType = keyof typeof BookingLanguage; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/mark-absent.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/mark-absent.input.ts new file mode 100644 index 00000000000000..4dc28920e4b382 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/mark-absent.input.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsOptional, IsBoolean, IsEmail, IsArray, ArrayMinSize, ValidateNested } from "class-validator"; + +class Attendee { + @IsEmail() + @ApiProperty() + email!: string; + + @IsBoolean() + @ApiProperty() + absent!: boolean; +} + +export class MarkAbsentBookingInput_2024_08_13 { + @IsBoolean() + @IsOptional() + @ApiProperty({ example: false, required: false, description: "Whether the host was absent" }) + host?: boolean; + + @ArrayMinSize(1) + @ApiProperty({ + type: [String], + description: "Toggle whether an attendee was absent or not.", + example: [{ absent: true, email: "someone@gmail.com" }], + }) + @ValidateNested() + @Type(() => Attendee) + @IsArray() + @IsOptional() + attendees?: Attendee[]; +} diff --git a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts new file mode 100644 index 00000000000000..998d7b3950fb78 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsDateString, IsOptional, IsString } from "class-validator"; + +export class RescheduleBookingInput_2024_08_13 { + @IsDateString() + @ApiProperty({ + description: "Start time in ISO 8601 format for the new booking", + example: "2024-08-13T10:00:00Z", + }) + start!: string; + + @IsString() + @IsOptional() + @ApiProperty({ + example: "User requested reschedule", + description: "Reason for rescheduling the booking", + required: false, + }) + reschedulingReason?: string; +} diff --git a/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts new file mode 100644 index 00000000000000..46f2e1a8c01604 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts @@ -0,0 +1,232 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsOptional, + IsString, + IsTimeZone, + IsUrl, + ValidateNested, +} from "class-validator"; + +import type { BookingLanguageType } from "../inputs/language"; +import { BookingLanguage } from "../inputs/language"; + +class Attendee { + @ApiProperty({ type: String, example: "John Doe" }) + @IsString() + @Expose() + name!: string; + + @ApiProperty({ type: String, example: "America/New_York" }) + @IsTimeZone() + @Expose() + timeZone!: string; + + @ApiProperty({ enum: BookingLanguage, required: false, example: "en" }) + @IsEnum(BookingLanguage) + @Expose() + @IsOptional() + language?: BookingLanguageType; + + @ApiProperty({ type: Boolean, example: false }) + @IsBoolean() + @Expose() + absent!: boolean; +} + +class Host { + @ApiProperty({ type: Number, example: 1 }) + @IsInt() + @Expose() + id!: number; + + @ApiProperty({ type: String, example: "Jane Doe" }) + @IsString() + @Expose() + name!: string; + + @ApiProperty({ type: String, example: "America/Los_Angeles" }) + @IsTimeZone() + @Expose() + timeZone!: string; +} + +export class BookingOutput_2024_08_13 { + @ApiProperty({ type: Number, example: 123 }) + @IsInt() + @Expose() + id!: number; + + @ApiProperty({ type: String, example: "booking_uid_123" }) + @IsString() + @Expose() + uid!: string; + + @ApiProperty({ type: [Host] }) + @ValidateNested({ each: true }) + @Type(() => Host) + @Expose() + hosts!: Host[]; + + @ApiProperty({ enum: ["cancelled", "accepted", "rejected", "pending", "rescheduled"], example: "accepted" }) + @IsEnum(["cancelled", "accepted", "rejected", "pending", "rescheduled"]) + @Expose() + status!: "cancelled" | "accepted" | "rejected" | "pending" | "rescheduled"; + + @ApiProperty({ type: String, required: false, example: "User requested cancellation" }) + @IsString() + @IsOptional() + @Expose() + cancellationReason?: string; + + @ApiProperty({ type: String, required: false, example: "User rescheduled the event" }) + @IsString() + @IsOptional() + @Expose() + reschedulingReason?: string; + + @ApiProperty({ type: String, required: false, example: "previous_uid_123" }) + @IsString() + @IsOptional() + @Expose() + rescheduledFromUid?: string; + + @ApiProperty({ type: String, example: "2024-08-13T15:30:00Z" }) + @IsDateString() + @Expose() + start!: string; + + @ApiProperty({ type: String, example: "2024-08-13T16:30:00Z" }) + @IsDateString() + @Expose() + end!: string; + + @ApiProperty({ type: Number, example: 60 }) + @IsInt() + @Expose() + duration!: number; + + @ApiProperty({ type: Number, example: 45 }) + @IsInt() + @Expose() + eventTypeId!: number; + + @ApiProperty({ type: [Attendee] }) + @ValidateNested({ each: true }) + @Type(() => Attendee) + @Expose() + attendees!: Attendee[]; + + @ApiProperty({ type: [String], required: false, example: ["guest1@example.com", "guest2@example.com"] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + @Expose() + guests?: string[]; + + @ApiProperty({ type: String, required: false, example: "https://example.com/meeting" }) + @IsUrl() + @IsOptional() + @Expose() + meetingUrl?: string; + + @ApiProperty({ type: Boolean, example: true }) + @IsBoolean() + @Expose() + absentHost!: boolean; +} + +export class RecurringBookingOutput_2024_08_13 { + @ApiProperty({ type: Number, example: 456 }) + @IsInt() + @Expose() + id!: number; + + @ApiProperty({ type: String, example: "recurring_uid_123" }) + @IsString() + @Expose() + uid!: string; + + @ApiProperty({ type: [Host] }) + @ValidateNested({ each: true }) + @Type(() => Host) + @Expose() + hosts!: Host[]; + + @ApiProperty({ enum: ["cancelled", "accepted", "rejected", "pending"], example: "pending" }) + @IsEnum(["cancelled", "accepted", "rejected", "pending"]) + @Expose() + status!: "cancelled" | "accepted" | "rejected" | "pending"; + + @ApiProperty({ type: String, required: false, example: "Event was cancelled" }) + @IsString() + @IsOptional() + @Expose() + cancellationReason?: string; + + @ApiProperty({ type: String, required: false, example: "Event was rescheduled" }) + @IsString() + @IsOptional() + @Expose() + reschedulingReason?: string; + + @ApiProperty({ type: String, required: false, example: "previous_recurring_uid_123" }) + @IsString() + @IsOptional() + @Expose() + rescheduledFromUid?: string; + + @ApiProperty({ type: String, example: "2024-08-13T15:30:00Z" }) + @IsDateString() + @Expose() + start!: string; + + @ApiProperty({ type: String, example: "2024-08-13T16:30:00Z" }) + @IsDateString() + @Expose() + end!: string; + + @ApiProperty({ type: Number, example: 30 }) + @IsInt() + @Expose() + duration!: number; + + @ApiProperty({ type: Number, example: 50 }) + @IsInt() + @Expose() + eventTypeId!: number; + + @ApiProperty({ type: String, example: "recurring_uid_987" }) + @IsString() + @Expose() + recurringBookingUid!: string; + + @ApiProperty({ type: [Attendee] }) + @ValidateNested({ each: true }) + @Type(() => Attendee) + @Expose() + attendees!: Attendee[]; + + @ApiProperty({ type: [String], required: false, example: ["guest3@example.com", "guest4@example.com"] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + @Expose() + guests?: string[]; + + @ApiProperty({ type: String, required: false, example: "https://example.com/recurring-meeting" }) + @IsUrl() + @IsOptional() + @Expose() + meetingUrl?: string; + + @ApiProperty({ type: Boolean, example: false }) + @IsBoolean() + @Expose() + absentHost!: boolean; +} diff --git a/packages/platform/types/bookings/2024-08-13/outputs/index.ts b/packages/platform/types/bookings/2024-08-13/outputs/index.ts new file mode 100644 index 00000000000000..6b714c906187a3 --- /dev/null +++ b/packages/platform/types/bookings/2024-08-13/outputs/index.ts @@ -0,0 +1 @@ +export * from "./booking.output"; diff --git a/packages/platform/types/bookings/index.ts b/packages/platform/types/bookings/index.ts new file mode 100644 index 00000000000000..1feb2c7f74edc0 --- /dev/null +++ b/packages/platform/types/bookings/index.ts @@ -0,0 +1,2 @@ +export * from "./2024-04-15"; +export * from "./2024-08-13"; diff --git a/packages/platform/types/package.json b/packages/platform/types/package.json index 80110c80ad4a2d..466c1501fe4d6f 100644 --- a/packages/platform/types/package.json +++ b/packages/platform/types/package.json @@ -4,7 +4,8 @@ "main": "./dist/index.js", "types": "./dist/index.js", "scripts": { - "build": "tsc --build --force tsconfig.json", + "build": "yarn clean && tsc --build --force tsconfig.json", + "clean": "rm -rf ./dist", "build:watch": "tsc --build --force ./tsconfig.json --watch", "post-install": "yarn build" }, diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 5b24993732c29e..a0cae887fc3521 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -23,7 +23,8 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { const take = input.limit ?? 10; const skip = input.cursor ?? 0; const { prisma, user } = ctx; - const bookingListingByStatus = input.filters.status; + const defaultStatus = "upcoming"; + const bookingListingByStatus = [input.filters.status || defaultStatus]; const { bookings, recurringInfo, nextCursor } = await getAllUserBookings({ ctx: { user: { id: user.id, email: user.email }, prisma: prisma }, @@ -68,9 +69,10 @@ export async function getBookings({ take: number; skip: number; }) { - // TODO: Fix record typing - const bookingWhereInputFilters: Record = { - teamIds: { + const bookingWhereInputFilters: Record = {}; + + if (filters?.teamIds && filters.teamIds.length > 0) { + bookingWhereInputFilters.teamIds = { AND: [ { OR: [ @@ -78,7 +80,7 @@ export async function getBookings({ eventType: { team: { id: { - in: filters?.teamIds, + in: filters.teamIds, }, }, }, @@ -88,7 +90,7 @@ export async function getBookings({ parent: { team: { id: { - in: filters?.teamIds, + in: filters.teamIds, }, }, }, @@ -97,8 +99,11 @@ export async function getBookings({ ], }, ], - }, - userIds: { + }; + } + + if (filters?.userIds && filters.userIds.length > 0) { + bookingWhereInputFilters.userIds = { AND: [ { OR: [ @@ -107,7 +112,7 @@ export async function getBookings({ hosts: { some: { userId: { - in: filters?.userIds, + in: filters.userIds, }, }, }, @@ -115,7 +120,7 @@ export async function getBookings({ }, { userId: { - in: filters?.userIds, + in: filters.userIds, }, }, { @@ -123,7 +128,7 @@ export async function getBookings({ users: { some: { id: { - in: filters?.userIds, + in: filters.userIds, }, }, }, @@ -132,21 +137,24 @@ export async function getBookings({ ], }, ], - }, - eventTypeIds: { + }; + } + + if (filters?.eventTypeIds && filters.eventTypeIds.length > 0) { + bookingWhereInputFilters.eventTypeIds = { AND: [ { OR: [ { eventTypeId: { - in: filters?.eventTypeIds, + in: filters.eventTypeIds, }, }, { eventType: { parent: { id: { - in: filters?.eventTypeIds, + in: filters.eventTypeIds, }, }, }, @@ -154,8 +162,44 @@ export async function getBookings({ ], }, ], - }, - }; + }; + } + + if (filters?.attendeeEmail) { + bookingWhereInputFilters.attendeeEmail = { + attendees: { + some: { + email: filters.attendeeEmail.trim(), + }, + }, + }; + } + + if (filters?.attendeeName) { + bookingWhereInputFilters.attendeeName = { + attendees: { + some: { + name: filters.attendeeName.trim(), + }, + }, + }; + } + + if (filters?.afterStartDate) { + bookingWhereInputFilters.afterStartDate = { + startTime: { + gte: new Date(filters.afterStartDate), + }, + }; + } + + if (filters?.beforeEndDate) { + bookingWhereInputFilters.beforeEndDate = { + endTime: { + lte: new Date(filters.beforeEndDate), + }, + }; + } const filtersCombined: Prisma.BookingWhereInput[] = !filters ? [] @@ -192,7 +236,6 @@ export async function getBookings({ }, status: true, paid: true, - payment: { select: { paymentOption: true, diff --git a/packages/trpc/server/routers/viewer/bookings/get.schema.ts b/packages/trpc/server/routers/viewer/bookings/get.schema.ts index c06284ac0282df..28e3472e83a6bf 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.schema.ts @@ -4,8 +4,12 @@ export const ZGetInputSchema = z.object({ filters: z.object({ teamIds: z.number().array().optional(), userIds: z.number().array().optional(), - status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]), + status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]).optional(), eventTypeIds: z.number().array().optional(), + attendeeEmail: z.string().optional(), + attendeeName: z.string().optional(), + afterStartDate: z.string().optional(), + beforeEndDate: z.string().optional(), }), limit: z.number().min(1).max(100).nullish(), cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type diff --git a/yarn.lock b/yarn.lock index ea454f9be3d297..126ea42ba0c410 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4134,7 +4134,7 @@ __metadata: dependencies: "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.36" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.37" "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" @@ -5121,14 +5121,14 @@ __metadata: languageName: node linkType: hard -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.36": - version: 0.0.36 - resolution: "@calcom/platform-libraries@npm:0.0.36" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.37": + version: 0.0.37 + resolution: "@calcom/platform-libraries@npm:0.0.37" dependencies: "@calcom/core": "*" "@calcom/features": "*" "@calcom/lib": "*" - checksum: 12147c9e10dda5c879d56d88ebc5a50a17e85b7ce683654af56c0eaafedb1167373bba3764c29c30e466ea7776bf96b7b191327703c125d22381b40802d09a02 + checksum: 02bd6c330d6eb80bc33977e9f6bd3b4f583bc014d26519272d7e7f76ad307a4810067933d0e98c8632bd689b2dd25c5b7a02b8e0509976534d772f2ae169674d languageName: node linkType: hard @@ -46665,14 +46665,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.6.1, tslib@npm:^2.6.2, tslib@npm:^2.6.3": - version: 2.7.0 - resolution: "tslib@npm:2.7.0" - checksum: 1606d5c89f88d466889def78653f3aab0f88692e80bb2066d090ca6112ae250ec1cfa9dbfaab0d17b60da15a4186e8ec4d893801c67896b277c17374e36e1d28 - languageName: node - linkType: hard - -"tslib@npm:~2.6.0": +"tslib@npm:^2.6.1, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:~2.6.0": version: 2.6.3 resolution: "tslib@npm:2.6.3" checksum: 74fce0e100f1ebd95b8995fbbd0e6c91bdd8f4c35c00d4da62e285a3363aaa534de40a80db30ecfd388ed7c313c42d930ee0eaf108e8114214b180eec3dbe6f5