From 6dd4933337714f69d9ea48f937c129245cca195b Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 22 May 2024 18:34:08 +0530 Subject: [PATCH] feat: add get recording and transcripts API endpoint (#15131) * feat: add get recording API endpoint * feat: add transcript endpoint * chore: add missing await * chore: add error handling * fix: type err and add swagger comment * test: add test * chore: add afterEach * fix: test * test: add transcript test --- .../api/bookings/[id]/recordings/_get.ts | 99 +++++++++++++ .../api/bookings/[id]/recordings/index.ts | 16 +++ .../api/bookings/[id]/transcripts/_get.ts | 73 ++++++++++ .../api/bookings/[id]/transcripts/index.ts | 16 +++ apps/api/v1/pages/api/docs.ts | 31 ++++ .../lib/bookings/[id]/recordings/_get.test.ts | 136 ++++++++++++++++++ .../bookings/[id]/transcripts/_get.test.ts | 120 ++++++++++++++++ packages/lib/test/builder.ts | 6 +- 8 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 apps/api/v1/pages/api/bookings/[id]/recordings/_get.ts create mode 100644 apps/api/v1/pages/api/bookings/[id]/recordings/index.ts create mode 100644 apps/api/v1/pages/api/bookings/[id]/transcripts/_get.ts create mode 100644 apps/api/v1/pages/api/bookings/[id]/transcripts/index.ts create mode 100644 apps/api/v1/test/lib/bookings/[id]/recordings/_get.test.ts create mode 100644 apps/api/v1/test/lib/bookings/[id]/transcripts/_get.test.ts 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(),