feat: cal video settings (#21347)

* feat: cal video settings

* fix: var names

* feat: finish setting

* chore: add enabled

* chore: remove logs

* feat: support redirect url on exit

* feat: finish settings

* refactor: improve UI

* chore: remove logs

* fix: type err

* fix: finish redirect url

* fix: add type

* chore: change schema

* fix: type error

* chore: improvements

* chore: remove index

* feat: add organizer

* chore: remove log

* chore: use safe parse

* fix: add nullable

* refactor: hide toggles if platorm

* chore: update description

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
This commit is contained in:
Udit Takkar
2025-05-27 10:13:24 -04:00
committed by GitHub
co-authored by Alex van Andel supalarry
parent a1007398f6
commit ed4b3f082a
16 changed files with 329 additions and 23 deletions
@@ -94,6 +94,7 @@ const getEventTypes = async (userId: number, teamIds?: number[]) => {
userId: true,
destinationCalendar: true,
bookingFields: true,
calVideoSettings: true,
});
let eventTypeGroups: TEventTypeGroup[] | null = [];
+72 -5
View File
@@ -12,12 +12,62 @@ import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
import { isENVDev } from "@calcom/lib/env";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
};
const shouldEnableRecordButton = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan) return false;
if (!calVideoSettings) return true;
if (isOrganizer) {
return !calVideoSettings.disableRecordingForOrganizer;
}
return !calVideoSettings.disableRecordingForGuests;
};
const checkIfUserIsHost = async ({
booking,
sessionUserId,
}: {
booking: {
user: { id: number } | null;
eventTypeId: number | undefined;
};
sessionUserId?: number;
}) => {
if (!sessionUserId) return false;
if (!booking.eventTypeId) {
return booking.user?.id === sessionUserId;
}
const eventType = await EventTypeRepository.findByIdWithUserAccess({
id: booking.eventTypeId,
userId: sessionUserId,
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
@@ -107,13 +157,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
const sessionUserId = !!session?.user?.impersonatedBy ? session.user.impersonatedBy.id : session?.user.id;
const isOrganizer = await checkIfUserIsHost({
booking: {
eventTypeId: bookingObj.eventType?.id,
user: bookingObj.user,
},
sessionUserId,
});
// set meetingPassword for guests
if (sessionUserId !== bookingObj.user?.id) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
videoReferencePassword,
sessionUserId
);
if (!isOrganizer) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
meetingToken: videoReferencePassword,
userId: sessionUserId,
});
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = guestMeetingPassword;
@@ -141,6 +198,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
const showRecordingButton = shouldEnableRecordButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
});
return {
props: {
meetingUrl: videoReference.meetingUrl ?? "",
@@ -161,6 +224,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
calVideoLogo,
displayLogInOverlay,
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
},
};
}
@@ -37,7 +37,13 @@ import { STEPS } from "~/apps/installation/[[...step]]/constants";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<
EventTypeModel,
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
| "metadata"
| "schedulingType"
| "slug"
| "requiresConfirmation"
| "position"
| "destinationCalendar"
| "calVideoSettings"
> & {
selected: boolean;
locations: LocationFormValues["locations"];
@@ -3,6 +3,7 @@
import type { DailyCall } from "@daily-co/daily-js";
import DailyIframe from "@daily-co/daily-js";
import { DailyProvider } from "@daily-co/daily-react";
import { useDailyEvent } from "@daily-co/daily-react";
import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
@@ -33,6 +34,8 @@ export default function JoinCall(props: PageProps) {
calVideoLogo,
displayLogInOverlay,
loggedInUserName,
showRecordingButton,
rediectAttendeeToOnExit,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
@@ -65,12 +68,16 @@ export default function JoinCall(props: PageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
...(showRecordingButton
? {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
}
: {}),
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
@@ -126,7 +133,7 @@ export default function JoinCall(props: PageProps) {
</div>
{displayLogInOverlay && <LogInOverlay isLoggedIn={!!loggedInUserName} bookingUid={booking.uid} />}
<VideoMeetingInfo booking={booking} />
<VideoMeetingInfo booking={booking} rediectAttendeeToOnExit={rediectAttendeeToOnExit} />
</DailyProvider>
);
}
@@ -255,16 +262,23 @@ export function LogInOverlay(props: LogInOverlayProps) {
interface VideoMeetingInfo {
booking: PageProps["booking"];
rediectAttendeeToOnExit?: string | null;
}
export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false);
const { booking } = props;
const { booking, rediectAttendeeToOnExit } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
useDailyEvent("left-meeting", () => {
if (rediectAttendeeToOnExit) {
window.location.href = rediectAttendeeToOnExit;
}
});
return (
<>
<aside
@@ -44,6 +44,10 @@
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
"event_reassigned_subject": "Reassigned: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
"customize_calvideo_settings": "Customize Cal Video Settings",
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
"disable_recording_for_guests": "Disable recording for guests",
"disable_recording_for_organizer": "Disable recording for organizer",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
@@ -2006,6 +2010,7 @@
"add_a_booking_question": "Add a question",
"identifier": "Identifier",
"duplicate_email": "Email is duplicate",
"enter_redirect_url_on_exit_description": "Enter a redirect URL to which the attendee will be sent once they leave the meeting.",
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment is on its way.",
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
@@ -2911,6 +2916,7 @@
"routing_form_insights_booking_at": "Booking At",
"routing_form_insights_submitted_at": "Submitted At",
"copy_redirect_url": "Copy Redirect URL",
"redirect_url_on_exit": "Redirect URL on Exit",
"total_bookings_per_period": "Total Bookings per period",
"routed_to_per_period": "Routed to per period",
"per_day": "Per day",
@@ -146,10 +146,13 @@ export const updateMeetingTokenIfExpired = async ({
return meetingToken;
};
export const generateGuestMeetingTokenFromOwnerMeetingToken = async (
meetingToken: string | null,
userId?: number
) => {
export const generateGuestMeetingTokenFromOwnerMeetingToken = async ({
meetingToken,
userId,
}: {
meetingToken: string | null;
userId?: number;
}) => {
if (!meetingToken) return null;
const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse);
@@ -2,7 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ErrorMessage } from "@hookform/error-message";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { EventLocationType } from "@calcom/app-store/locations";
@@ -13,6 +13,7 @@ import type {
LocationFormValues,
EventTypeSetupProps,
CheckboxClassNames,
FormValues,
} from "@calcom/features/eventtypes/lib/types";
import CheckboxField from "@calcom/features/form/components/CheckboxField";
import type {
@@ -25,11 +26,13 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Input } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations">;
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations" | "calVideoSettings">;
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["locationOptions"];
export type TDestinationCalendar = { integration: string } | null;
export type TPrefillLocation = { credentialId?: number; type: string };
@@ -129,6 +132,8 @@ const Locations: React.FC<LocationsProps> = ({
name: "locations",
});
const formMethods = useFormContext<FormValues>();
const locationOptions = props.locationOptions.map((locationOption) => {
const options = locationOption.options.filter((option) => {
// Skip "Organizer's Default App" for non-team members
@@ -244,7 +249,7 @@ const Locations: React.FC<LocationsProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefillLocation, seatsEnabled]);
const isPlatorm = useIsPlatform();
const isPlatform = useIsPlatform();
return (
<div className={classNames("w-full", customClassNames?.container)}>
@@ -253,6 +258,8 @@ const Locations: React.FC<LocationsProps> = ({
const eventLocationType = getEventLocationType(field.type);
const defaultLocation = field;
const isCalVideo = field.type === "integrations:daily";
const option = getLocationFromType(field.type, locationOptions);
return (
<li key={field.id}>
@@ -315,7 +322,9 @@ const Locations: React.FC<LocationsProps> = ({
data-testid={`delete-locations.${index}.type`}
className={classNames("min-h-9 block h-9 px-2", customClassNames?.removeLocationButton)}
type="button"
onClick={() => remove(index)}
onClick={() => {
remove(index);
}}
aria-label={t("remove")}>
<div className="h-4 w-4">
<Icon
@@ -330,6 +339,64 @@ const Locations: React.FC<LocationsProps> = ({
)}
</div>
{isCalVideo && (
<div className="bg-muted mt-2 space-y-2 rounded-lg p-4">
<div className="w-full">
<div className="flex flex-col gap-2">
{!isPlatform && (
<Controller
name="calVideoSettings.disableRecordingForGuests"
defaultValue={!!eventType.calVideoSettings?.disableRecordingForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_guests")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
/>
);
}}
/>
)}
{!isPlatform && (
<Controller
name="calVideoSettings.disableRecordingForOrganizer"
defaultValue={!!eventType.calVideoSettings?.disableRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_organizer")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
/>
);
}}
/>
)}
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={eventType.calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-2"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})}
/>
<ErrorMessage
errors={formMethods.formState.errors?.calVideoSettings}
name="redirectUrlOnExit"
className={classNames("text-error text-sm")}
as="div"
id="calVideoSettings.redirectUrlOnExit-error"
/>
</div>
</div>
</div>
)}
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
<div className="w-full">
@@ -482,7 +549,7 @@ const Locations: React.FC<LocationsProps> = ({
</li>
)}
</ul>
{props.showAppStoreLink && !isPlatorm && (
{props.showAppStoreLink && !isPlatform && (
<p className="text-default mt-2 text-sm">
<ServerTrans
t={t}
@@ -151,6 +151,11 @@ export type FormValues = {
secondaryEmailId?: number;
isRRWeightsEnabled: boolean;
maxLeadThreshold?: number;
calVideoSettings?: {
disableRecordingForOrganizer?: boolean;
disableRecordingForGuests?: boolean;
redirectUrlOnExit?: string;
};
};
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
@@ -374,7 +374,15 @@ export class BookingRepository {
isRecorded: true,
eventType: {
select: {
id: true,
hideOrganizerEmail: true,
calVideoSettings: {
select: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
redirectUrlOnExit: true,
},
},
},
},
user: {
@@ -0,0 +1,37 @@
import prisma from "@calcom/prisma";
export class CalVideoSettingsRepository {
static async deleteCalVideoSettings(eventTypeId: number) {
return await prisma.calVideoSettings.delete({
where: { eventTypeId },
});
}
static async createOrUpdateCalVideoSettings({
eventTypeId,
calVideoSettings,
}: {
eventTypeId: number;
calVideoSettings: {
disableRecordingForGuests?: boolean | null;
disableRecordingForOrganizer?: boolean | null;
redirectUrlOnExit?: string | null;
};
}) {
return await prisma.calVideoSettings.upsert({
where: { eventTypeId },
update: {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
updatedAt: new Date(),
},
create: {
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
eventTypeId,
},
});
}
}
@@ -453,6 +453,15 @@ export class EventTypeRepository {
});
}
static async findByIdWithUserAccess({ id, userId }: { id: number; userId: number }) {
return await prisma.eventType.findUnique({
where: {
id,
OR: [{ userId }, { hosts: { some: { userId } } }, { users: { some: { id: userId } } }],
},
});
}
static async findById({ id, userId }: { id: number; userId: number }) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
@@ -677,6 +686,13 @@ export class EventTypeRepository {
maxLeadThreshold: true,
includeNoShowInRRCalculation: true,
useEventLevelSelectedCalendars: true,
calVideoSettings: {
select: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
redirectUrlOnExit: true,
},
},
});
// This is more efficient than using a complex join with team.members in the query
@@ -130,6 +130,7 @@ export const useEventTypeForm = ({
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
useEventLevelSelectedCalendars: eventType.useEventLevelSelectedCalendars,
customReplyToEmail: eventType.customReplyToEmail || null,
calVideoSettings: eventType.calVideoSettings,
};
}, [eventType, periodDates]);
@@ -162,6 +163,14 @@ export const useEventTypeForm = ({
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
bookingFields: eventTypeBookingFieldsSchema,
locations: locationsResolver(t),
calVideoSettings: z
.object({
redirectUrlOnExit: z.string().url().nullish(),
disableRecordingForOrganizer: z.boolean().nullable(),
disableRecordingForGuests: z.boolean().nullable(),
})
.optional()
.nullable(),
})
// TODO: Add schema for other fields later.
.passthrough()
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "CalVideoSettings" (
"eventTypeId" INTEGER NOT NULL,
"disableRecordingForOrganizer" BOOLEAN NOT NULL DEFAULT false,
"disableRecordingForGuests" BOOLEAN NOT NULL DEFAULT false,
"redirectUrlOnExit" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalVideoSettings_pkey" PRIMARY KEY ("eventTypeId")
);
-- AddForeignKey
ALTER TABLE "CalVideoSettings" ADD CONSTRAINT "CalVideoSettings_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+13
View File
@@ -68,6 +68,18 @@ model Host {
@@index([scheduleId])
}
model CalVideoSettings {
eventTypeId Int @id
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
disableRecordingForOrganizer Boolean @default(false)
disableRecordingForGuests Boolean @default(false)
redirectUrlOnExit String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EventType {
id Int @id @default(autoincrement())
/// @zod.min(1)
@@ -176,6 +188,7 @@ model EventType {
hideOrganizerEmail Boolean @default(false)
/// @zod.custom(imports.emailSchema)
customReplyToEmail String?
calVideoSettings CalVideoSettings?
/// @zod.custom(imports.eventTypeColor)
eventTypeColor Json?
@@ -25,6 +25,15 @@ const aiPhoneCallConfig = z
})
.optional();
const calVideoSettingsSchema = z
.object({
disableRecordingForGuests: z.boolean().optional().nullable(),
disableRecordingForOrganizer: z.boolean().optional().nullable(),
redirectUrlOnExit: z.string().url().optional().nullable(),
})
.optional()
.nullable();
const hostSchema = z.object({
userId: z.number(),
profileId: z.number().or(z.null()).optional(),
@@ -51,6 +60,7 @@ const BaseEventTypeUpdateInput = _EventTypeModel
instantMeetingParameters: z.array(z.string()),
instantMeetingExpiryTimeOffsetInSeconds: z.number(),
aiPhoneCallConfig,
calVideoSettings: calVideoSettingsSchema,
calAiPhoneScript: z.string(),
customInputs: z.array(customInputSchema),
destinationCalendar: _DestinationCalendarModel
@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
import type { NextApiResponse, GetServerSidePropsContext } from "next";
import type { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
import { DailyLocationType } from "@calcom/app-store/locations";
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
import {
allowDisablingAttendeeConfirmationEmails,
@@ -11,11 +12,13 @@ import tasker from "@calcom/features/tasker";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CalVideoSettingsRepository } from "@calcom/lib/server/repository/calVideoSettings";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
import { SchedulingType, EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import { eventTypeAppMetadataOptionalSchema } from "@calcom/prisma/zod-utils";
import { eventTypeLocations } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -83,6 +86,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
description: newDescription,
title: newTitle,
seatsPerTimeSlot,
calVideoSettings,
...rest
} = input;
@@ -90,6 +94,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
where: { id },
select: {
title: true,
locations: true,
description: true,
seatsPerTimeSlot: true,
recurringEvent: true,
@@ -115,6 +120,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
llmId: true,
},
},
calVideoSettings: {
select: {
disableRecordingForOrganizer: true,
disableRecordingForGuests: true,
redirectUrlOnExit: true,
},
},
children: {
select: {
userId: true,
@@ -518,6 +530,24 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
}
if (calVideoSettings) {
await CalVideoSettingsRepository.createOrUpdateCalVideoSettings({
eventTypeId: id,
calVideoSettings,
});
}
const parsedEventTypeLocations = eventTypeLocations.safeParse(eventType.locations ?? []);
const isCalVideoLocationActive = locations
? locations.some((location) => location.type === DailyLocationType)
: parsedEventTypeLocations.success &&
parsedEventTypeLocations.data?.some((location) => location.type === DailyLocationType);
if (eventType.calVideoSettings && !isCalVideoLocationActive) {
await CalVideoSettingsRepository.deleteCalVideoSettings(id);
}
// Logic for updating `fieldTranslations`
// user has no translations OR user is changing the field
const hasNoDescriptionTranslations =