diff --git a/apps/web/pages/api/download-cal-video-recording.ts b/apps/web/pages/api/download-cal-video-recording.ts deleted file mode 100644 index 1d87b8ccc5..0000000000 --- a/apps/web/pages/api/download-cal-video-recording.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - -import { fetcher } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; -import { getSession } from "@calcom/lib/auth"; -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; -import { defaultHandler, defaultResponder } from "@calcom/lib/server"; - -const getAccessLinkSchema = z.object({ - download_link: z.string().url(), - // expires (timestamp), s3_bucket, s3_region, s3_key -}); - -const requestQuery = z.object({ - recordingId: z.string(), -}); - -const isDownloadAllowed = async (req: NextApiRequest) => { - if (IS_SELF_HOSTED) return true; - const session = await getSession({ req }); - // Check if user belong to active team - return !session?.user?.belongsToActiveTeam; -}; - -async function handler( - req: NextApiRequest, - res: NextApiResponse | void> -) { - const { recordingId } = requestQuery.parse(req.query); - - if (!(await isDownloadAllowed(req))) return res.status(403); - - const response = await fetcher(`/recordings/${recordingId}/access-link`).then(getAccessLinkSchema.parse); - return res.status(200).json(response); -} - -export default defaultHandler({ - GET: Promise.resolve({ default: defaultResponder(handler) }), -}); diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 7e6313d96f..87250e59eb 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { handleErrorsJson } from "@calcom/lib/errors"; -import type { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; -import { getRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; +import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils"; +import { getRecordingsResponseSchema, getAccessLinkResponseSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import type { PartialReference } from "@calcom/types/EventManager"; @@ -159,6 +159,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { throw new Error("Something went wrong! Unable to get recording"); } }, + getRecordingDownloadLink: async (recordingId: string): Promise => { + try { + const res = await fetcher(`/recordings/${recordingId}/access-link`).then( + getAccessLinkResponseSchema.parse + ); + return Promise.resolve(res); + } catch (err) { + console.log("err", err); + throw new Error("Something went wrong! Unable to get recording access link"); + } + }, }; }; diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index c808464a37..ff5308b956 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -7,9 +7,9 @@ import { sendBrokenIntegrationEmail } from "@calcom/emails"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; -import { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; +import type { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar"; -import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential"; +import type { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { VideoApiAdapter, VideoApiAdapterFactory, VideoCallData } from "@calcom/types/VideoApiAdapter"; @@ -175,6 +175,7 @@ const getRecordingsOfCalVideoByRoomName = async ( try { dailyAppKeys = await getDailyAppKeys(); } catch (e) { + console.error("Error: Cal video provider is not installed."); return; } const [videoAdapter] = getVideoAdapters([ @@ -190,4 +191,32 @@ const getRecordingsOfCalVideoByRoomName = async ( return videoAdapter?.getRecordings?.(roomName); }; -export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting, getRecordingsOfCalVideoByRoomName }; +const getDownloadLinkOfCalVideoByRecordingId = async (recordingId: string) => { + let dailyAppKeys: Awaited>; + try { + dailyAppKeys = await getDailyAppKeys(); + } catch (e) { + console.error("Error: Cal video provider is not installed."); + return; + } + const [videoAdapter] = getVideoAdapters([ + { + id: 0, + appId: "daily-video", + type: "daily_video", + userId: null, + key: dailyAppKeys, + invalid: false, + }, + ]); + return videoAdapter?.getRecordingDownloadLink?.(recordingId); +}; + +export { + getBusyVideoTimes, + createMeeting, + updateMeeting, + deleteMeeting, + getRecordingsOfCalVideoByRoomName, + getDownloadLinkOfCalVideoByRecordingId, +}; diff --git a/packages/features/ee/common/components/v2/LicenseRequired.tsx b/packages/features/ee/common/components/v2/LicenseRequired.tsx index 4b366df547..43452e7344 100644 --- a/packages/features/ee/common/components/v2/LicenseRequired.tsx +++ b/packages/features/ee/common/components/v2/LicenseRequired.tsx @@ -1,6 +1,7 @@ import DOMPurify from "dompurify"; import { useSession } from "next-auth/react"; -import React, { AriaRole, ComponentType, Fragment } from "react"; +import type { AriaRole, ComponentType } from "react"; +import React, { Fragment } from "react"; import { APP_NAME, CONSOLE_URL, SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/packages/features/ee/video/ViewRecordingsDialog.tsx b/packages/features/ee/video/ViewRecordingsDialog.tsx index 55c901315d..641dd938d5 100644 --- a/packages/features/ee/video/ViewRecordingsDialog.tsx +++ b/packages/features/ee/video/ViewRecordingsDialog.tsx @@ -1,11 +1,12 @@ -import { useState } from "react"; +import { useState, Suspense } from "react"; import dayjs from "@calcom/dayjs"; import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired"; import useHasPaidPlan from "@calcom/lib/hooks/useHasPaidPlan"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { RecordingItemSchema } from "@calcom/prisma/zod-utils"; -import { RouterOutputs, trpc } from "@calcom/trpc/react"; +import type { RecordingItemSchema } from "@calcom/prisma/zod-utils"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; import type { PartialReference } from "@calcom/types/EventManager"; import { Dialog, @@ -15,7 +16,7 @@ import { DialogHeader, UpgradeTeamsBadge, } from "@calcom/ui"; -import { Button, showToast } from "@calcom/ui"; +import { Button } from "@calcom/ui"; import { FiDownload } from "@calcom/ui/components/icon"; import RecordingListSkeleton from "./components/RecordingListSkeleton"; @@ -40,31 +41,122 @@ interface GetTimeSpanProps { startTime: string | undefined; endTime: string | undefined; locale: string; - isTimeFormatAMPM: boolean; + hour12: boolean; } -const getTimeSpan = ({ startTime, endTime, locale, isTimeFormatAMPM }: GetTimeSpanProps) => { +const getTimeSpan = ({ startTime, endTime, locale, hour12 }: GetTimeSpanProps) => { if (!startTime || !endTime) return ""; const formattedStartTime = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", - hour12: isTimeFormatAMPM, + hour12, }).format(new Date(startTime)); const formattedEndTime = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric", - hour12: isTimeFormatAMPM, + hour12, }).format(new Date(endTime)); return `${formattedStartTime} - ${formattedEndTime}`; }; +const useRecordingDownload = () => { + const [recordingId, setRecordingId] = useState(""); + const { isFetching, data } = trpc.viewer.getDownloadLinkOfCalVideoRecordings.useQuery( + { + recordingId, + }, + { + enabled: !!recordingId, + cacheTime: 0, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + onSuccess: (data) => { + if (data && data.download_link) { + window.location.href = data.download_link; + } + }, + } + ); + + return { + setRecordingId: (newRecordingId: string) => { + // may be a way to do this by default, but this is easy enough. + if (recordingId === newRecordingId && data) { + window.location.href = data.download_link; + } + if (!isFetching) { + setRecordingId(newRecordingId); + } + // assume it is still fetching, do nothing. + }, + isFetching, + }; +}; + +const ViewRecordingsList = ({ roomName, hasPaidPlan }: { roomName: string; hasPaidPlan: boolean }) => { + const { t } = useLocale(); + const { setRecordingId, isFetching } = useRecordingDownload(); + + const { data: recordings } = trpc.viewer.getCalVideoRecordings.useQuery( + { roomName }, + { + suspense: true, + } + ); + + const handleDownloadClick = async (recordingId: string) => { + // this would enable the getDownloadLinkOfCalVideoRecordings + setRecordingId(recordingId); + }; + + return ( + <> + {recordings && "data" in recordings && recordings?.data?.length > 0 ? ( +
+ {recordings.data.map((recording: RecordingItemSchema, index: number) => { + return ( +
+
+

+ {t("recording")} {index + 1} +

+

+ {convertSecondsToMs(recording.duration)} +

+
+ {hasPaidPlan ? ( + + ) : ( + + )} +
+ ); + })} +
+ ) : ( + (!recordings || (recordings && "total_count" in recordings && recordings?.total_count === 0)) && ( +

{t("no_recordings_found")}

+ ) + )} + + ); +}; + export const ViewRecordingsDialog = (props: IViewRecordingsDialog) => { const { t, i18n } = useLocale(); const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props; - const [downloadingRecordingId, setRecordingId] = useState(null); const { hasPaidPlan, isLoading: isTeamPlanStatusLoading } = useHasPaidPlan(); @@ -72,84 +164,32 @@ export const ViewRecordingsDialog = (props: IViewRecordingsDialog) => { booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ?? undefined; - const { data: recordings, isLoading } = trpc.viewer.getCalVideoRecordings.useQuery( - { roomName: roomName ?? "" }, - { enabled: !!roomName && isOpenDialog } - ); - const handleDownloadClick = async (recordingId: string) => { - try { - setRecordingId(recordingId); - const res = await fetch(`/api/download-cal-video-recording?recordingId=${recordingId}`, { - headers: { - "Content-Type": "application/json", - }, - }); - const respBody = await res.json(); - - if (respBody?.download_link) { - window.location.href = respBody.download_link; - } - } catch (err) { - console.error(err); - showToast(t("something_went_wrong"), "error"); - } - setRecordingId(null); - }; - const subtitle = `${booking?.title} - ${dayjs(booking?.startTime).format("ddd")} ${dayjs( booking?.startTime ).format("D")}, ${dayjs(booking?.startTime).format("MMM")} ${getTimeSpan({ startTime: booking?.startTime, endTime: booking?.endTime, locale: i18n.language, - isTimeFormatAMPM: timeFormat === 12, + hour12: timeFormat === 12, })} `; return ( - - <> - {(isLoading || isTeamPlanStatusLoading) && } - {recordings && "data" in recordings && recordings?.data?.length > 0 && ( -
- {recordings.data.map((recording: RecordingItemSchema, index: number) => { - return ( -
-
-

- {t("recording")} {index + 1} -

-

- {convertSecondsToMs(recording.duration)} -

-
- {hasPaidPlan ? ( - - ) : ( - - )} -
- ); - })} -
+ {roomName ? ( + + {isTeamPlanStatusLoading ? ( + + ) : ( + }> + + )} - {!isLoading && - (!recordings || - (recordings && "total_count" in recordings && recordings?.total_count === 0)) && ( -

{t("no_recordings_found")}

- )} - -
+
+ ) : ( +

{t("no_recordings_found")}

+ )} diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 1f18d72e33..90eb83222e 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -433,3 +433,9 @@ export const fromEntries = < ): FromEntries> => { return Object.fromEntries(entries) as FromEntries>; }; + +export const getAccessLinkResponseSchema = z.object({ + download_link: z.string().url(), +}); + +export type GetAccessLinkResponseSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 640df42d15..0bc4888dfa 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -14,13 +14,17 @@ import getApps, { getLocationGroupedOptions } from "@calcom/app-store/utils"; import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { DailyLocationType } from "@calcom/core/location"; -import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient"; +import { + getRecordingsOfCalVideoByRoomName, + getDownloadLinkOfCalVideoByRecordingId, +} from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; import { ErrorCode, verifyPassword } from "@calcom/lib/auth"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; @@ -1158,6 +1162,7 @@ const loggedInViewerRouter = router({ ) .query(async ({ input }) => { const { roomName } = input; + try { const res = await getRecordingsOfCalVideoByRoomName(roomName); return res; @@ -1167,6 +1172,33 @@ const loggedInViewerRouter = router({ }); } }), + getDownloadLinkOfCalVideoRecordings: authedProcedure + .input( + z.object({ + recordingId: z.string(), + }) + ) + .query(async ({ input, ctx }) => { + const { recordingId } = input; + const { session } = ctx; + + const isDownloadAllowed = IS_SELF_HOSTED || session.user.belongsToActiveTeam; + + if (!isDownloadAllowed) { + throw new TRPCError({ + code: "FORBIDDEN", + }); + } + + try { + const res = await getDownloadLinkOfCalVideoByRecordingId(recordingId); + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + }), getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => { return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp; }), diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts index abc74d45d1..cc1dfd109e 100644 --- a/packages/types/VideoApiAdapter.d.ts +++ b/packages/types/VideoApiAdapter.d.ts @@ -1,7 +1,7 @@ -import { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; +import type { GetRecordingsResponseSchema, GetAccessLinkResponseSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "./Calendar"; -import { CredentialPayload } from "./Credential"; +import type { CredentialPayload } from "./Credential"; export interface VideoCallData { type: string; @@ -22,6 +22,8 @@ export type VideoApiAdapter = getAvailability(dateFrom?: string, dateTo?: string): Promise; getRecordings?(roomName: string): Promise; + + getRecordingDownloadLink?(recordingId: string): Promise; } | undefined;