feat: create trpc route to fetch download link (#7495)

* feat: create trpc route to fetch download link

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: removing file, adding isdownloadable logix

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: type error

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Some performance enhancements and better error logging

* Add error message here too

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Udit Takkar 2023-03-05 18:29:07 +05:30 committed by GitHub
parent cc1d606ba8
commit 2dddd4ce77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 204 additions and 122 deletions

View File

@ -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<z.infer<typeof getAccessLinkSchema> | 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) }),
});

View File

@ -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<GetAccessLinkResponseSchema> => {
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");
}
},
};
};

View File

@ -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<ReturnType<typeof getDailyAppKeys>>;
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,
};

View File

@ -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";

View File

@ -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 ? (
<div className="flex flex-col gap-3">
{recordings.data.map((recording: RecordingItemSchema, index: number) => {
return (
<div
className="flex w-full items-center justify-between rounded-md border px-4 py-2"
key={recording.id}>
<div className="flex flex-col">
<h1 className="text-sm font-semibold">
{t("recording")} {index + 1}
</h1>
<p className="text-sm font-normal text-gray-500">
{convertSecondsToMs(recording.duration)}
</p>
</div>
{hasPaidPlan ? (
<Button
StartIcon={FiDownload}
className="ml-4 lg:ml-0"
loading={isFetching}
onClick={() => handleDownloadClick(recording.id)}>
{t("download")}
</Button>
) : (
<UpgradeTeamsBadge />
)}
</div>
);
})}
</div>
) : (
(!recordings || (recordings && "total_count" in recordings && recordings?.total_count === 0)) && (
<p className="font-semibold">{t("no_recordings_found")}</p>
)
)}
</>
);
};
export const ViewRecordingsDialog = (props: IViewRecordingsDialog) => {
const { t, i18n } = useLocale();
const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props;
const [downloadingRecordingId, setRecordingId] = useState<string | null>(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 (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogHeader title={t("recordings_title")} subtitle={subtitle} />
<LicenseRequired>
<>
{(isLoading || isTeamPlanStatusLoading) && <RecordingListSkeleton />}
{recordings && "data" in recordings && recordings?.data?.length > 0 && (
<div className="flex flex-col gap-3">
{recordings.data.map((recording: RecordingItemSchema, index: number) => {
return (
<div
className="flex w-full items-center justify-between rounded-md border px-4 py-2"
key={recording.id}>
<div className="flex flex-col">
<h1 className="text-sm font-semibold">
{t("recording")} {index + 1}
</h1>
<p className="text-sm font-normal text-gray-500">
{convertSecondsToMs(recording.duration)}
</p>
</div>
{hasPaidPlan ? (
<Button
StartIcon={FiDownload}
className="ml-4 lg:ml-0"
loading={downloadingRecordingId === recording.id}
onClick={() => handleDownloadClick(recording.id)}>
{t("download")}
</Button>
) : (
<UpgradeTeamsBadge />
)}
</div>
);
})}
</div>
{roomName ? (
<LicenseRequired>
{isTeamPlanStatusLoading ? (
<RecordingListSkeleton />
) : (
<Suspense fallback={<RecordingListSkeleton />}>
<ViewRecordingsList hasPaidPlan={!!hasPaidPlan} roomName={roomName} />
</Suspense>
)}
{!isLoading &&
(!recordings ||
(recordings && "total_count" in recordings && recordings?.total_count === 0)) && (
<h1 className="font-semibold">{t("no_recordings_found")}</h1>
)}
</>
</LicenseRequired>
</LicenseRequired>
) : (
<p className="font-semibold">{t("no_recordings_found")}</p>
)}
<DialogFooter>
<DialogClose className="border" />
</DialogFooter>

View File

@ -433,3 +433,9 @@ export const fromEntries = <
): FromEntries<DeepWriteable<E>> => {
return Object.fromEntries(entries) as FromEntries<DeepWriteable<E>>;
};
export const getAccessLinkResponseSchema = z.object({
download_link: z.string().url(),
});
export type GetAccessLinkResponseSchema = z.infer<typeof getAccessLinkResponseSchema>;

View File

@ -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;
}),

View File

@ -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<EventBusyDate[]>;
getRecordings?(roomName: string): Promise<GetRecordingsResponseSchema>;
getRecordingDownloadLink?(recordingId: string): Promise<GetAccessLinkResponseSchema>;
}
| undefined;