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;