feat: add session endpoint and booking access pbac (#24637)

* feat: add session endpoint

* fix: remove participant id

* refactor; feedback

* fix: e2e test

* refactor: add pbac

* refactor: use constant

* tests: add booking tests

* fix: conflicts

* chore: test

* fix: tests

* fix: tests

* chore: remove un necessary

* chore: docs

* fix: desc

* fix: booking-pbac gurd

* fix: booking-pbac gurd

* test: add unit tests

* fix: remove legacy pbac

* fix: chore remove

* fix: update tests

* test: add more test

* test: move it to new file

* chore: comment auth

* chore: add message

* chore: add message

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Udit Takkar
2025-12-04 05:45:17 +00:00
committed by GitHub
co-authored by Morgan
parent 3a8bf7014c
commit a11f7e6510
22 changed files with 1186 additions and 71 deletions
+2
View File
@@ -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'
@@ -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],
@@ -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.
<Note>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.</Note>
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>
`,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
@@ -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.
<Note>cal-api-version: \`2024-08-13\` is required in the request header.</Note>`,
})
async getVideoSessions(@Param("bookingUid") bookingUid: string): Promise<GetBookingVideoSessionsOutput> {
const sessions = await this.calVideoService.getVideoSessions(bookingUid);
return {
status: SUCCESS_STATUS,
data: sessions,
};
}
}
@@ -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>(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();
});
});
});
@@ -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);
@@ -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<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { user?: ApiAuthGuardUser; pbacAuthorizedRequest?: boolean }>();
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;
}
}
@@ -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,
})),
}));
}
}
@@ -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);
}
}
+148 -1
View File
@@ -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 <Note>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.</Note>\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 <Note>cal-api-version: `2024-08-13` is required in the request header.</Note>\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -9922,6 +9922,15 @@
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` 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 <token>` where `<token>` 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 <Note>cal-api-version: `2024-08-13` is required in the request header.</Note>",
"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 <token>` where `<token>` 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": {
@@ -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<TGetMeetingInformationResponsesSchema> => {
try {
const res = await fetcher(`/meetings?room=${encodeURIComponent(roomName)}`).then(
getMeetingInformationResponseSchema.parse
+26
View File
@@ -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<typeof meetingParticipantSchema>;
export type CalMeetingSession = z.infer<typeof meetingSessionSchema>;
export type TGetMeetingInformationResponsesSchema = z.infer<typeof getMeetingInformationResponseSchema>;
export type TGetTranscriptAccessLink = z.infer<typeof ZGetTranscriptAccessLink>;
@@ -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<typeof vi.fn>;
};
let mockUserRepo: {
getUserOrganizationAndTeams: ReturnType<typeof vi.fn>;
};
let mockPermissionCheckService: {
checkPermission: ReturnType<typeof vi.fn>;
};
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);
});
});
});
});
@@ -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<Awaited<ReturnType<BookingRepository["findByUidIncludeEventType"]>>>;
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<number, { id: number; email: string }>();
@@ -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;
@@ -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<ReturnType<typeof getDailyAppKeys>>;
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,
};
+4
View File
@@ -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;
@@ -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";
+2
View File
@@ -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";
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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";
+2 -1
View File
@@ -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<boolean>;
getMeetingInformation?(roomName: string): Promise<any>;
getMeetingInformation?(roomName: string): Promise<TGetMeetingInformationResponsesSchema>;
}
| undefined;