From bcf5fb18c5ff2146f3a1a8f1ee10a1891048b8c9 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 28 Dec 2022 02:33:39 +0530 Subject: [PATCH] Cal Video (Daily) Recording (#6039) * feat: wip Signed-off-by: Udit Takkar * feat: add download recording button Signed-off-by: Udit Takkar * moved video recording into /ee/, wrapped in LicenseRequired * fix: security issues in downloading recording updates designs Signed-off-by: Udit Takkar * feat: add upgradation banner Signed-off-by: Udit Takkar * chore: remove default room Signed-off-by: Udit Takkar * chore: fix type error Signed-off-by: Udit Takkar * fix: add type in get recording Signed-off-by: Udit Takkar * fix: add suggestions and zod type for recording Signed-off-by: Udit Takkar * fix: add types in getRecordings Signed-off-by: Udit Takkar * fix: type error Signed-off-by: Udit Takkar * fix: type error Signed-off-by: Udit Takkar * fix: finally all type errors fixed Signed-off-by: Udit Takkar * Server side validation for users in team plans for recordings * fix: remove any type Signed-off-by: Udit Takkar * fix: type error Signed-off-by: Udit Takkar Signed-off-by: Udit Takkar Co-authored-by: Peer Richelsen Co-authored-by: Peer Richelsen Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Alan --- .../components/booking/BookingListItem.tsx | 28 ++- .../pages/api/download-cal-video-recording.ts | 46 +++++ apps/web/public/static/locales/en/common.json | 10 +- .../dailyvideo/lib/VideoApiAdapter.ts | 24 ++- .../dailyvideo/lib/getDailyAppKeys.ts | 1 + packages/app-store/dailyvideo/zod.ts | 2 +- packages/core/videoClient.ts | 25 ++- .../ee/video/ViewRecordingsDialog.tsx | 164 ++++++++++++++++++ .../components/RecordingListItemSkeleton.tsx | 19 ++ .../components/RecordingListSkeleton.tsx | 15 ++ .../components/UpgradeRecordingBanner.tsx | 30 ++++ packages/prisma/zod-utils.ts | 24 +++ packages/trpc/server/routers/viewer.tsx | 52 +++++- .../trpc/server/routers/viewer/bookings.tsx | 1 + packages/trpc/server/routers/viewer/teams.tsx | 14 ++ .../trpc/server/routers/viewer/workflows.tsx | 6 +- packages/types/VideoApiAdapter.d.ts | 4 + 17 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 apps/web/pages/api/download-cal-video-recording.ts create mode 100644 packages/features/ee/video/ViewRecordingsDialog.tsx create mode 100644 packages/features/ee/video/components/RecordingListItemSkeleton.tsx create mode 100644 packages/features/ee/video/components/RecordingListSkeleton.tsx create mode 100644 packages/features/ee/video/components/UpgradeRecordingBanner.tsx diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 32a972429b..ae88f6067c 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; +import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog"; import classNames from "@calcom/lib/classNames"; import { formatTime } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -22,8 +23,9 @@ import { showToast, TextArea, Tooltip, + ActionType, + TableActions, } from "@calcom/ui"; -import { ActionType, TableActions } from "@calcom/ui"; import useMeQuery from "@lib/hooks/useMeQuery"; @@ -48,6 +50,7 @@ function BookingListItem(booking: BookingItemProps) { const router = useRouter(); const [rejectionReason, setRejectionReason] = useState(""); const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); + const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(false); const mutation = trpc.viewer.bookings.confirm.useMutation({ onSuccess: (data) => { if (data.status === BookingStatus.REJECTED) { @@ -112,6 +115,17 @@ function BookingListItem(booking: BookingItemProps) { }, ]; + const showRecordingActions: ActionType[] = [ + { + id: "view_recordings", + label: t("view_recordings"), + onClick: () => { + setViewRecordingsDialogIsOpen(true); + }, + disabled: mutation.isLoading, + }, + ]; + let bookedActions: ActionType[] = [ { id: "cancel", @@ -206,7 +220,7 @@ function BookingListItem(booking: BookingItemProps) { }, }); }; - + const showRecordingsButtons = booking.location === "integrations:daily" && isPast && isConfirmed; return ( <> - + {showRecordingsButtons && ( + + )} {/* NOTE: Should refactor this dialog component as is being rendered multiple times */} @@ -377,6 +398,7 @@ function BookingListItem(booking: BookingItemProps) { ) : null} {isPast && isPending && !isConfirmed ? : null} + {showRecordingsButtons && } {isCancelled && booking.rescheduled && (
diff --git a/apps/web/pages/api/download-cal-video-recording.ts b/apps/web/pages/api/download-cal-video-recording.ts new file mode 100644 index 0000000000..f912e4d1e9 --- /dev/null +++ b/apps/web/pages/api/download-cal-video-recording.ts @@ -0,0 +1,46 @@ +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 { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +const getAccessLinkSchema = z.union([ + z.object({ + download_link: z.string(), + expires: z.number(), + }), + z.object({}), +]); + +const requestQuery = z.object({ + recordingId: z.string(), +}); + +async function handler( + req: NextApiRequest, + res: NextApiResponse | void> +) { + const { recordingId } = requestQuery.parse(req.query); + const session = await getSession({ req }); + + // Check if user belong to active team + if (!session?.user?.belongsToActiveTeam) { + return res.status(403); + } + try { + const response = await fetcher(`/recordings/${recordingId}/access-link`).then(getAccessLinkSchema.parse); + + if ("download_link" in response && response.download_link) { + return res.status(200).json(response); + } + + return res.status(400); + } catch (err) { + res.status(500); + } +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7babd99be4..e3d4cae92d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -388,7 +388,7 @@ "further_billing_help": "If you need any further help with billing, our support team are here to help.", "contact": "Contact", "our_support_team": "our support team", - "contact_our_support_team":"Contact our support team", + "contact_our_support_team": "Contact our support team", "uh_oh": "Uh oh!", "no_event_types_have_been_setup": "This user hasn't set up any event types yet.", "edit_logo": "Edit logo", @@ -1091,6 +1091,10 @@ "navigate": "Navigate", "open": "Open", "close": "Close", + "upgrade": "Upgrade", + "upgrade_to_access_recordings_title": "Upgrade to access recordings", + "upgrade_to_access_recordings_description":"Recordings are only available as part of our teams plan. Upgrade to start recording your calls", + "recordings_are_part_of_the_teams_plan":"Recordings are part of the teams plan", "team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.", "team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.", "show_eventtype_on_profile": "Show on Profile", @@ -1148,8 +1152,11 @@ "confirm_delete_account_modal": "Are you sure you want to delete your {{appName}} account?", "delete_my_account": "Delete my account", "start_of_week": "Start of week", + "recordings_title": "Recordings", + "recording": "Recording", "select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.", "check_for_conflicts": "Check for conflicts", + "view_recordings": "View recordings", "adding_events_to": "Adding events to", "follow_system_preferences": "Follow system preferences", "custom_brand_colors": "Custom brand colors", @@ -1221,6 +1228,7 @@ "to": "To", "workflow_turned_on_successfully": "{{workflowName}} workflow turned {{offOn}} successfully", "download_responses": "Download Responses", + "download": "Download", "create_your_first_form": "Create your first form", "create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.", "create_your_first_webhook": "Create your first Webhook", diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 10378e3ccf..463bdec28e 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { handleErrorsJson } from "@calcom/lib/errors"; +import { GetRecordingsResponseSchema, getRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { CredentialPayload } from "@calcom/types/Credential"; import type { PartialReference } from "@calcom/types/EventManager"; @@ -62,7 +63,7 @@ export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = { invalid: false, }; -const fetcher = async (endpoint: string, init?: RequestInit | undefined) => { +export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => { const { api_key } = await getDailyAppKeys(); return fetch(`https://api.daily.co/v1${endpoint}`, { method: "GET", @@ -87,9 +88,8 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { if (!event.uid) { throw new Error("We need need the booking uid to create the Daily reference in DB"); } - const dailyEvent = await postToDailyAPI(endpoint, translateEvent(event)).then( - dailyReturnTypeSchema.parse - ); + const body = await translateEvent(event); + const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse); const meetingToken = await postToDailyAPI("/meeting-tokens", { properties: { room_name: dailyEvent.name, is_owner: true }, }).then(meetingTokenSchema.parse); @@ -102,11 +102,11 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { }); } - const translateEvent = (event: CalendarEvent) => { + const translateEvent = async (event: CalendarEvent) => { // Documentation at: https://docs.daily.co/reference#list-rooms // added a 1 hour buffer for room expiration const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60; - const scalePlan = process.env.DAILY_SCALE_PLAN; + const { scale_plan: scalePlan } = await getDailyAppKeys(); if (scalePlan === "true") { return { @@ -118,7 +118,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { enable_screenshare: true, enable_chat: true, exp: exp, - enable_recording: "local", + enable_recording: "cloud", }, }; } @@ -148,6 +148,16 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { }, updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise => createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event), + getRecordings: async (roomName: string): Promise => { + try { + const res = await fetcher(`/recordings?room_name=${roomName}`).then( + getRecordingsResponseSchema.parse + ); + return Promise.resolve(res); + } catch (err) { + throw new Error("Something went wrong! Unable to get recording"); + } + }, }; }; diff --git a/packages/app-store/dailyvideo/lib/getDailyAppKeys.ts b/packages/app-store/dailyvideo/lib/getDailyAppKeys.ts index adc04f7a36..e232f43654 100644 --- a/packages/app-store/dailyvideo/lib/getDailyAppKeys.ts +++ b/packages/app-store/dailyvideo/lib/getDailyAppKeys.ts @@ -4,6 +4,7 @@ import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; const dailyAppKeysSchema = z.object({ api_key: z.string(), + scale_plan: z.string().default("false"), }); export const getDailyAppKeys = async () => { diff --git a/packages/app-store/dailyvideo/zod.ts b/packages/app-store/dailyvideo/zod.ts index 461e857911..2774e0c0ec 100644 --- a/packages/app-store/dailyvideo/zod.ts +++ b/packages/app-store/dailyvideo/zod.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const appKeysSchema = z.object({ api_key: z.string().min(1), - scale_plan: z.string(), + scale_plan: z.string().default("false"), }); export const appDataSchema = z.object({}); diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 614d6cb27a..c808464a37 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -7,6 +7,7 @@ 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 { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar"; import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; @@ -167,4 +168,26 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => { return videoAdapter?.createMeeting(calEvent); }; -export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting }; +const getRecordingsOfCalVideoByRoomName = async ( + roomName: string +): Promise => { + let dailyAppKeys: Awaited>; + try { + dailyAppKeys = await getDailyAppKeys(); + } catch (e) { + return; + } + const [videoAdapter] = getVideoAdapters([ + { + id: 0, + appId: "daily-video", + type: "daily_video", + userId: null, + key: dailyAppKeys, + invalid: false, + }, + ]); + return videoAdapter?.getRecordings?.(roomName); +}; + +export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting, getRecordingsOfCalVideoByRoomName }; diff --git a/packages/features/ee/video/ViewRecordingsDialog.tsx b/packages/features/ee/video/ViewRecordingsDialog.tsx new file mode 100644 index 0000000000..ed2372efe1 --- /dev/null +++ b/packages/features/ee/video/ViewRecordingsDialog.tsx @@ -0,0 +1,164 @@ +import { useSession } from "next-auth/react"; +import { useState } from "react"; + +import dayjs from "@calcom/dayjs"; +import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired"; +import { WEBSITE_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { RecordingItemSchema } from "@calcom/prisma/zod-utils"; +import { RouterOutputs, trpc } from "@calcom/trpc/react"; +import type { PartialReference } from "@calcom/types/EventManager"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui"; +import { Button, showToast, Icon } from "@calcom/ui"; + +import RecordingListSkeleton from "./components/RecordingListSkeleton"; +import UpgradeRecordingBanner from "./components/UpgradeRecordingBanner"; + +type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; + +interface IViewRecordingsDialog { + booking?: BookingItem; + isOpenDialog: boolean; + setIsOpenDialog: React.Dispatch>; + timeFormat: number | null; +} + +function convertSecondsToMs(seconds: number) { + // Bitwise Double Not is faster than Math.floor + const minutes = ~~(seconds / 60); + const extraSeconds = seconds % 60; + return `${minutes}min ${extraSeconds}sec`; +} + +interface GetTimeSpanProps { + startTime: string | undefined; + endTime: string | undefined; + locale: string; + isTimeFormatAMPM: boolean; +} + +const getTimeSpan = ({ startTime, endTime, locale, isTimeFormatAMPM }: GetTimeSpanProps) => { + if (!startTime || !endTime) return ""; + + const formattedStartTime = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "numeric", + hour12: isTimeFormatAMPM, + }).format(new Date(startTime)); + + const formattedEndTime = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "numeric", + hour12: isTimeFormatAMPM, + }).format(new Date(endTime)); + + return `${formattedStartTime} - ${formattedEndTime}`; +}; + +export const ViewRecordingsDialog = (props: IViewRecordingsDialog) => { + const { t, i18n } = useLocale(); + const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props; + const [downloadingRecordingId, setRecordingId] = useState(null); + const session = useSession(); + const belongsToActiveTeam = session?.data?.user?.belongsToActiveTeam ?? false; + const [showUpgradeBanner, setShowUpgradeBanner] = useState(false); + const roomName = + 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(`${WEBSITE_URL}/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, + })} `; + + return ( + + + + + {showUpgradeBanner && } + {!showUpgradeBanner && ( + <> + {isLoading && } + {recordings && "data" in recordings && recordings?.data?.length > 0 && ( +
+ {recordings.data.map((recording: RecordingItemSchema, index: number) => { + return ( +
+
+

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

+

+ {convertSecondsToMs(recording.duration)} +

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

No Recordings Found

+ )} + + )} +
+ + setShowUpgradeBanner(false)} className="border" /> + +
+
+ ); +}; + +export default ViewRecordingsDialog; diff --git a/packages/features/ee/video/components/RecordingListItemSkeleton.tsx b/packages/features/ee/video/components/RecordingListItemSkeleton.tsx new file mode 100644 index 0000000000..700438f5b8 --- /dev/null +++ b/packages/features/ee/video/components/RecordingListItemSkeleton.tsx @@ -0,0 +1,19 @@ +import { SkeletonText, SkeletonButton } from "@calcom/ui"; + +export default function RecordingListItemSkeleton() { + return ( +
+
+

+ +

+
+ +
+
+
+ +
+
+ ); +} diff --git a/packages/features/ee/video/components/RecordingListSkeleton.tsx b/packages/features/ee/video/components/RecordingListSkeleton.tsx new file mode 100644 index 0000000000..56e938ddae --- /dev/null +++ b/packages/features/ee/video/components/RecordingListSkeleton.tsx @@ -0,0 +1,15 @@ +import { SkeletonContainer } from "@calcom/ui"; + +import RecordingListItemSkeleton from "./RecordingListItemSkeleton"; + +export default function RecordingListSkeleton() { + return ( + +
+ + + +
+
+ ); +} diff --git a/packages/features/ee/video/components/UpgradeRecordingBanner.tsx b/packages/features/ee/video/components/UpgradeRecordingBanner.tsx new file mode 100644 index 0000000000..802a112d6e --- /dev/null +++ b/packages/features/ee/video/components/UpgradeRecordingBanner.tsx @@ -0,0 +1,30 @@ +import { useRouter } from "next/router"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Icon, Button } from "@calcom/ui"; + +export default function UpgradeRecordingBanner() { + const { t } = useLocale(); + const router = useRouter(); + + return ( +
+ +
+
+

{t("upgrade_to_access_recordings_title")}

+

{t("upgrade_to_access_recordings_description")}

+
+
+ +
+
+
+ ); +} diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 83c86cae9e..b32708e155 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -246,6 +246,30 @@ export const customInputSchema = z.object({ export type CustomInputSchema = z.infer; +export const recordingItemSchema = z.object({ + id: z.string(), + room_name: z.string(), + start_ts: z.number(), + status: z.string(), + max_participants: z.number(), + duration: z.number(), + share_token: z.string(), +}); + +export const recordingItemsSchema = z.array(recordingItemSchema); + +export type RecordingItemSchema = z.infer; + +export const getRecordingsResponseSchema = z.union([ + z.object({ + total_count: z.number(), + data: recordingItemsSchema, + }), + z.object({}), +]); + +export type GetRecordingsResponseSchema = z.infer; + /** * Ensures that it is a valid HTTP URL * It automatically avoids diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index ff9b5e9c4d..fa1d0f5303 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -12,12 +12,14 @@ 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 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, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import getStripeAppData from "@calcom/lib/getStripeAppData"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; @@ -1138,10 +1140,58 @@ const loggedInViewerRouter = router({ }); return recurringGrouping.reduce((prev, current) => { // recurringEventId is the total number of recurring instances for a booking - // we need to substract all but one, to represent a single recurring booking + // we need to subtract all but one, to represent a single recurring booking return prev - (current._count?.recurringEventId - 1); }, count); }), + getCalVideoRecordings: authedProcedure + .input( + z.object({ + roomName: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const { roomName } = input; + let shouldHideRecordingsData = false; + // If self-hosted, he should be able to get recordings + if (IS_TEAM_BILLING_ENABLED) { + // If user is not a team member, throw error + const { hasTeamPlan } = await viewerTeamsRouter.createCaller(ctx).hasTeamPlan(); + if (!hasTeamPlan) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You are not a team plan.", + }); + } else { + shouldHideRecordingsData = true; + } + } + + try { + const res = await getRecordingsOfCalVideoByRoomName(roomName); + + if (shouldHideRecordingsData) { + if (res && "data" in res && res.data.length > 0) { + res.data = res.data.map((recording) => { + return { + id: "", + room_name: "", + start_ts: recording.start_ts, + status: recording.status, + max_participants: recording.max_participants, + duration: recording.duration, + share_token: recording.share_token, + }; + }); + } + } + return res; + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } + }), }); export const viewerRouter = mergeRouters( diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index dbf9c6b16a..fcb44990c5 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -279,6 +279,7 @@ export const bookingsRouter = router({ }, }, rescheduled: true, + references: true, }, orderBy, take: take + 1, diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx index 223e1845c3..a9674fdf59 100644 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ b/packages/trpc/server/routers/viewer/teams.tsx @@ -683,4 +683,18 @@ export const viewerTeamsRouter = router({ .reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap); return Object.values(users); }), + hasTeamPlan: authedProcedure.query(async ({ ctx }) => { + const userId = ctx.user.id; + const hasTeamPlan = await ctx.prisma.membership.findFirst({ + where: { + userId, + team: { + slug: { + not: null, + }, + }, + }, + }); + return { hasTeamPlan: !!hasTeamPlan }; + }), }); diff --git a/packages/trpc/server/routers/viewer/workflows.tsx b/packages/trpc/server/routers/viewer/workflows.tsx index 16bb01fe3f..772cf1230d 100644 --- a/packages/trpc/server/routers/viewer/workflows.tsx +++ b/packages/trpc/server/routers/viewer/workflows.tsx @@ -38,6 +38,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { TRPCError } from "@trpc/server"; import { router, authedProcedure, authedRateLimitedProcedure } from "../../trpc"; +import { viewerTeamsRouter } from "./teams"; function isSMSAction(action: WorkflowActions) { return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.SMS_NUMBER; @@ -1089,9 +1090,8 @@ export const workflowsRouter = router({ return verifiedNumbers; }), getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => { - const userId = ctx.user.id; - const hasTeamPlan = (await ctx.prisma.membership.count({ where: { userId } })) > 0; + const { hasTeamPlan } = await viewerTeamsRouter.createCaller(ctx).hasTeamPlan(); const t = await getTranslation(ctx.user.locale, "common"); - return getWorkflowActionOptions(t, hasTeamPlan); + return getWorkflowActionOptions(t, !!hasTeamPlan); }), }); diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts index d395a32bdf..abc74d45d1 100644 --- a/packages/types/VideoApiAdapter.d.ts +++ b/packages/types/VideoApiAdapter.d.ts @@ -1,3 +1,5 @@ +import { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; + import type { EventBusyDate } from "./Calendar"; import { CredentialPayload } from "./Credential"; @@ -18,6 +20,8 @@ export type VideoApiAdapter = deleteMeeting(uid: string): Promise; getAvailability(dateFrom?: string, dateTo?: string): Promise; + + getRecordings?(roomName: string): Promise; } | undefined;