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:
parent
cc1d606ba8
commit
2dddd4ce77
|
@ -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) }),
|
||||
});
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user