diff --git a/.github/oasdiff-err-ignore.txt b/.github/oasdiff-err-ignore.txt
index e3e210756f..a0388c01e3 100644
--- a/.github/oasdiff-err-ignore.txt
+++ b/.github/oasdiff-err-ignore.txt
@@ -1,2 +1,4 @@
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
+GET /v2/bookings/{bookingUid}/recordings added the new required 'header' request parameter 'Authorization'
+GET /v2/bookings/{bookingUid}/transcripts added the new required 'header' request parameter 'Authorization'
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts
index 15ea4dd09e..d47a73c333 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts
@@ -1,10 +1,12 @@
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
+import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
+import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
@@ -96,6 +98,8 @@ import { Module } from "@nestjs/common";
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
CalVideoService,
+ CalVideoOutputService,
+ BookingPbacGuard,
],
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts
index 23a5f73c2b..166cf8445b 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts
@@ -1,3 +1,4 @@
+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 { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
@@ -23,6 +24,7 @@ import {
GetOptionalUser,
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
+import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
@@ -66,6 +68,7 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingTranscriptsOutput,
+ GetBookingVideoSessionsOutput,
} from "@calcom/platform-types";
import {
CreateBookingInputPipe,
@@ -209,12 +212,16 @@ export class BookingsController_2024_08_13 {
}
@Get("/:bookingUid/recordings")
+ // @Pbac(["booking.readRecordings"])
+ @Permissions([BOOKING_READ])
@UseGuards(BookingUidGuard)
+ // @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
+ @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get all the recordings for the booking",
- description: `Fetches all the recordings for the booking \`:bookingUid\`
+ description: `Fetches all the recordings for the booking \`:bookingUid\`. Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
- Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.
+ cal-api-version: \`2024-08-13\` is required in the request header.
`,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise {
@@ -223,11 +230,17 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: recordings,
+ message:
+ "This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
};
}
@Get("/:bookingUid/transcripts")
+ // @Pbac(["booking.readRecordings"])
+ @Permissions([BOOKING_READ])
@UseGuards(BookingUidGuard)
+ // @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
+ @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get Cal Video real time transcript download links for the booking",
description: `Fetches all the transcript download links for the booking \`:bookingUid\`
@@ -245,6 +258,8 @@ export class BookingsController_2024_08_13 {
return {
status: SUCCESS_STATUS,
data: transcripts ?? [],
+ message:
+ "This endpoint will require authentication in a future release. Please update your integration to include valid credentials. See https://cal.com/docs/api-reference/v2/introduction#authentication for details.",
};
}
@@ -546,4 +561,25 @@ export class BookingsController_2024_08_13 {
data: bookingReferences,
};
}
+
+ @Get("/:bookingUid/conferencing-sessions")
+ @HttpCode(HttpStatus.OK)
+ @Pbac(["booking.readRecordings"])
+ @Permissions([BOOKING_READ])
+ @UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
+ @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
+ @ApiOperation({
+ summary: "Get Video Meeting Sessions. Only supported for Cal Video",
+ description: `Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.
+
+ cal-api-version: \`2024-08-13\` is required in the request header.`,
+ })
+ async getVideoSessions(@Param("bookingUid") bookingUid: string): Promise {
+ const sessions = await this.calVideoService.getVideoSessions(bookingUid);
+
+ return {
+ status: SUCCESS_STATUS,
+ data: sessions,
+ };
+ }
}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-access-auth.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-access-auth.e2e-spec.ts
new file mode 100644
index 0000000000..c14d06b1e5
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-access-auth.e2e-spec.ts
@@ -0,0 +1,245 @@
+import { bootstrap } from "@/app";
+import { AppModule } from "@/app.module";
+import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
+import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
+import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
+import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
+import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
+import { PrismaModule } from "@/modules/prisma/prisma.module";
+import { UsersModule } from "@/modules/users/users.module";
+import { INestApplication } from "@nestjs/common";
+import { NestExpressApplication } from "@nestjs/platform-express";
+import { Test } from "@nestjs/testing";
+import * as request from "supertest";
+import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
+import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
+import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
+import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
+import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
+import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
+import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
+import { randomString } from "test/utils/randomString";
+
+import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
+import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
+
+describe("Bookings Endpoints 2024-08-13", () => {
+ describe("Booking access authorization", () => {
+ let app: INestApplication;
+ let organization: Team;
+
+ let userRepositoryFixture: UserRepositoryFixture;
+ let bookingsRepositoryFixture: BookingsRepositoryFixture;
+ let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
+ let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
+ let organizationsRepositoryFixture: OrganizationRepositoryFixture;
+ let teamRepositoryFixture: TeamRepositoryFixture;
+ let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
+ let schedulesService: SchedulesService_2024_04_15;
+ let oAuthClient: PlatformOAuthClient;
+
+ const ownerEmail = `booking-access-auth-owner-${randomString()}@api.com`;
+ const unauthorizedEmail = `booking-access-auth-unauthorized-${randomString()}@api.com`;
+ let ownerUser: User;
+ let unauthorizedUser: User;
+ let ownerApiKey: string;
+ let unauthorizedApiKey: string;
+
+ let eventTypeId: number;
+ let testBooking: Booking;
+
+ beforeAll(async () => {
+ 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);
+ organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
+ teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
+ apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
+ schedulesService = moduleRef.get(SchedulesService_2024_04_15);
+
+ organization = await organizationsRepositoryFixture.create({
+ name: `booking-access-auth-organization-${randomString()}`,
+ });
+ oAuthClient = await createOAuthClient(organization.id);
+
+ ownerUser = await userRepositoryFixture.create({
+ email: ownerEmail,
+ locale: "en",
+ name: `booking-access-auth-owner-${randomString()}`,
+ });
+
+ unauthorizedUser = await userRepositoryFixture.create({
+ email: unauthorizedEmail,
+ locale: "en",
+ name: `booking-access-auth-unauthorized-${randomString()}`,
+ });
+
+ const { keyString: ownerKeyString } = await apiKeysRepositoryFixture.createApiKey(ownerUser.id, null);
+ ownerApiKey = `cal_test_${ownerKeyString}`;
+
+ const { keyString: unauthorizedKeyString } = await apiKeysRepositoryFixture.createApiKey(
+ unauthorizedUser.id,
+ null
+ );
+ unauthorizedApiKey = `cal_test_${unauthorizedKeyString}`;
+
+ const userSchedule: CreateScheduleInput_2024_04_15 = {
+ name: `booking-access-auth-schedule-${randomString()}`,
+ timeZone: "Europe/Rome",
+ isDefault: true,
+ };
+ await schedulesService.createUserSchedule(ownerUser.id, userSchedule);
+
+ const eventType = await eventTypesRepositoryFixture.create(
+ {
+ title: `booking-access-auth-event-type-${randomString()}`,
+ slug: `booking-access-auth-event-type-${randomString()}`,
+ length: 60,
+ },
+ ownerUser.id
+ );
+ eventTypeId = eventType.id;
+
+ testBooking = await bookingsRepositoryFixture.create({
+ uid: `booking-access-auth-booking-${randomString()}`,
+ title: "Test Booking for Access Auth",
+ description: "",
+ startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
+ endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
+ eventType: {
+ connect: {
+ id: eventTypeId,
+ },
+ },
+ user: {
+ connect: {
+ id: ownerUser.id,
+ },
+ },
+ metadata: {},
+ responses: {
+ name: "Test Attendee",
+ email: "attendee@example.com",
+ },
+ references: {
+ create: [
+ {
+ type: "daily_video",
+ uid: `daily-room-${randomString()}`,
+ meetingId: `daily-room-${randomString()}`,
+ meetingPassword: "test-password",
+ meetingUrl: `https://daily.co/test-room-${randomString()}`,
+ },
+ ],
+ },
+ });
+
+ 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;
+ }
+
+ describe("GET /v2/bookings/:bookingUid/conferencing-sessions - Authorization", () => {
+ it("should allow booking organizer to access conferencing sessions", async () => {
+ const calVideoService = app.get(CalVideoService);
+ jest.spyOn(calVideoService, "getVideoSessions").mockResolvedValue([]);
+
+ const response = await request(app.getHttpServer())
+ .get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
+ .set("Authorization", `Bearer ${ownerApiKey}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .expect(200);
+
+ expect(response.body.status).toEqual(SUCCESS_STATUS);
+ });
+
+ it("should return 403 when unauthorized user tries to access conferencing sessions", async () => {
+ await request(app.getHttpServer())
+ .get(`/v2/bookings/${testBooking.uid}/conferencing-sessions`)
+ .set("Authorization", `Bearer ${unauthorizedApiKey}`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .expect(403);
+ });
+ });
+
+ // describe("GET /v2/bookings/:bookingUid/recordings - Authorization", () => {
+ // it("should allow booking organizer to access recordings", async () => {
+ // const calVideoService = app.get(CalVideoService);
+ // jest.spyOn(calVideoService, "getRecordings").mockResolvedValue([]);
+
+ // const response = await request(app.getHttpServer())
+ // .get(`/v2/bookings/${testBooking.uid}/recordings`)
+ // .set("Authorization", `Bearer ${ownerApiKey}`)
+ // .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ // .expect(200);
+
+ // expect(response.body.status).toEqual(SUCCESS_STATUS);
+ // });
+
+ // it("should return 403 when unauthorized user tries to access recordings", async () => {
+ // await request(app.getHttpServer())
+ // .get(`/v2/bookings/${testBooking.uid}/recordings`)
+ // .set("Authorization", `Bearer ${unauthorizedApiKey}`)
+ // .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ // .expect(403);
+ // });
+ // });
+
+ // describe("GET /v2/bookings/:bookingUid/transcripts - Authorization", () => {
+ // it("should allow booking organizer to access transcripts", async () => {
+ // const calVideoService = app.get(CalVideoService);
+ // jest.spyOn(calVideoService, "getTranscripts").mockResolvedValue([]);
+
+ // const response = await request(app.getHttpServer())
+ // .get(`/v2/bookings/${testBooking.uid}/transcripts`)
+ // .set("Authorization", `Bearer ${ownerApiKey}`)
+ // .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ // .expect(200);
+
+ // expect(response.body.status).toEqual(SUCCESS_STATUS);
+ // });
+
+ // it("should return 403 when unauthorized user tries to access transcripts", async () => {
+ // await request(app.getHttpServer())
+ // .get(`/v2/bookings/${testBooking.uid}/transcripts`)
+ // .set("Authorization", `Bearer ${unauthorizedApiKey}`)
+ // .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ // .expect(403);
+ // });
+ // });
+
+ afterAll(async () => {
+ await bookingsRepositoryFixture.deleteById(testBooking.id);
+ await eventTypesRepositoryFixture.delete(eventTypeId);
+ await userRepositoryFixture.deleteByEmail(ownerEmail);
+ await userRepositoryFixture.deleteByEmail(unauthorizedEmail);
+ await oauthClientRepositoryFixture.delete(oAuthClient.id);
+ await teamRepositoryFixture.delete(organization.id);
+ await app.close();
+ });
+ });
+});
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts
index 4141b6997f..15d7fea3c3 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts
@@ -4,6 +4,7 @@ import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
+import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.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";
@@ -3078,6 +3079,102 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
}
+ describe("Meeting sessions", () => {
+ it("should get cal video sessions for a booking", async () => {
+ const mockSessions = [
+ {
+ id: "session-123",
+ room: "daily-room-123",
+ start_time: 1678901234,
+ duration: 3600,
+ ongoing: false,
+ max_participants: 10,
+ participants: [
+ {
+ user_id: "user-1",
+ participant_id: "participant-1",
+ user_name: "John Doe",
+ join_time: 1678901234,
+ duration: 3600,
+ },
+ ],
+ },
+ ];
+
+ const booking = await bookingsRepositoryFixture.create({
+ uid: `test-video-session-${randomString()}`,
+ title: "Test Video Session Booking",
+ description: "",
+ startTime: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)),
+ endTime: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)),
+ eventType: {
+ connect: {
+ id: eventTypeId,
+ },
+ },
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ references: {
+ create: [
+ {
+ type: "daily_video",
+ uid: `daily-room-123-${randomString()}`,
+ meetingId: "daily-room-123",
+ meetingPassword: "test-password",
+ meetingUrl: "https://daily.co/daily-room-123",
+ },
+ ],
+ },
+ });
+
+ const calVideoService = app.get(CalVideoService);
+ jest
+ .spyOn(calVideoService, "getVideoSessions")
+ .mockResolvedValue([
+ {
+ id: mockSessions[0].id,
+ room: mockSessions[0].room,
+ startTime: mockSessions[0].start_time,
+ duration: mockSessions[0].duration,
+ ongoing: mockSessions[0].ongoing,
+ maxParticipants: mockSessions[0].max_participants,
+ participants: mockSessions[0].participants.map((p) => ({
+ userId: p.user_id,
+ userName: p.user_name,
+ joinTime: p.join_time,
+ duration: p.duration,
+ })),
+ },
+ ]);
+
+ const response = await request(app.getHttpServer())
+ .get(`/v2/bookings/${booking.uid}/conferencing-sessions`)
+ .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
+ .expect(200);
+
+ expect(response.body.status).toEqual(SUCCESS_STATUS);
+ expect(response.body.data).toBeDefined();
+ expect(Array.isArray(response.body.data)).toBe(true);
+ expect(response.body.data.length).toBe(1);
+ expect(response.body.data[0].id).toEqual(mockSessions[0].id);
+ expect(response.body.data[0].room).toEqual(mockSessions[0].room);
+ expect(response.body.data[0].startTime).toEqual(mockSessions[0].start_time);
+ expect(response.body.data[0].duration).toEqual(mockSessions[0].duration);
+ expect(response.body.data[0].ongoing).toEqual(mockSessions[0].ongoing);
+ expect(response.body.data[0].maxParticipants).toEqual(mockSessions[0].max_participants);
+ expect(response.body.data[0].participants.length).toBe(1);
+ expect(response.body.data[0].participants[0].userId).toEqual(mockSessions[0].participants[0].user_id);
+ expect(response.body.data[0].participants[0].userName).toEqual(
+ mockSessions[0].participants[0].user_name
+ );
+
+ await bookingsRepositoryFixture.deleteById(booking.id);
+ });
+ });
+
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
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
new file mode 100644
index 0000000000..a24a9207e1
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/guards/booking-pbac.guard.ts
@@ -0,0 +1,52 @@
+import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
+import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
+import {
+ Injectable,
+ CanActivate,
+ ExecutionContext,
+ ForbiddenException,
+ UnauthorizedException,
+ BadRequestException,
+} from "@nestjs/common";
+import { Request } from "express";
+
+import { BookingAccessService } from "@calcom/platform-libraries";
+
+@Injectable()
+export class BookingPbacGuard implements CanActivate {
+ private bookingAccessService: BookingAccessService;
+
+ constructor(private readonly prismaReadService: PrismaReadService) {
+ this.bookingAccessService = new BookingAccessService(this.prismaReadService.prisma);
+ }
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context
+ .switchToHttp()
+ .getRequest();
+ const user = request.user;
+ const bookingUid = request.params.bookingUid;
+
+ if (!user) {
+ throw new UnauthorizedException();
+ }
+
+ if (!bookingUid) {
+ throw new BadRequestException("BookingPbacGuard - bookingUid is required");
+ }
+
+ const hasAccess = await this.bookingAccessService.doesUserIdHaveAccessToBooking({
+ userId: user.id,
+ bookingUid,
+ });
+
+ if (!hasAccess) {
+ throw new ForbiddenException(
+ `BookingPbacGuard - user with id=${user.id} does not have access to booking with uid=${bookingUid}`
+ );
+ }
+
+ request.pbacAuthorizedRequest = true;
+ return true;
+ }
+}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.output.service.ts
new file mode 100644
index 0000000000..f64a9ce016
--- /dev/null
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.output.service.ts
@@ -0,0 +1,23 @@
+import { Injectable } from "@nestjs/common";
+
+import type { CalMeetingSession } from "@calcom/platform-libraries/conferencing";
+
+@Injectable()
+export class CalVideoOutputService {
+ getOutputVideoSessions(sessions: CalMeetingSession[]) {
+ return sessions.map((session) => ({
+ id: session.id,
+ room: session.room,
+ startTime: session.start_time,
+ duration: session.duration,
+ ongoing: session.ongoing,
+ maxParticipants: session.max_participants,
+ participants: session.participants.map((participant) => ({
+ userId: participant.user_id,
+ userName: participant.user_name,
+ joinTime: participant.join_time,
+ duration: participant.duration,
+ })),
+ }));
+ }
+}
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts
index 69810c2a72..7441352e9b 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/cal-video.service.ts
@@ -1,11 +1,13 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
-import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
+import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
+import { CAL_VIDEO_TYPE } from "@calcom/platform-constants";
import {
getRecordingsOfCalVideoByRoomName,
getAllTranscriptsAccessLinkFromRoomName,
getDownloadLinkOfCalVideoByRecordingId,
+ getCalVideoMeetingSessionsByRoomName,
} from "@calcom/platform-libraries/conferencing";
@Injectable()
@@ -13,18 +15,23 @@ export class CalVideoService {
private readonly logger = new Logger("CalVideoService");
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
- private readonly outputService: OutputBookingsService_2024_08_13
+ private readonly calVideoOutputService: CalVideoOutputService
) {}
+ private getVideoSessionsRoomName(references?: Array<{ type: string; meetingId?: string | null }>) {
+ return (
+ references?.filter((reference) => reference.type === CAL_VIDEO_TYPE)?.pop()?.meetingId ??
+ undefined
+ );
+ }
+
async getRecordings(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
- const roomName =
- booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
- undefined;
+ const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
@@ -68,10 +75,7 @@ export class CalVideoService {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
- const roomName =
- booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
- undefined;
-
+ const roomName = this.getVideoSessionsRoomName(booking.references);
if (!roomName) {
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
}
@@ -80,4 +84,19 @@ export class CalVideoService {
return transcripts;
}
+
+ async getVideoSessions(bookingUid: string) {
+ const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
+ if (!booking) {
+ throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
+ }
+
+ const roomName = this.getVideoSessionsRoomName(booking.references);
+ if (!roomName) {
+ throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
+ }
+
+ const sessions = await getCalVideoMeetingSessionsByRoomName(roomName);
+ return this.calVideoOutputService.getOutputVideoSessions(sessions.data);
+ }
}
diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json
index 6eec205f65..51c48d64b3 100644
--- a/docs/api-reference/v2/openapi.json
+++ b/docs/api-reference/v2/openapi.json
@@ -9903,7 +9903,7 @@
"get": {
"operationId": "BookingsController_2024_08_13_getBookingRecordings",
"summary": "Get all the recordings for the booking",
- "description": "Fetches all the recordings for the booking `:bookingUid`\n\n Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.\n ",
+ "description": "Fetches all the recordings for the booking `:bookingUid`. Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.\n\n cal-api-version: `2024-08-13` is required in the request header.\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -9922,6 +9922,15 @@
"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": {
@@ -9962,6 +9971,15 @@
"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": {
@@ -10548,6 +10566,55 @@
"tags": ["Bookings"]
}
},
+ "/v2/bookings/{bookingUid}/conferencing-sessions": {
+ "get": {
+ "operationId": "BookingsController_2024_08_13_getVideoSessions",
+ "summary": "Get Video Meeting Sessions. Only supported for Cal Video",
+ "description": "Requires authentication and proper authorization. Access is granted if you are the booking organizer, team admin or org admin/owner.\n\n cal-api-version: `2024-08-13` is required in the request header.",
+ "parameters": [
+ {
+ "name": "cal-api-version",
+ "in": "header",
+ "description": "Must be set to 2024-08-13. If not set to this value, the endpoint will default to an older version.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "default": "2024-08-13"
+ }
+ },
+ {
+ "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/GetBookingVideoSessionsOutput"
+ }
+ }
+ }
+ }
+ },
+ "tags": ["Bookings"]
+ }
+ },
"/v2/bookings/{bookingUid}/guests": {
"post": {
"operationId": "BookingGuestsController_2024_08_13_addGuests",
@@ -29688,6 +29755,86 @@
},
"required": ["status", "data"]
},
+ "CalMeetingParticipant": {
+ "type": "object",
+ "properties": {
+ "userId": {
+ "type": "string",
+ "nullable": true,
+ "example": "user123"
+ },
+ "userName": {
+ "type": "string",
+ "nullable": true,
+ "example": "John Doe"
+ },
+ "joinTime": {
+ "type": "number",
+ "example": 1678901234
+ },
+ "duration": {
+ "type": "number",
+ "example": 3600
+ }
+ },
+ "required": ["userId", "userName", "joinTime", "duration"]
+ },
+ "CalMeetingSession": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "example": "session123"
+ },
+ "room": {
+ "type": "string",
+ "example": "daily-video-room-123"
+ },
+ "startTime": {
+ "type": "number",
+ "example": 1678901234
+ },
+ "duration": {
+ "type": "number",
+ "example": 3600
+ },
+ "ongoing": {
+ "type": "boolean",
+ "example": false
+ },
+ "maxParticipants": {
+ "type": "number",
+ "example": 10
+ },
+ "participants": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CalMeetingParticipant"
+ }
+ }
+ },
+ "required": ["id", "room", "startTime", "duration", "ongoing", "maxParticipants", "participants"]
+ },
+ "GetBookingVideoSessionsOutput": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "success",
+ "enum": ["success", "error"]
+ },
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CalMeetingSession"
+ }
+ },
+ "error": {
+ "type": "object"
+ }
+ },
+ "required": ["status", "data"]
+ },
"Guest": {
"type": "object",
"properties": {
diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts
index be295a08b3..5b617adfc8 100644
--- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts
+++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts
@@ -13,7 +13,12 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
-import { ZSubmitBatchProcessorJobRes, ZGetTranscriptAccessLink } from "../zod";
+import {
+ ZSubmitBatchProcessorJobRes,
+ ZGetTranscriptAccessLink,
+ getMeetingInformationResponseSchema,
+ TGetMeetingInformationResponsesSchema,
+} from "../zod";
import type { TSubmitBatchProcessorJobRes, TGetTranscriptAccessLink, batchProcessorBody } from "../zod";
import { fetcher } from "./dailyApiFetcher";
import {
@@ -25,28 +30,6 @@ import {
ZGetMeetingTokenResponseSchema,
} from "./types";
-const meetingParticipantSchema = z.object({
- user_id: z.string().nullable(),
- participant_id: z.string(),
- user_name: z.string().nullable(),
- join_time: z.number(),
- duration: z.number(),
-});
-
-const meetingSessionSchema = z.object({
- id: z.string(),
- room: z.string(),
- start_time: z.number(),
- duration: z.number(),
- ongoing: z.boolean(),
- max_participants: z.number(),
- participants: z.array(meetingParticipantSchema),
-});
-
-const getMeetingInformationResponseSchema = z.object({
- data: z.array(meetingSessionSchema),
-});
-
export interface DailyEventResult {
id: string;
name: string;
@@ -520,7 +503,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
throw new Error(`Something went wrong! Unable to checkIfRoomNameMatchesInRecording. ${err}`);
}
},
- getMeetingInformation: async (roomName: string) => {
+ getMeetingInformation: async (roomName: string): Promise => {
try {
const res = await fetcher(`/meetings?room=${encodeURIComponent(roomName)}`).then(
getMeetingInformationResponseSchema.parse
diff --git a/packages/app-store/dailyvideo/zod.ts b/packages/app-store/dailyvideo/zod.ts
index fbfdf4af64..1ce8d3266c 100644
--- a/packages/app-store/dailyvideo/zod.ts
+++ b/packages/app-store/dailyvideo/zod.ts
@@ -38,4 +38,30 @@ export const ZGetTranscriptAccessLink = z.object({
),
});
+const meetingParticipantSchema = z.object({
+ user_id: z.string().nullable(),
+ participant_id: z.string(),
+ user_name: z.string().nullable(),
+ join_time: z.number(),
+ duration: z.number(),
+});
+
+const meetingSessionSchema = z.object({
+ id: z.string(),
+ room: z.string(),
+ start_time: z.number(),
+ duration: z.number(),
+ ongoing: z.boolean(),
+ max_participants: z.number(),
+ participants: z.array(meetingParticipantSchema),
+});
+
+export const getMeetingInformationResponseSchema = z.object({
+ data: z.array(meetingSessionSchema),
+});
+
+export type CalMeetingParticipant = z.infer;
+export type CalMeetingSession = z.infer;
+export type TGetMeetingInformationResponsesSchema = z.infer;
+
export type TGetTranscriptAccessLink = z.infer;
diff --git a/packages/features/bookings/services/BookingAccessService.test.ts b/packages/features/bookings/services/BookingAccessService.test.ts
new file mode 100644
index 0000000000..0b52ffedd3
--- /dev/null
+++ b/packages/features/bookings/services/BookingAccessService.test.ts
@@ -0,0 +1,340 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import type { PrismaClient } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { BookingRepository } from "../repositories/BookingRepository";
+import { BookingAccessService } from "./BookingAccessService";
+
+vi.mock("../repositories/BookingRepository");
+vi.mock("@calcom/features/users/repositories/UserRepository");
+
+describe("BookingAccessService", () => {
+ let service: BookingAccessService;
+ let mockPrismaClient: PrismaClient;
+ let mockBookingRepo: {
+ findByUidIncludeEventType: ReturnType;
+ };
+ let mockUserRepo: {
+ getUserOrganizationAndTeams: ReturnType;
+ };
+ let mockPermissionCheckService: {
+ checkPermission: ReturnType;
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockPrismaClient = {} as PrismaClient;
+
+ mockBookingRepo = {
+ findByUidIncludeEventType: vi.fn(),
+ };
+
+ mockUserRepo = {
+ getUserOrganizationAndTeams: vi.fn(),
+ };
+
+ mockPermissionCheckService = {
+ checkPermission: vi.fn(),
+ };
+
+ vi.mocked(BookingRepository).mockImplementation(() => mockBookingRepo as any);
+ vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
+
+ service = new BookingAccessService(mockPrismaClient);
+
+ (service as any).permissionCheckService = mockPermissionCheckService;
+ });
+
+ describe("doesUserIdHaveAccessToBooking", () => {
+ describe("Case 1: Booking Organizer", () => {
+ it("should return true when user is the booking organizer", async () => {
+ const mockBooking = {
+ userId: 123,
+ eventType: null,
+ attendees: [],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(true);
+ expect(mockBookingRepo.findByUidIncludeEventType).toHaveBeenCalledWith({
+ bookingUid: "test-booking-uid",
+ });
+ });
+
+ it("should return false when user is not the organizer and booking has no team", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: null,
+ attendees: [],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(null);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("Case 2: Booking Host", () => {
+ it("should return true when user is a host in eventType.hosts", async () => {
+ const mockBooking = {
+ userId: 456,
+ user: { id: 456, email: "organizer@example.com" },
+ eventType: {
+ hosts: [
+ { userId: 123, user: { email: "host@example.com" } },
+ { userId: 789, user: { email: "other-host@example.com" } },
+ ],
+ users: [],
+ },
+ attendees: [{ email: "host@example.com" }],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("should return true when user is in eventType.users", async () => {
+ const mockBooking = {
+ userId: 456,
+ user: { id: 456, email: "organizer@example.com" },
+ eventType: {
+ hosts: [],
+ users: [
+ { id: 123, email: "user@example.com" },
+ { id: 789, email: "other-user@example.com" },
+ ],
+ },
+ attendees: [{ email: "user@example.com" }],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("should return false when user is not a host", async () => {
+ const mockBooking = {
+ userId: 456,
+ user: { id: 456, email: "organizer@example.com" },
+ eventType: {
+ hosts: [{ userId: 789, user: { email: "host@example.com" } }],
+ users: [],
+ },
+ attendees: [{ email: "host@example.com" }],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(null);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("Case 3: Team Event Access", () => {
+ it("should return true when user has booking.readTeamBookings permission", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: {
+ teamId: 100,
+ },
+ attendees: [],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockPermissionCheckService.checkPermission.mockResolvedValue(true);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(true);
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
+ userId: 123,
+ teamId: 100,
+ permission: "booking.readTeamBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+ });
+
+ it("should return false when user lacks booking.readTeamBookings permission", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: {
+ teamId: 100,
+ },
+ attendees: [],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockPermissionCheckService.checkPermission.mockResolvedValue(false);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(false);
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
+ userId: 123,
+ teamId: 100,
+ permission: "booking.readTeamBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+ });
+ });
+
+ describe("Case 4: Org Admin Access (Personal Bookings)", () => {
+ it("should return true when user has booking.readOrgBookings permission", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: null,
+ attendees: [],
+ };
+
+ const mockBookingOwner = {
+ organizationId: 200,
+ teams: [],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
+ mockPermissionCheckService.checkPermission.mockResolvedValue(true);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(true);
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
+ userId: 123,
+ teamId: 200,
+ permission: "booking.readOrgBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+ });
+
+ it("should return false when user lacks booking.readOrgBookings permission", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: null,
+ attendees: [],
+ };
+
+ const mockBookingOwner = {
+ organizationId: 200,
+ teams: [],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
+ mockPermissionCheckService.checkPermission.mockResolvedValue(false);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("Case 5: Team Admin Access (Personal Bookings)", () => {
+ it("should return true when user has booking.readTeamBookings on ANY team", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: null,
+ attendees: [],
+ };
+
+ const mockBookingOwner = {
+ organizationId: null,
+ teams: [{ teamId: 300 }, { teamId: 400 }],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
+ mockPermissionCheckService.checkPermission
+ .mockResolvedValueOnce(false) // Team 300 - no permission
+ .mockResolvedValueOnce(true); // Team 400 - has permission
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(true);
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledTimes(2);
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenNthCalledWith(1, {
+ userId: 123,
+ teamId: 300,
+ permission: "booking.readTeamBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenNthCalledWith(2, {
+ userId: 123,
+ teamId: 400,
+ permission: "booking.readTeamBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
+ });
+ });
+
+ it("should return false when user lacks permission on all teams", async () => {
+ const mockBooking = {
+ userId: 456,
+ eventType: null,
+ attendees: [],
+ };
+
+ const mockBookingOwner = {
+ organizationId: null,
+ teams: [{ teamId: 300 }, { teamId: 400 }],
+ };
+
+ mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
+ mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
+ mockPermissionCheckService.checkPermission.mockResolvedValue(false);
+
+ const result = await service.doesUserIdHaveAccessToBooking({
+ userId: 123,
+ bookingUid: "test-booking-uid",
+ });
+
+ expect(result).toBe(false);
+ expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+});
diff --git a/packages/features/bookings/services/BookingAccessService.ts b/packages/features/bookings/services/BookingAccessService.ts
index 0d13b8388f..ad352c486a 100644
--- a/packages/features/bookings/services/BookingAccessService.ts
+++ b/packages/features/bookings/services/BookingAccessService.ts
@@ -1,12 +1,18 @@
+import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { PrismaClient } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
import { BookingRepository } from "../repositories/BookingRepository";
type BookingForAccessCheck = NonNullable>>;
export class BookingAccessService {
- constructor(private prismaClient: PrismaClient) {}
+ private permissionCheckService: PermissionCheckService;
+
+ constructor(private prismaClient: PrismaClient) {
+ this.permissionCheckService = new PermissionCheckService();
+ }
private isUserAHost(userId: number, booking: BookingForAccessCheck): boolean {
const hostMap = new Map();
@@ -38,9 +44,9 @@ export class BookingAccessService {
* Determines if a user has access to a booking based on:
* 1. Being the booking organizer
* 2. Being one of the hosts in a multi-host booking
- * 3. Being a team/org admin where the event type belongs
- * 4. Being an org admin where the booking organizer belongs (for personal bookings)
- * 5. Being a team admin of any team the booking organizer belongs to (for personal bookings)
+ * 3. Being a team/org admin where the event type belongs (uses PBAC if enabled)
+ * 4. Being an org admin where the booking organizer belongs (uses PBAC if enabled, for personal bookings)
+ * 5. Being a team admin of any team the booking organizer belongs to (uses PBAC if enabled, for personal bookings)
*/
async doesUserIdHaveAccessToBooking({
userId,
@@ -63,17 +69,23 @@ export class BookingAccessService {
if (!booking) return false;
+ // Case 1: User is the booking organizer
if (userId === booking.userId) return true;
+ // Case 2: User is one of the hosts
if (this.isUserAHost(userId, booking)) return true;
- // If booking has a teamId, check if user is admin of that team/org
+ // Case 3: If booking has a teamId, check if user has access to team bookings
if (booking.eventType?.teamId) {
- const isAdminOrUser = await userRepo.isAdminOfTeamOrParentOrg({
+ const teamId = booking.eventType.teamId;
+
+ const hasAccess = await this.permissionCheckService.checkPermission({
userId,
- teamId: booking.eventType.teamId,
+ teamId,
+ permission: "booking.readTeamBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
- return isAdminOrUser;
+ return hasAccess;
}
// For managed events (child event types), check the parent's teamId
@@ -91,22 +103,30 @@ export class BookingAccessService {
if (!bookingOwner) return false;
- // Check if user is admin of booking organizer's organization
+ // Case 4: Check if user is admin of booking organizer's organization
if (bookingOwner.organizationId) {
- const isOrgAdmin = await userRepo.isAdminOfTeamOrParentOrg({
+ const orgId = bookingOwner.organizationId;
+
+ const hasAccess = await this.permissionCheckService.checkPermission({
userId,
- teamId: bookingOwner.organizationId,
+ teamId: orgId,
+ permission: "booking.readOrgBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
- if (isOrgAdmin) return true;
+ if (hasAccess) return true;
}
- // Check if user is admin of any team the booking organizer belongs to
+ // Case 5: Check if user is admin of any team the booking organizer belongs to
for (const membership of bookingOwner.teams) {
- const isTeamAdmin = await userRepo.isAdminOfTeamOrParentOrg({
+ const teamId = membership.teamId;
+
+ const hasAccess = await this.permissionCheckService.checkPermission({
userId,
- teamId: membership.teamId,
+ teamId,
+ permission: "booking.readTeamBookings",
+ fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
- if (isTeamAdmin) return true;
+ if (hasAccess) return true;
}
return false;
diff --git a/packages/features/conferencing/lib/videoClient.ts b/packages/features/conferencing/lib/videoClient.ts
index 8c0b93e4dd..6d659a69dc 100644
--- a/packages/features/conferencing/lib/videoClient.ts
+++ b/packages/features/conferencing/lib/videoClient.ts
@@ -6,6 +6,7 @@ import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKey
import { getVideoAdapters } from "@calcom/app-store/getVideoAdapters";
import { sendBrokenIntegrationEmail } from "@calcom/emails/integration-email-service";
import { getUid } from "@calcom/lib/CalEventParser";
+import { CAL_VIDEO, CAL_VIDEO_TYPE } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -167,8 +168,8 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => {
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -190,8 +191,8 @@ export const createInstantMeetingWithCalVideo = async (endTime: string) => {
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -216,8 +217,8 @@ const getRecordingsOfCalVideoByRoomName = async (
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -242,8 +243,8 @@ const getDownloadLinkOfCalVideoByRecordingId = async (
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -266,8 +267,8 @@ const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => {
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -290,8 +291,8 @@ const getAllTranscriptsAccessLinkFromMeetingId = async (meetingId: string) => {
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -314,8 +315,8 @@ const submitBatchProcessorTranscriptionJob = async (recordingId: string) => {
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -350,8 +351,8 @@ const getTranscriptsAccessLinkFromRecordingId = async (recordingId: string) => {
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -375,8 +376,8 @@ const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId:
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
- appId: "daily-video",
- type: "daily_video",
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
userId: null,
user: { email: "" },
teamId: null,
@@ -389,6 +390,31 @@ const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId:
return videoAdapter?.checkIfRoomNameMatchesInRecording?.(roomName, recordingId);
};
+const getCalVideoMeetingSessionsByRoomName = async (roomName: string) => {
+ let dailyAppKeys: Awaited>;
+ try {
+ dailyAppKeys = await getDailyAppKeys();
+ } catch (e) {
+ console.error("Error: Cal video provider is not installed.");
+ return { data: [] };
+ }
+ const [videoAdapter] = await getVideoAdapters([
+ {
+ id: 0,
+ appId: CAL_VIDEO,
+ type: CAL_VIDEO_TYPE,
+ userId: null,
+ user: { email: "" },
+ teamId: null,
+ key: dailyAppKeys,
+ invalid: false,
+ delegationCredentialId: null,
+ },
+ ]);
+
+ return videoAdapter?.getMeetingInformation?.(roomName) ?? { data: [] };
+};
+
export {
getBusyVideoTimes,
createMeeting,
@@ -401,4 +427,5 @@ export {
submitBatchProcessorTranscriptionJob,
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
+ getCalVideoMeetingSessionsByRoomName,
};
diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts
index 77c9623c18..aa4ae30d95 100644
--- a/packages/lib/constants.ts
+++ b/packages/lib/constants.ts
@@ -248,6 +248,10 @@ export const RETELL_AI_TEST_EVENT_TYPE_MAP = (() => {
export const ENV_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS =
process.env._CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS;
+// Cal Video (Daily) app identifiers
+export const CAL_VIDEO = "daily-video";
+export const CAL_VIDEO_TYPE = "daily_video";
+
export const ORG_TRIAL_DAYS = process.env.STRIPE_ORG_TRIAL_DAYS
? Math.max(0, parseInt(process.env.STRIPE_ORG_TRIAL_DAYS, 10))
: null;
diff --git a/packages/platform/libraries/conferencing.ts b/packages/platform/libraries/conferencing.ts
index 4d0aff0d7f..0da2313491 100644
--- a/packages/platform/libraries/conferencing.ts
+++ b/packages/platform/libraries/conferencing.ts
@@ -2,4 +2,7 @@ export {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
getAllTranscriptsAccessLinkFromRoomName,
+ getCalVideoMeetingSessionsByRoomName,
} from "@calcom/features/conferencing/lib/videoClient";
+
+export type { CalMeetingParticipant, CalMeetingSession } from "@calcom/app-store/dailyvideo/zod";
diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts
index 478146fb03..1f7fa378d2 100644
--- a/packages/platform/libraries/index.ts
+++ b/packages/platform/libraries/index.ts
@@ -128,3 +128,5 @@ export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEma
export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publicViewer/checkIfUserEmailVerificationRequired.handler";
export { TeamService } from "@calcom/features/ee/teams/services/teamService";
+
+export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-booking-recordings.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-booking-recordings.output.ts
index a56128d076..4b8cafbc05 100644
--- a/packages/platform/types/bookings/2024-08-13/outputs/get-booking-recordings.output.ts
+++ b/packages/platform/types/bookings/2024-08-13/outputs/get-booking-recordings.output.ts
@@ -55,4 +55,12 @@ export class GetBookingRecordingsOutput {
@ValidateNested({ each: true })
@Type(() => RecordingItem)
data!: RecordingItem[];
+
+ @ApiProperty({
+ example: "This endpoint will require authentication in a future release.",
+ required: false,
+ })
+ @IsString()
+ @IsOptional()
+ message?: string;
}
diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-booking-transcripts.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-booking-transcripts.output.ts
index 21ceb5cfda..d9322cba67 100644
--- a/packages/platform/types/bookings/2024-08-13/outputs/get-booking-transcripts.output.ts
+++ b/packages/platform/types/bookings/2024-08-13/outputs/get-booking-transcripts.output.ts
@@ -14,4 +14,11 @@ export class GetBookingTranscriptsOutput {
@IsArray()
@IsString({ each: true })
data!: string[];
+
+ @ApiProperty({
+ example: "This endpoint will require authentication in a future release.",
+ required: false,
+ })
+ @IsString()
+ message?: string;
}
diff --git a/packages/platform/types/bookings/2024-08-13/outputs/get-booking-video-sessions.output.ts b/packages/platform/types/bookings/2024-08-13/outputs/get-booking-video-sessions.output.ts
new file mode 100644
index 0000000000..96f31f7cee
--- /dev/null
+++ b/packages/platform/types/bookings/2024-08-13/outputs/get-booking-video-sessions.output.ts
@@ -0,0 +1,68 @@
+import { ApiProperty } from "@nestjs/swagger";
+import { Type } from "class-transformer";
+import { IsEnum, ValidateNested, IsNumber, IsString, IsBoolean, IsArray } from "class-validator";
+
+import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
+
+export class CalMeetingParticipant {
+ @ApiProperty({ example: "user123", nullable: true })
+ @IsString()
+ userId!: string | null;
+
+ @ApiProperty({ example: "John Doe", nullable: true })
+ @IsString()
+ userName!: string | null;
+
+ @ApiProperty({ example: 1678901234 })
+ @IsNumber()
+ joinTime!: number;
+
+ @ApiProperty({ example: 3600 })
+ @IsNumber()
+ duration!: number;
+}
+
+export class CalMeetingSession {
+ @ApiProperty({ example: "session123" })
+ @IsString()
+ id!: string;
+
+ @ApiProperty({ example: "daily-video-room-123" })
+ @IsString()
+ room!: string;
+
+ @ApiProperty({ example: 1678901234 })
+ @IsNumber()
+ startTime!: number;
+
+ @ApiProperty({ example: 3600 })
+ @IsNumber()
+ duration!: number;
+
+ @ApiProperty({ example: false })
+ @IsBoolean()
+ ongoing!: boolean;
+
+ @ApiProperty({ example: 10 })
+ @IsNumber()
+ maxParticipants!: number;
+
+ @ApiProperty({ type: [CalMeetingParticipant] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => CalMeetingParticipant)
+ participants!: CalMeetingParticipant[];
+}
+
+export class GetBookingVideoSessionsOutput {
+ @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
+ @IsEnum([SUCCESS_STATUS, ERROR_STATUS])
+ status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
+
+ error?: Error;
+
+ @ApiProperty({ type: [CalMeetingSession] })
+ @ValidateNested({ each: true })
+ @Type(() => CalMeetingSession)
+ data!: CalMeetingSession[];
+}
diff --git a/packages/platform/types/bookings/2024-08-13/outputs/index.ts b/packages/platform/types/bookings/2024-08-13/outputs/index.ts
index 16be5288fe..cac349980b 100644
--- a/packages/platform/types/bookings/2024-08-13/outputs/index.ts
+++ b/packages/platform/types/bookings/2024-08-13/outputs/index.ts
@@ -3,3 +3,4 @@ export * from "./get-booking.output";
export * from "./get-bookings.output";
export * from "./get-booking-recordings.output";
export * from "./get-booking-transcripts.output";
+export * from "./get-booking-video-sessions.output";
diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts
index 099224464c..fbef66a251 100644
--- a/packages/types/VideoApiAdapter.d.ts
+++ b/packages/types/VideoApiAdapter.d.ts
@@ -2,6 +2,7 @@ import type {
TSubmitBatchProcessorJobRes,
batchProcessorBody,
TGetTranscriptAccessLink,
+ TGetMeetingInformationResponsesSchema,
} from "@calcom/app-store/dailyvideo/zod";
import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils";
@@ -44,7 +45,7 @@ export type VideoApiAdapter =
checkIfRoomNameMatchesInRecording?(roomName: string, recordingId: string): Promise;
- getMeetingInformation?(roomName: string): Promise;
+ getMeetingInformation?(roomName: string): Promise;
}
| undefined;