diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts index a64afcbc2b..9a3e0cf553 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/booking-attendees.controller.ts @@ -1,12 +1,33 @@ -import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + BOOKING_WRITE, + BOOKING_READ, + SUCCESS_STATUS, +} from "@calcom/platform-constants"; import { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types"; -import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, + UseGuards, + Get, + ParseIntPipe, +} from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard"; import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard"; import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output"; +import { + GetBookingAttendeesOutput_2024_08_13, + GetBookingAttendeeOutput_2024_08_13, +} from "@/ee/bookings/2024-08-13/outputs/get-booking-attendees.output"; import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service"; -import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions"; +import { + VERSION_2024_08_13, + VERSION_2024_08_13_VALUE, +} from "@/lib/api-versions"; import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; import { Throttle } from "@/lib/endpoint-throttler-decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -28,7 +49,60 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st required: true, }) export class BookingAttendeesController_2024_08_13 { - constructor(private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13) {} + constructor( + private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13 + ) {} + + @Get("/") + @Permissions([BOOKING_READ]) + @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get all attendees for a booking", + description: `Retrieve all attendees for a specific booking by its UID. + + The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error. + `, + }) + async getBookingAttendees( + @Param("bookingUid") bookingUid: string, + @GetUser() user: ApiAuthGuardUser + ): Promise { + const attendees = await this.bookingAttendeesService.getBookingAttendees( + bookingUid + ); + + return { + status: SUCCESS_STATUS, + data: attendees, + }; + } + + @Get("/:attendeeId") + @Permissions([BOOKING_READ]) + @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard) + @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) + @ApiOperation({ + summary: "Get a specific attendee for a booking", + description: `Retrieve a specific attendee by their ID for a booking identified by its UID. + + The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error. + `, + }) + async getBookingAttendee( + @Param("bookingUid") bookingUid: string, + @Param("attendeeId", ParseIntPipe) attendeeId: number + ): Promise { + const attendee = await this.bookingAttendeesService.getBookingAttendee( + bookingUid, + attendeeId + ); + + return { + status: SUCCESS_STATUS, + data: attendee, + }; + } @Post("/") @HttpCode(HttpStatus.CREATED) @@ -61,7 +135,11 @@ export class BookingAttendeesController_2024_08_13 { @Body() body: AddAttendeeInput_2024_08_13, @GetUser() user: ApiAuthGuardUser ): Promise { - const attendee = await this.bookingAttendeesService.addAttendee(bookingUid, body, user); + const attendee = await this.bookingAttendeesService.addAttendee( + bookingUid, + body, + user + ); return { status: SUCCESS_STATUS, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/get-attendees.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/get-attendees.e2e-spec.ts new file mode 100644 index 0000000000..a812a6dabc --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/get-attendees.e2e-spec.ts @@ -0,0 +1,386 @@ +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types"; +import type { Team, User } from "@calcom/prisma/client"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import 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 { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { mockThrottlerGuard } from "test/utils/withNoThrottler"; +import { AppModule } from "@/app.module"; +import { bootstrap } from "@/bootstrap"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { + GetBookingAttendeeOutput_2024_08_13, + GetBookingAttendeesOutput_2024_08_13, +} from "@/ee/bookings/2024-08-13/outputs/get-booking-attendees.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"; + +type TestUser = { + user: User; + accessToken: string; + refreshToken: string; +}; + +type TestSetup = { + organizer: TestUser; + unrelatedUser: TestUser; + eventTypeId: number; + bookingUid: string; + attendeeId: number; +}; + +describe("Bookings Endpoints 2024-08-13 get attendees", () => { + 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 teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + + let testSetup: TestSetup; + + beforeAll(async () => { + mockThrottlerGuard(); + + const moduleRef = await 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); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ + name: `get-attendees-2024-08-13-organization-${randomString()}`, + }); + + await setupTestData(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + async function setupTestData() { + const oAuthClient = await createOAuthClient(organization.id); + + const organizerUser = await userRepositoryFixture.create({ + email: `get-attendees-2024-08-13-organizer-${randomString()}@api.com`, + platformOAuthClients: { + connect: { id: oAuthClient.id }, + }, + }); + + const unrelatedUserData = await userRepositoryFixture.create({ + email: `get-attendees-2024-08-13-unrelated-${randomString()}@api.com`, + platformOAuthClients: { + connect: { id: oAuthClient.id }, + }, + }); + + const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id); + const unrelatedUserTokens = await tokensRepositoryFixture.createTokens( + unrelatedUserData.id, + oAuthClient.id + ); + + const schedule: CreateScheduleInput_2024_04_15 = { + name: `get-attendees-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(organizerUser.id, schedule); + + const eventType = await eventTypesRepositoryFixture.create( + { + title: `get-attendees-2024-08-13-event-type-${randomString()}`, + slug: `get-attendees-2024-08-13-event-type-${randomString()}`, + length: 60, + }, + organizerUser.id + ); + + testSetup = { + organizer: { + user: organizerUser, + accessToken: organizerTokens.accessToken, + refreshToken: organizerTokens.refreshToken, + }, + unrelatedUser: { + user: unrelatedUserData, + accessToken: unrelatedUserTokens.accessToken, + refreshToken: unrelatedUserTokens.refreshToken, + }, + eventTypeId: eventType.id, + bookingUid: "", + attendeeId: 0, + }; + } + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + areEmailsEnabled: true, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("GET /v2/bookings/:bookingUid/attendees", () => { + const attendeeEmail = "attendee@example.com"; + const attendeeName = "Test Attendee"; + const attendeeTimeZone = "Europe/Rome"; + + beforeAll(async () => { + const createBookingBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: testSetup.eventTypeId, + attendee: { + name: attendeeName, + email: attendeeEmail, + timeZone: attendeeTimeZone, + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + }; + + const createBookingResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBookingBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body; + expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS); + + if (!responseDataIsBooking(createBookingResponseBody.data)) { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + + testSetup.bookingUid = createBookingResponseBody.data.uid; + }); + + describe("Authentication", () => { + it("should return 401 when getting attendees without authentication", async () => { + const getAttendeesResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(getAttendeesResponse.status).toBe(401); + }); + }); + + describe("Authorization - Organizer", () => { + it("should allow booking organizer to get attendees", async () => { + const getAttendeesResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const getAttendeesResponseBody: GetBookingAttendeesOutput_2024_08_13 = getAttendeesResponse.body; + + expect(getAttendeesResponseBody.status).toEqual(SUCCESS_STATUS); + expect(getAttendeesResponseBody.data).toBeDefined(); + expect(Array.isArray(getAttendeesResponseBody.data)).toBe(true); + expect(getAttendeesResponseBody.data.length).toBe(1); + + const attendee = getAttendeesResponseBody.data[0]; + expect(attendee.name).toEqual(attendeeName); + expect(attendee.email).toEqual(attendeeEmail); + expect(attendee.timeZone).toEqual(attendeeTimeZone); + }); + + it("should return 403 when unrelated user tries to get attendees", async () => { + await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`) + .expect(403); + }); + }); + + describe("Response format", () => { + it("should return attendees with correct structure", async () => { + const getAttendeesResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const getAttendeesResponseBody: GetBookingAttendeesOutput_2024_08_13 = getAttendeesResponse.body; + + expect(getAttendeesResponseBody.status).toEqual(SUCCESS_STATUS); + expect(getAttendeesResponseBody.data).toBeDefined(); + + const attendee = getAttendeesResponseBody.data[0]; + + expect(typeof attendee.id).toBe("number"); + expect(typeof attendee.name).toBe("string"); + expect(typeof attendee.email).toBe("string"); + expect(typeof attendee.timeZone).toBe("string"); + expect(attendee).toHaveProperty("displayEmail"); + expect(attendee).toHaveProperty("language"); + expect(attendee).toHaveProperty("absent"); + }); + }); + }); + + describe("GET /v2/bookings/:bookingUid/attendees/:attendeeId", () => { + const attendeeEmail = "attendee@example.com"; + const attendeeName = "Test Attendee"; + const attendeeTimeZone = "Europe/Rome"; + + beforeAll(async () => { + const getAttendeesResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const getAttendeesResponseBody: GetBookingAttendeesOutput_2024_08_13 = getAttendeesResponse.body; + if (getAttendeesResponseBody.data?.length) { + testSetup.attendeeId = getAttendeesResponseBody.data[0].id; + } + }); + + describe("Authentication", () => { + it("should return 401 when getting single attendee without authentication", async () => { + const getAttendeeResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.attendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(getAttendeeResponse.status).toBe(401); + }); + }); + + describe("Authorization", () => { + it("should allow booking organizer to get single attendee", async () => { + const getAttendeeResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.attendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const getAttendeeResponseBody: GetBookingAttendeeOutput_2024_08_13 = getAttendeeResponse.body; + + expect(getAttendeeResponseBody.status).toEqual(SUCCESS_STATUS); + expect(getAttendeeResponseBody.data).toBeDefined(); + expect(getAttendeeResponseBody.data.name).toEqual(attendeeName); + expect(getAttendeeResponseBody.data.email).toEqual(attendeeEmail); + expect(getAttendeeResponseBody.data.timeZone).toEqual(attendeeTimeZone); + }); + + it("should return 403 when unrelated user tries to get single attendee", async () => { + await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.attendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`) + .expect(403); + }); + }); + + describe("Not found cases", () => { + it("should return 404 for non-existent attendee ID", async () => { + const nonExistentAttendeeId = 999999999; + await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees/${nonExistentAttendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(404); + }); + + it("should return 403 for non-existent booking UID because BookingPbacGuard treats missing bookings as unauthorized", async () => { + await request(app.getHttpServer()) + .get(`/v2/bookings/non-existent-booking-uid/attendees/${testSetup.attendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(403); + }); + }); + + describe("Response format", () => { + it("should return single attendee with correct structure", async () => { + const getAttendeeResponse = await request(app.getHttpServer()) + .get(`/v2/bookings/${testSetup.bookingUid}/attendees/${testSetup.attendeeId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${testSetup.organizer.accessToken}`) + .expect(200); + + const getAttendeeResponseBody: GetBookingAttendeeOutput_2024_08_13 = getAttendeeResponse.body; + + expect(getAttendeeResponseBody.status).toEqual(SUCCESS_STATUS); + expect(getAttendeeResponseBody.data).toBeDefined(); + + expect(Array.isArray(getAttendeeResponseBody.data)).toBe(false); + + const attendee = getAttendeeResponseBody.data; + + expect(typeof attendee.id).toBe("number"); + expect(typeof attendee.name).toBe("string"); + expect(typeof attendee.email).toBe("string"); + expect(typeof attendee.timeZone).toBe("string"); + expect(attendee).toHaveProperty("displayEmail"); + expect(attendee).toHaveProperty("language"); + expect(attendee).toHaveProperty("absent"); + }); + }); + }); + + afterAll(async () => { + await teamRepositoryFixture.delete(organization.id); + + await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email); + await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email); + + await bookingsRepositoryFixture.deleteAllBookings( + testSetup.organizer.user.id, + testSetup.organizer.user.email + ); + + await app.close(); + }); + + function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data; + } +}); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-pbac.guard.ts b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-pbac.guard.ts index a24a9207e1..c0699dc3e8 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-pbac.guard.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-pbac.guard.ts @@ -17,13 +17,17 @@ export class BookingPbacGuard implements CanActivate { private bookingAccessService: BookingAccessService; constructor(private readonly prismaReadService: PrismaReadService) { - this.bookingAccessService = new BookingAccessService(this.prismaReadService.prisma); + this.bookingAccessService = new BookingAccessService( + this.prismaReadService.prisma + ); } async canActivate(context: ExecutionContext): Promise { const request = context .switchToHttp() - .getRequest(); + .getRequest< + Request & { user?: ApiAuthGuardUser; pbacAuthorizedRequest?: boolean } + >(); const user = request.user; const bookingUid = request.params.bookingUid; @@ -32,13 +36,16 @@ export class BookingPbacGuard implements CanActivate { } if (!bookingUid) { - throw new BadRequestException("BookingPbacGuard - bookingUid is required"); + throw new BadRequestException( + "BookingPbacGuard - bookingUid is required" + ); } - const hasAccess = await this.bookingAccessService.doesUserIdHaveAccessToBooking({ - userId: user.id, - bookingUid, - }); + const hasAccess = + await this.bookingAccessService.doesUserIdHaveAccessToBooking({ + userId: user.id, + bookingUid, + }); if (!hasAccess) { throw new ForbiddenException( diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-booking-attendees.output.ts b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-booking-attendees.output.ts new file mode 100644 index 0000000000..95721e7712 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/outputs/get-booking-attendees.output.ts @@ -0,0 +1,41 @@ +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { BookingAttendee } from "@calcom/platform-types"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsArray, IsEnum, IsNumber, ValidateNested } from "class-validator"; + +export class BookingAttendeeWithId_2024_08_13 extends BookingAttendee { + @ApiProperty({ type: Number, example: 251 }) + @IsNumber() + @Expose() + id!: number; +} + +export class GetBookingAttendeesOutput_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: [BookingAttendeeWithId_2024_08_13] }) + @ValidateNested({ each: true }) + @IsArray() + @Type(() => BookingAttendeeWithId_2024_08_13) + data!: BookingAttendeeWithId_2024_08_13[]; +} + +export class GetBookingAttendeeOutput_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: BookingAttendeeWithId_2024_08_13 }) + @ValidateNested() + @Type(() => BookingAttendeeWithId_2024_08_13) + data!: BookingAttendeeWithId_2024_08_13; +} diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts index cf3bde4b06..88b6f5f3a2 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts @@ -6,7 +6,10 @@ import type { Prisma } from "@calcom/prisma/client"; @Injectable() export class BookingsRepository_2024_08_13 { - constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService + ) {} async getById(id: number) { return this.dbRead.prisma.booking.findUnique({ @@ -72,6 +75,25 @@ export class BookingsRepository_2024_08_13 { }); } + async getByUidWithAttendees(uid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid, + }, + select: { + id: true, + attendees: { + select: { + id: true, + name: true, + email: true, + timeZone: true, + }, + }, + }, + }); + } + async getByUidWithEventType(bookingUid: string) { return this.dbRead.prisma.booking.findUnique({ where: { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts index 88e22bd2b1..e6cede6327 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-attendees.service.ts @@ -1,8 +1,12 @@ import { BookingAttendeesService } from "@calcom/platform-libraries/bookings"; +import { ErrorCode, ErrorWithCode } from "@calcom/platform-libraries/errors"; import type { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types"; -import { HttpException, Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; import { plainToClass } from "class-transformer"; import { BookingAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output"; +import { + BookingAttendeeWithId_2024_08_13, +} from "@/ee/bookings/2024-08-13/outputs/get-booking-attendees.output"; import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository"; import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; @@ -18,21 +22,84 @@ export class BookingAttendeesService_2024_08_13 { this.bookingAttendeesService = new BookingAttendeesService(); } + async getBookingAttendees( + bookingUid: string + ): Promise { + const attendees = await this.bookingAttendeesService.getBookingAttendees( + bookingUid + ); + + return attendees.map((attendee) => + plainToClass( + BookingAttendeeWithId_2024_08_13, + { + id: attendee.id, + name: attendee.name, + email: attendee.email, + displayEmail: this.getDisplayEmail(attendee.email), + timeZone: attendee.timeZone, + language: attendee.locale ?? undefined, + absent: attendee.noShow ?? false, + phoneNumber: attendee.phoneNumber ?? undefined, + }, + { strategy: "excludeAll" } + ) + ); + } + + async getBookingAttendee( + bookingUid: string, + attendeeId: number + ): Promise { + try { + const attendee = await this.bookingAttendeesService.getBookingAttendee( + bookingUid, + attendeeId + ); + + return plainToClass( + BookingAttendeeWithId_2024_08_13, + { + id: attendee.id, + name: attendee.name, + email: attendee.email, + displayEmail: this.getDisplayEmail(attendee.email), + timeZone: attendee.timeZone, + language: attendee.locale ?? undefined, + absent: attendee.noShow ?? false, + phoneNumber: attendee.phoneNumber ?? undefined, + }, + { strategy: "excludeAll" } + ); + } catch (e) { + if (e instanceof ErrorWithCode && e.code === ErrorCode.NotFound) { + throw new NotFoundException(e.message); + } + throw e; + } + } + async addAttendee( bookingUid: string, input: AddAttendeeInput_2024_08_13, user: ApiAuthGuardUser ): Promise { - const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid); + const booking = await this.bookingsRepository.getByUidWithEventType( + bookingUid + ); if (!booking) { throw new NotFoundException(`Booking with uid ${bookingUid} not found`); } const platformClientParams = booking.eventTypeId - ? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId) + ? await this.platformBookingsService.getOAuthClientParams( + booking.eventTypeId + ) : undefined; - const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true; + const emailsEnabled = platformClientParams + ? platformClientParams.arePlatformEmailsEnabled + : true; const createdAttendee = await this.bookingAttendeesService.addAttendee({ bookingId: booking.id, 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 e55479db25..e37bf8acf7 100644 --- a/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/bookings.repository.fixture.ts @@ -1,8 +1,7 @@ +import type { Booking, Prisma, User } from "@calcom/prisma/client"; +import { TestingModule } from "@nestjs/testing"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; -import { TestingModule } from "@nestjs/testing"; - -import type { Booking, User, Prisma } from "@calcom/prisma/client"; export class BookingsRepositoryFixture { private prismaReadClient: PrismaReadService["prisma"]; @@ -21,6 +20,13 @@ export class BookingsRepositoryFixture { return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } }); } + async getByUidWithAttendees(bookingUid: Booking["uid"]) { + return this.prismaReadClient.booking.findUnique({ + where: { uid: bookingUid }, + include: { attendees: true }, + }); + } + async getByRecurringBookingUid(recurringBookingUid: string) { return this.prismaReadClient.booking.findMany({ where: { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 3c892a80f1..939b173174 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -10235,6 +10235,52 @@ } }, "/v2/bookings/{bookingUid}/attendees": { + "get": { + "operationId": "BookingAttendeesController_2024_08_13_getBookingAttendees", + "summary": "Get all attendees for a booking", + "description": "Retrieve all attendees for a specific booking by its UID.\n \n The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.\n ", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to 2024-08-13. This header is required as this endpoint does not exist in older API versions.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingAttendeesOutput_2024_08_13" + } + } + } + } + }, + "tags": ["Bookings / Attendees"] + }, "post": { "operationId": "BookingAttendeesController_2024_08_13_addAttendee", "summary": "Add an attendee to a booking", @@ -10292,6 +10338,62 @@ "tags": ["Bookings / Attendees"] } }, + "/v2/bookings/{bookingUid}/attendees/{attendeeId}": { + "get": { + "operationId": "BookingAttendeesController_2024_08_13_getBookingAttendee", + "summary": "Get a specific attendee for a booking", + "description": "Retrieve a specific attendee by their ID for a booking identified by its UID.\n \n The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.\n ", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to 2024-08-13. This header is required as this endpoint does not exist in older API versions.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bookingUid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "attendeeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingAttendeeOutput_2024_08_13" + } + } + } + } + }, + "tags": ["Bookings / Attendees"] + } + }, "/v2/bookings/{bookingUid}/guests": { "post": { "operationId": "BookingGuestsController_2024_08_13_addGuests", @@ -31580,6 +31682,121 @@ }, "required": ["status", "data"] }, + "BookingAttendeeWithId_2024_08_13": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "John Doe" + }, + "email": { + "type": "string", + "example": "john@example.com" + }, + "displayEmail": { + "type": "string", + "example": "john@example.com", + "description": "Clean email for display purposes" + }, + "timeZone": { + "type": "string", + "example": "America/New_York" + }, + "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", + "bn" + ], + "example": "en" + }, + "absent": { + "type": "boolean", + "example": false + }, + "phoneNumber": { + "type": "string", + "example": "+1234567890" + }, + "id": { + "type": "number", + "example": 251 + } + }, + "required": ["name", "email", "displayEmail", "timeZone", "absent", "id"] + }, + "GetBookingAttendeesOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookingAttendeeWithId_2024_08_13" + } + } + }, + "required": ["status", "data"] + }, + "GetBookingAttendeeOutput_2024_08_13": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/BookingAttendeeWithId_2024_08_13" + } + }, + "required": ["status", "data"] + }, "AddAttendeeInput_2024_08_13": { "type": "object", "properties": { diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index a373b99a83..311ecd1ed5 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -493,11 +493,13 @@ export class BookingRepository implements IBookingRepository { }, attendees: { select: { + id: true, email: true, name: true, timeZone: true, locale: true, phoneNumber: true, + noShow: true, }, }, user: { diff --git a/packages/features/bookings/services/BookingAttendeesService.ts b/packages/features/bookings/services/BookingAttendeesService.ts index 271148c383..e00b9266ae 100644 --- a/packages/features/bookings/services/BookingAttendeesService.ts +++ b/packages/features/bookings/services/BookingAttendeesService.ts @@ -3,9 +3,16 @@ import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import type { Booking, TUser } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; +import { prisma } from "@calcom/prisma"; +import type { + Booking, + TUser, +} from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; import { buildCalendarEvent, getBooking, @@ -41,6 +48,42 @@ export type CreatedAttendee = { }; export class BookingAttendeesService { + async getBookingAttendees(bookingUid: string) { + const bookingRepository = new BookingRepository(prisma); + const booking = + await bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ + bookingUid, + }); + + if (!booking) { + throw new Error(`Booking with uid ${bookingUid} not found`); + } + + return booking.attendees; + } + + async getBookingAttendee(bookingUid: string, attendeeId: number) { + const bookingRepository = new BookingRepository(prisma); + const booking = + await bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({ + bookingUid, + }); + + if (!booking) { + throw new Error(`Booking with uid ${bookingUid} not found`); + } + + const attendee = booking.attendees.find((a) => a.id === attendeeId); + if (!attendee) { + throw new ErrorWithCode( + ErrorCode.NotFound, + `Attendee with id ${attendeeId} not found in booking ${bookingUid}` + ); + } + + return attendee; + } + async addAttendee({ bookingId, attendee, @@ -56,7 +99,10 @@ export class BookingAttendeesService { const organizer = await getOrganizerData(booking.userId); - const validatedAttendees = await sanitizeAndFilterGuests([attendee], booking); + const validatedAttendees = await sanitizeAndFilterGuests( + [attendee], + booking + ); const newAttendeeDetails = validatedAttendees.map((a) => ({ name: a.name || "", @@ -89,7 +135,10 @@ export class BookingAttendeesService { const featuresRepository = getFeaturesRepository(); const organizationId = user.organizationId ?? null; const isBookingAuditEnabled = organizationId - ? await featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit") + ? await featuresRepository.checkIfTeamHasFeature( + organizationId, + "booking-audit" + ) : false; await bookingEventHandlerService.onAttendeeAdded({ @@ -134,7 +183,9 @@ export class BookingAttendeesService { await emailsAndSmsHandler.handleAddAttendee({ evt, eventType: { - metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata), + metadata: eventTypeMetaDataSchemaWithTypedApps.parse( + booking?.eventType?.metadata + ), schedulingType: booking.eventType?.schedulingType || null, }, newGuests: [attendeeEmail], 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 index 2d66174429..ac9e81705c 100644 --- a/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts +++ b/packages/platform/types/bookings/2024-08-13/outputs/booking.output.ts @@ -291,11 +291,11 @@ class BaseBookingOutput_2024_08_13 { } export class BookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 { - @ApiProperty({ type: [BookingAttendee] }) - @ValidateNested({ each: true }) - @Type(() => BookingAttendee) - @Expose() - attendees!: BookingAttendee[]; + @ApiProperty({ type: [BookingAttendee] }) + @ValidateNested({ each: true }) + @Type(() => BookingAttendee) + @Expose() + attendees!: BookingAttendee[]; @ApiPropertyOptional({ type: [String],