diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts new file mode 100644 index 0000000000..3ec371dd98 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest } from "next"; + +import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient"; +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { RecordingItemSchema } from "@calcom/prisma/zod-utils"; +import type { PartialReference } from "@calcom/types/EventManager"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}/recordings: + * get: + * summary: Find all Cal video recordings of that booking + * operationId: getRecordingsByBookingId + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking for which recordings need to be fetched. Recording download link is only valid for 12 hours and you would have to fetch the recordings again to get new download link + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - bookings + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/ArrayOfRecordings" + * examples: + * recordings: + * value: + * - id: "ad90a2e7-154f-49ff-a815-5da1db7bf899" + * room_name: "0n22w24AQ5ZFOtEKX2gX" + * start_ts: 1716215386 + * status: "finished" + * max_participants: 1 + * duration: 11 + * share_token: "x94YK-69Gnh7" + * download_link: "https://daily-meeting-recordings..." + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Booking was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { references: true }, + }); + + if (!booking) + throw new HttpError({ + statusCode: 404, + message: `No Booking found with booking id ${id}`, + }); + + const roomName = + booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? + undefined; + + if (!roomName) + throw new HttpError({ + statusCode: 404, + message: `No Cal Video reference found with booking id ${booking.id}`, + }); + + const recordings = await getRecordingsOfCalVideoByRoomName(roomName); + + if (!recordings || !("data" in recordings)) return []; + + const recordingWithDownloadLink = recordings.data.map((recording: RecordingItemSchema) => { + return getDownloadLinkOfCalVideoByRecordingId(recording.id) + .then((res) => ({ + ...recording, + download_link: res?.download_link, + })) + .catch((err) => ({ ...recording, download_link: null, error: err.message })); + }); + const res = await Promise.all(recordingWithDownloadLink); + return res; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts b/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts new file mode 100644 index 0000000000..8d5bc44ed5 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/recordings/index.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +import authMiddleware from "../_auth-middleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts new file mode 100644 index 0000000000..2200f06466 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts @@ -0,0 +1,73 @@ +import type { NextApiRequest } from "next"; + +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import type { PartialReference } from "@calcom/types/EventManager"; + +import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt"; + +/** + * @swagger + * /bookings/{id}/transcripts: + * get: + * summary: Find all Cal video transcripts of that booking + * operationId: getTranscriptsByBookingId + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID of the booking for which recordings need to be fetched. + * - in: query + * name: apiKey + * required: true + * schema: + * type: string + * description: Your API key + * tags: + * - bookings + * responses: + * 200: + * description: OK + * content: + * application/json: + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Booking was not found + */ + +export async function getHandler(req: NextApiRequest) { + const { query } = req; + const { id } = schemaQueryIdParseInt.parse(query); + + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { references: true }, + }); + + if (!booking) + throw new HttpError({ + statusCode: 404, + message: `No Booking found with booking id ${id}`, + }); + + const roomName = + booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? + undefined; + + if (!roomName) + throw new HttpError({ + statusCode: 404, + message: `No Cal Video reference found with booking id ${booking.id}`, + }); + + const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName); + + return transcripts; +} + +export default defaultResponder(getHandler); diff --git a/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts b/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts new file mode 100644 index 0000000000..8d5bc44ed5 --- /dev/null +++ b/apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts @@ -0,0 +1,16 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "~/lib/helpers/withMiddleware"; + +import authMiddleware from "../_auth-middleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + })(req, res); + }) +); diff --git a/apps/api/v1/pages/api/docs.ts b/apps/api/v1/pages/api/docs.ts index 14b947ce0b..0619965535 100644 --- a/apps/api/v1/pages/api/docs.ts +++ b/apps/api/v1/pages/api/docs.ts @@ -27,6 +27,37 @@ const swaggerHandler = withSwagger({ $ref: "#/components/schemas/Booking", }, }, + ArrayOfRecordings: { + type: "array", + items: { + $ref: "#/components/schemas/Recording", + }, + }, + Recording: { + properties: { + id: { + type: "string", + }, + room_name: { + type: "string", + }, + start_ts: { + type: "number", + }, + status: { + type: "string", + }, + max_participants: { + type: "number", + }, + duration: { + type: "number", + }, + download_link: { + type: "string", + }, + }, + }, Booking: { properties: { id: { diff --git a/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts new file mode 100644 index 0000000000..7a0cc731bd --- /dev/null +++ b/apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts @@ -0,0 +1,136 @@ +import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { + getRecordingsOfCalVideoByRoomName, + getDownloadLinkOfCalVideoByRecordingId, +} from "@calcom/core/videoClient"; +import { buildBooking } from "@calcom/lib/test/builder"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; + +import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware"; +import handler from "../../../../../pages/api/bookings/[id]/recordings/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +const adminUserId = 1; +const memberUserId = 10; + +vi.mock("@calcom/core/videoClient", () => { + return { + getRecordingsOfCalVideoByRoomName: vi.fn(), + getDownloadLinkOfCalVideoByRecordingId: vi.fn(), + }; +}); + +vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => { + return { + getAccessibleUsers: vi.fn(), + }; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +const mockGetRecordingsAndDownloadLink = () => { + const download_link = "https://URL"; + const recordingItem = { + id: "TEST_ID", + room_name: "0n22w24AQ5ZFOtEKX2gX", + start_ts: 1716215386, + status: "finished", + max_participants: 1, + duration: 11, + share_token: "TEST_TOKEN", + }; + + vi.mocked(getRecordingsOfCalVideoByRoomName).mockResolvedValue({ data: [recordingItem], total_count: 1 }); + + vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({ + download_link, + }); + + return [{ ...recordingItem, download_link }]; +}; + +describe("GET /api/bookings/[id]/recordings", () => { + test("Returns recordings if user is system-wide admin", async () => { + const userId = 2; + + const bookingId = 1111; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId, + references: [ + { + id: 1, + type: "daily_video", + uid: "17OHkCH53pBa03FhxMbw", + meetingId: "17OHkCH53pBa03FhxMbw", + meetingPassword: "password", + meetingUrl: "https://URL", + }, + ], + }) + ); + + const mockedRecordings = mockGetRecordingsAndDownloadLink(); + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual(mockedRecordings); + }); + + test("Allows GET recordings when user is org-wide admin", async () => { + const bookingId = 3333; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId: memberUserId, + references: [ + { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" }, + ], + }) + ); + + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.userId = adminUserId; + req.isOrganizationOwnerOrAdmin = true; + const mockedRecordings = mockGetRecordingsAndDownloadLink(); + vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]); + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts new file mode 100644 index 0000000000..a821935bd8 --- /dev/null +++ b/apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts @@ -0,0 +1,120 @@ +import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock"; + +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMocks } from "node-mocks-http"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; +import { buildBooking } from "@calcom/lib/test/builder"; + +import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers"; + +import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware"; +import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get"; + +type CustomNextApiRequest = NextApiRequest & Request; +type CustomNextApiResponse = NextApiResponse & Response; + +vi.mock("@calcom/core/videoClient", () => { + return { + getAllTranscriptsAccessLinkFromRoomName: vi.fn(), + }; +}); + +vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => { + return { + getAccessibleUsers: vi.fn(), + }; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +const mockGetTranscripts = () => { + const downloadLinks = ["https://URL1", "https://URL2"]; + + vi.mocked(getAllTranscriptsAccessLinkFromRoomName).mockResolvedValue(downloadLinks); + + return downloadLinks; +}; + +describe("GET /api/bookings/[id]/transcripts", () => { + test("Returns transcripts if user is system-wide admin", async () => { + const adminUserId = 1; + const userId = 2; + + const bookingId = 1111; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId, + references: [ + { + id: 1, + type: "daily_video", + uid: "17OHkCH53pBa03FhxMbw", + meetingId: "17OHkCH53pBa03FhxMbw", + meetingPassword: "password", + meetingUrl: "https://URL", + }, + ], + }) + ); + + const mockedTranscripts = mockGetTranscripts(); + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.isSystemWideAdmin = true; + req.userId = adminUserId; + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res._getData())).toEqual(mockedTranscripts); + }); + + test("Allows GET transcripts when user is org-wide admin", async () => { + const adminUserId = 1; + const memberUserId = 10; + const bookingId = 3333; + + prismaMock.booking.findUnique.mockResolvedValue( + buildBooking({ + id: bookingId, + userId: memberUserId, + references: [ + { id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" }, + ], + }) + ); + + const { req, res } = createMocks({ + method: "GET", + body: {}, + query: { + id: bookingId, + }, + }); + + req.userId = adminUserId; + req.isOrganizationOwnerOrAdmin = true; + mockGetTranscripts(); + + vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]); + + await authMiddleware(req); + await handler(req, res); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 771fb9f074..d3083672c8 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -1,5 +1,5 @@ import { faker } from "@faker-js/faker"; -import type { Booking, EventType, Prisma, Webhook } from "@prisma/client"; +import type { Booking, EventType, Prisma, Webhook, BookingReference } from "@prisma/client"; import type { TFunction } from "next-i18next"; import getICalUID from "@calcom/emails/lib/getICalUID"; @@ -31,7 +31,9 @@ export const buildPerson = (person?: Partial): Person => { }; }; -export const buildBooking = (booking?: Partial): Booking => { +export const buildBooking = ( + booking?: Partial & { references?: Partial[] } +): Booking & { references?: Partial[] } => { const uid = faker.datatype.uuid(); return { id: faker.datatype.number(),