Cal Video (Daily) Recording (#6039)
* feat: wip Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * feat: add download recording button Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * moved video recording into /ee/, wrapped in LicenseRequired * fix: security issues in downloading recording updates designs Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * feat: add upgradation banner Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * chore: remove default room Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * chore: fix type error Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * fix: add type in get recording Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * fix: add suggestions and zod type for recording Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * fix: add types in getRecordings 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> * fix: type error Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * fix: finally all type errors fixed Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * Server side validation for users in team plans for recordings * fix: remove any type 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> Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Alan <alannnc@gmail.com>
This commit is contained in:
parent
3f822e814b
commit
bcf5fb18c5
|
@ -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<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(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 (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
|
@ -220,7 +234,14 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
/>
|
||||
|
||||
{showRecordingsButtons && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={user?.timeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent>
|
||||
|
@ -377,6 +398,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
|
|
|
@ -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<z.infer<typeof getAccessLinkSchema> | 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) }),
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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<VideoCallData> =>
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||
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");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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<GetRecordingsResponseSchema | undefined> => {
|
||||
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
|
||||
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 };
|
||||
|
|
|
@ -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<React.SetStateAction<boolean>>;
|
||||
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<string | null>(null);
|
||||
const session = useSession();
|
||||
const belongsToActiveTeam = session?.data?.user?.belongsToActiveTeam ?? false;
|
||||
const [showUpgradeBanner, setShowUpgradeBanner] = useState<boolean>(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 (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("recordings_title")} subtitle={subtitle} />
|
||||
<LicenseRequired>
|
||||
{showUpgradeBanner && <UpgradeRecordingBanner />}
|
||||
{!showUpgradeBanner && (
|
||||
<>
|
||||
{isLoading && <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 py-2 px-4"
|
||||
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>
|
||||
{belongsToActiveTeam ? (
|
||||
<Button
|
||||
StartIcon={Icon.FiDownload}
|
||||
className="ml-4 lg:ml-0"
|
||||
loading={downloadingRecordingId === recording.id}
|
||||
onClick={() => handleDownloadClick(recording.id)}>
|
||||
{t("download")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="secondary"
|
||||
tooltip={t("recordings_are_part_of_the_teams_plan")}
|
||||
className="ml-4 lg:ml-0"
|
||||
onClick={() => setShowUpgradeBanner(true)}>
|
||||
{t("upgrade")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
(!recordings ||
|
||||
(recordings && "total_count" in recordings && recordings?.total_count === 0)) && (
|
||||
<h1 className="font-semibold">No Recordings Found</h1>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
<DialogFooter>
|
||||
<DialogClose onClick={() => setShowUpgradeBanner(false)} className="border" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewRecordingsDialog;
|
|
@ -0,0 +1,19 @@
|
|||
import { SkeletonText, SkeletonButton } from "@calcom/ui";
|
||||
|
||||
export default function RecordingListItemSkeleton() {
|
||||
return (
|
||||
<div className="flex w-full justify-between rounded-md border p-2">
|
||||
<div>
|
||||
<p className="pt-2 text-sm font-medium text-gray-900">
|
||||
<SkeletonText className="h-3 w-56" />
|
||||
</p>
|
||||
<div className="mt-1 w-max">
|
||||
<SkeletonText className="h-3 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SkeletonButton className="h-5 w-28 rounded-md px-4 py-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { SkeletonContainer } from "@calcom/ui";
|
||||
|
||||
import RecordingListItemSkeleton from "./RecordingListItemSkeleton";
|
||||
|
||||
export default function RecordingListSkeleton() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="flex flex-col gap-3">
|
||||
<RecordingListItemSkeleton />
|
||||
<RecordingListItemSkeleton />
|
||||
<RecordingListItemSkeleton />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div className="flex items-start gap-2 rounded-md bg-gray-100 p-4">
|
||||
<Icon.FiUsers className="dark:bg-gray-90 inline-block h-5 w-5" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold">{t("upgrade_to_access_recordings_title")}</h2>
|
||||
<p className="text-sm font-normal">{t("upgrade_to_access_recordings_description")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push(`${WEBAPP_URL}/teams`);
|
||||
}}>
|
||||
{t("upgrade_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -246,6 +246,30 @@ export const customInputSchema = z.object({
|
|||
|
||||
export type CustomInputSchema = z.infer<typeof customInputSchema>;
|
||||
|
||||
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<typeof recordingItemSchema>;
|
||||
|
||||
export const getRecordingsResponseSchema = z.union([
|
||||
z.object({
|
||||
total_count: z.number(),
|
||||
data: recordingItemsSchema,
|
||||
}),
|
||||
z.object({}),
|
||||
]);
|
||||
|
||||
export type GetRecordingsResponseSchema = z.infer<typeof getRecordingsResponseSchema>;
|
||||
|
||||
/**
|
||||
* Ensures that it is a valid HTTP URL
|
||||
* It automatically avoids
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -279,6 +279,7 @@ export const bookingsRouter = router({
|
|||
},
|
||||
},
|
||||
rescheduled: true,
|
||||
references: true,
|
||||
},
|
||||
orderBy,
|
||||
take: take + 1,
|
||||
|
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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<unknown>;
|
||||
|
||||
getAvailability(dateFrom?: string, dateTo?: string): Promise<EventBusyDate[]>;
|
||||
|
||||
getRecordings?(roomName: string): Promise<GetRecordingsResponseSchema>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user