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:
Udit Takkar 2022-12-28 02:33:39 +05:30 committed by GitHub
parent 3f822e814b
commit bcf5fb18c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 448 additions and 17 deletions

View File

@ -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 />

View File

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

View File

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

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(

View File

@ -279,6 +279,7 @@ export const bookingsRouter = router({
},
},
rescheduled: true,
references: true,
},
orderBy,
take: take + 1,

View File

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

View File

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

View File

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