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 index cabca2c978..d1bf1082ef 100644 --- 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 @@ -1018,10 +1018,6 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); - function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { - return !Array.isArray(data) && typeof data === "object" && data && "id" in data; - } - function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 { return ( !Array.isArray(data) && @@ -1032,10 +1028,303 @@ describe("Bookings Endpoints 2024-08-13", () => { ); } - 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(); + }); + }); + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] { + return Array.isArray(data); + } + + describe("Recurring 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; + + const maxRecurrenceCount = 3; + let recurringEventTypeId: 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); + 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 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: maxRecurrenceCount, interval: 1 }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + + 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 not create recurring booking with recurrenceCount larger than event type recurrence count", async () => { + const recurrenceCount = 1000; + + 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", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount - 1; + 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", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + 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(maxRecurrenceCount - 1); + + 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.location).toEqual(body.location); + 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.location).toEqual(body.location); + expect(secondBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => { + const recurrenceCount = maxRecurrenceCount; + 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", + }, + location: "https://meet.google.com/abc-def-ghi", + recurrenceCount, + }; + + 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(maxRecurrenceCount); + + 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.location).toEqual(body.location); + expect(firstBooking.meetingUrl).toEqual(body.location); + 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.location).toEqual(body.location); + 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.location).toEqual(body.location); + expect(thirdBooking.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected recurring booking but received non array response" + ); + } + }); + }); + + afterEach(async () => { + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + }); + afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); 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 index 1190a638c3..2504b5fd43 100644 --- 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 @@ -5,7 +5,7 @@ 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 { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Request } from "express"; @@ -57,9 +57,9 @@ export enum Frequency { const recurringEventSchema = z.object({ dtstart: z.string().optional(), - interval: z.number().int().optional(), - count: z.number().int().optional(), - freq: z.nativeEnum(Frequency).optional(), + interval: z.number().int(), + count: z.number().int(), + freq: z.nativeEnum(Frequency), until: z.string().optional(), }); @@ -175,14 +175,16 @@ export class InputBookingsService_2024_08_13 { const occurrence = recurringEventSchema.parse(eventType.recurringEvent); const repeatsEvery = occurrence.interval; - const repeatsTimes = occurrence.count; + + if (inputBooking.recurrenceCount && inputBooking.recurrenceCount > occurrence.count) { + throw new BadRequestException( + "Provided recurrence count is higher than the event type's recurring event count." + ); + } + const repeatsTimes = inputBooking.recurrenceCount || 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(); 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 index 891e53b1bb..e524531eab 100644 --- 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 @@ -102,10 +102,10 @@ export class CreateBookingInputPipe implements PipeTransform { private isRecurringBookingInput( value: CreateBookingInput ): value is CreateRecurringBookingInput_2024_08_13 { - return value.hasOwnProperty("recurringEventTypeId"); + return value.hasOwnProperty("eventTypeId"); } - private isInstantBookingInput(value: CreateBookingInput): value is CreateRecurringBookingInput_2024_08_13 { + private isInstantBookingInput(value: CreateBookingInput): value is CreateInstantBookingInput_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 index 0eb4ef5a4f..88a91f24bf 100644 --- 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 @@ -13,6 +13,7 @@ import { IsUrl, IsObject, IsBoolean, + Min, } from "class-validator"; import type { BookingLanguageType } from "./language"; @@ -180,6 +181,18 @@ export class CreateRecurringBookingInput_2024_08_13 { @IsOptional() guests?: string[]; + @ApiProperty({ + type: String, + description: + "Deprecated - use 'location' instead. 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, + deprecated: true, + }) + @IsUrl() + @IsOptional() + meetingUrl?: string; + @ApiProperty({ type: String, description: "Location for this booking. Displayed in email and calendar event.", @@ -210,4 +223,16 @@ export class CreateRecurringBookingInput_2024_08_13 { @IsObject() @IsOptional() bookingFieldsResponses?: Record; + + @ApiPropertyOptional({ + type: Number, + description: `The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than + event type recurrence count`, + example: 5, + required: false, + }) + @IsOptional() + @IsInt() + @Min(1) + recurrenceCount?: number; }