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],