feat: link email to participant (requireEmailForGuests) (#24661)

* feat: link email to participatn

* fix: bugs

* refactor: improve code

* refactor: prevent repload

* chore: remove unued

* fix: type

* refactor

* fix: type

* feat: restrict host

* feat: type

* feat: tests

* fix: don't allow guest

* fix: merk guest

* fix: bugs

* fix: test

* fix: test

* refactor: feedback

* fix: tests

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
This commit is contained in:
Udit Takkar
2025-11-05 11:15:41 +00:00
committed by GitHub
co-authored by Volnei Munhoz
parent bf0fbe4888
commit c196ff1095
28 changed files with 618 additions and 155 deletions
+4 -5
View File
@@ -6,6 +6,7 @@ import type { NextRequest } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -17,13 +18,11 @@ async function handler(req: NextRequest) {
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
}
const bookingData = bookingCancelWithCsrfSchema.parse(appDirRequestBody);
const cookieStore = await cookies();
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
if (!cookieToken || cookieToken !== bookingData.csrfToken) {
return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 });
const csrfError = await validateCsrfToken(bookingData.csrfToken);
if (csrfError) {
return csrfError;
}
cookieStore.delete("calcom.csrf_token");
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
@@ -0,0 +1,94 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { z } from "zod";
import {
generateGuestMeetingTokenFromOwnerMeetingToken,
updateMeetingTokenIfExpired,
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { VideoCallGuestRepository } from "@calcom/features/video-call-guest/repositories/VideoCallGuestRepository";
import prisma from "@calcom/prisma";
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
const videoCallGuestWithCsrfSchema = z.object({
bookingUid: z.string(),
email: z.string().email(),
name: z.string().min(1),
csrfToken: z.string().length(64, "Invalid CSRF token"),
});
async function handler(req: NextRequest) {
let appDirRequestBody;
try {
appDirRequestBody = await req.json();
} catch {
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
}
const guestData = videoCallGuestWithCsrfSchema.parse(appDirRequestBody);
const csrfError = await validateCsrfToken(guestData.csrfToken);
if (csrfError) {
return csrfError;
}
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingIncludeCalVideoSettingsAndReferences({
bookingUid: guestData.bookingUid,
});
if (!booking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
const { hosts, guests } = getHostsAndGuests(booking);
const isHost = hosts.some((host) => host.email.toLowerCase() === guestData.email.trim().toLowerCase());
const isGuest = guests.some((guest) => guest.email.toLowerCase() === guestData.email.trim().toLowerCase());
if (isHost) {
return NextResponse.json({ error: "hosts_must_use_login" }, { status: 403 });
} else if (!isGuest) {
return NextResponse.json({ error: "invalid_guest_email" }, { status: 403 });
}
const videoCallGuestRepo = new VideoCallGuestRepository(prisma);
const guestSession = await videoCallGuestRepo.upsertVideoCallGuest({
bookingUid: guestData.bookingUid,
email: guestData.email,
name: guestData.name,
});
const videoReference = getCalVideoReference(booking.references);
if (!videoReference) {
return NextResponse.json({ error: "Video reference not found" }, { status: 404 });
}
const endTime = new Date(booking.endTime);
const fourteenDaysAfter = new Date(endTime.getTime() + 14 * 24 * 60 * 60 * 1000);
const epochTimeFourteenDaysAfter = Math.floor(fourteenDaysAfter.getTime() / 1000);
const videoReferencePassword = await updateMeetingTokenIfExpired({
bookingReferenceId: videoReference.id,
roomName: videoReference.uid,
meetingToken: videoReference.meetingPassword,
exp: epochTimeFourteenDaysAfter,
});
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
meetingToken: videoReferencePassword,
userId: guestSession.id,
});
return NextResponse.json({
guestSessionId: guestSession.id,
meetingPassword: guestMeetingPassword,
meetingUrl: videoReference.meetingUrl,
});
}
export const POST = defaultResponderForAppDir(handler);
+13
View File
@@ -0,0 +1,13 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function validateCsrfToken(csrfToken: string): Promise<NextResponse | null> {
const cookieStore = await cookies();
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
if (!cookieToken || cookieToken !== csrfToken) {
return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 });
}
cookieStore.delete("calcom.csrf_token");
return null;
}
+14 -10
View File
@@ -10,7 +10,6 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
@@ -26,6 +25,7 @@ type CalVideoSettings = {
enableAutomaticRecordingForOrganizer: boolean;
disableTranscriptionForGuests: boolean;
disableTranscriptionForOrganizer: boolean;
requireEmailForGuests: boolean;
};
const shouldEnableRecordButton = ({
@@ -124,7 +124,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingForMeetingPage({
const booking = await bookingRepo.findBookingIncludeCalVideoSettingsAndReferences({
bookingUid: context.query.uid as string,
});
@@ -211,6 +211,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
const sessionUserId = session?.user?.impersonatedBy ? session.user.impersonatedBy.id : session?.user.id;
const sessionUserEmail = session?.user?.email;
const isOrganizer = await checkIfUserIsHost({
booking: {
eventTypeId: bookingObj.eventType?.id,
@@ -219,18 +220,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
sessionUserId,
});
const isAttendee = sessionUserEmail
? bookingObj.attendees?.some(
(attendee) => attendee.email.toLowerCase() === sessionUserEmail.toLowerCase()
) ?? false
: false;
// set meetingPassword for guests
if (!isOrganizer) {
const userIdForToken = sessionUserId;
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
meetingToken: videoReferencePassword,
userId: sessionUserId,
userId: userIdForToken,
});
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = guestMeetingPassword;
});
}
// Only for backward compatibility and setting user id in participants for organizer
else {
const meetingPassword = await setEnableRecordingUIAndUserIdForOrganizer(
@@ -247,11 +255,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const videoReference = getCalVideoReference(bookingObj.references);
const featureRepo = new FeaturesRepository(prisma);
const displayLogInOverlay = profile?.organizationId
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
const showRecordingButton = shouldEnableRecordButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
@@ -293,7 +296,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
hasTeamPlan: !!hasTeamPlan,
calVideoLogo,
displayLogInOverlay,
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
enableAutomaticTranscription,
@@ -303,6 +305,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? undefined
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
overrideName: Array.isArray(context.query.name) ? context.query.name[0] : context.query.name,
requireEmailForGuests: bookingObj.eventType?.calVideoSettings?.requireEmailForGuests ?? false,
isLoggedInUserPartOfMeeting: isAttendee || isOrganizer,
},
};
}
@@ -11,6 +11,7 @@ import { WEBSITE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/dayjs";
import { emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
@@ -34,7 +35,6 @@ export default function JoinCall(props: PageProps) {
booking,
hasTeamPlan,
calVideoLogo,
displayLogInOverlay,
loggedInUserName,
overrideName,
showRecordingButton,
@@ -42,16 +42,27 @@ export default function JoinCall(props: PageProps) {
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
rediectAttendeeToOnExit,
requireEmailForGuests,
isLoggedInUserPartOfMeeting,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
const [userNameForCall, setUserNameForCall] = useState<string | undefined>(
overrideName ?? loggedInUserName ?? undefined
);
const [isUserNameConfirmed, setIsUserNameConfirmed] = useState<boolean>(!displayLogInOverlay);
const [guestCredentials, setGuestCredentials] = useState<{
meetingPassword: string;
meetingUrl: string;
userName: string;
} | null>(null);
const userNameForCall = overrideName ?? loggedInUserName ?? undefined;
const hideLoginModal =
!!userNameForCall && (requireEmailForGuests ? !!loggedInUserName && isLoggedInUserPartOfMeeting : true);
const [isCallFrameReady, setIsCallFrameReady] = useState<boolean>(false);
const activeMeetingPassword = guestCredentials?.meetingPassword ?? meetingPassword;
const activeMeetingUrl = guestCredentials?.meetingUrl ?? meetingUrl;
const activeUserName = guestCredentials?.userName ?? userNameForCall;
const createCallFrame = useCallback(
(userName?: string) => {
(userName?: string, password?: string, url?: string) => {
let callFrame: DailyCall | undefined;
try {
@@ -76,9 +87,9 @@ export default function JoinCall(props: PageProps) {
width: "100%",
height: "100%",
},
url: meetingUrl,
url: url ?? meetingUrl,
userName: userName,
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(typeof (password ?? meetingPassword) === "string" && { token: password ?? meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
...(showRecordingButton
@@ -116,16 +127,15 @@ export default function JoinCall(props: PageProps) {
},
[meetingUrl, meetingPassword, hasTeamPlan, showRecordingButton, showTranscriptionButton, t]
);
useEffect(() => {
if (displayLogInOverlay && !loggedInUserName && !overrideName && !isUserNameConfirmed) {
if (!hideLoginModal && !guestCredentials) {
return;
}
let callFrame: DailyCall | null = null;
try {
callFrame = createCallFrame(userNameForCall) ?? null;
callFrame = createCallFrame(activeUserName, activeMeetingPassword, activeMeetingUrl) ?? null;
setDaily(callFrame);
setIsCallFrameReady(true);
@@ -146,28 +156,16 @@ export default function JoinCall(props: PageProps) {
setIsCallFrameReady(false);
};
}, [
displayLogInOverlay,
isUserNameConfirmed,
userNameForCall,
hideLoginModal,
activeUserName,
activeMeetingPassword,
activeMeetingUrl,
createCallFrame,
loggedInUserName,
overrideName,
guestCredentials,
]);
const handleJoinAsGuest = useCallback((guestName: string) => {
const trimmedName = guestName.trim();
if (!trimmedName) {
return;
}
setUserNameForCall(trimmedName);
setIsUserNameConfirmed(true);
}, []);
const handleUserNameConfirmed = useCallback(() => {
setIsUserNameConfirmed(true);
}, []);
return (
<DailyProvider callObject={daily}>
{isCallFrameReady && (
@@ -205,14 +203,16 @@ export default function JoinCall(props: PageProps) {
/>
)}
</div>
{displayLogInOverlay && !isUserNameConfirmed && (
{!hideLoginModal && (
<LogInOverlay
isLoggedIn={!!loggedInUserName}
isOpen={!hideLoginModal}
bookingUid={booking.uid}
loggedInUserName={loggedInUserName ?? undefined}
overrideName={overrideName}
onJoinAsGuest={handleJoinAsGuest}
onUserNameConfirmed={handleUserNameConfirmed}
requireEmailForGuests={requireEmailForGuests}
onGuestCredentialsReceived={setGuestCredentials}
meetingPassword={meetingPassword}
meetingUrl={meetingUrl}
/>
)}
@@ -291,46 +291,125 @@ function ProgressBar(props: ProgressBarProps) {
}
interface LogInOverlayProps {
isLoggedIn: boolean;
isOpen: boolean;
bookingUid: string;
loggedInUserName?: string;
overrideName?: string;
onJoinAsGuest: (guestName: string) => void;
onUserNameConfirmed?: () => void;
requireEmailForGuests?: boolean;
onGuestCredentialsReceived: (credentials: {
meetingPassword: string;
meetingUrl: string;
userName: string;
}) => void;
meetingPassword?: string;
meetingUrl: string;
}
export function LogInOverlay(props: LogInOverlayProps) {
const { t } = useLocale();
const { bookingUid, isLoggedIn, loggedInUserName, overrideName, onJoinAsGuest, onUserNameConfirmed } =
props;
const {
bookingUid,
isOpen: _open,
loggedInUserName,
overrideName,
requireEmailForGuests = false,
onGuestCredentialsReceived,
meetingPassword,
meetingUrl,
} = props;
const [isOpen, setIsOpen] = useState(!isLoggedIn);
const [isOpen, setIsOpen] = useState(_open);
const [userName, setUserName] = useState(overrideName ?? loggedInUserName ?? "");
const [userEmail, setUserEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleContinueAsGuest = useCallback(async () => {
const trimmedName = userName.trim();
const trimmedEmail = userEmail.trim();
if (!trimmedName) {
setError(t("please_enter_name"));
return;
}
if (requireEmailForGuests) {
if (!trimmedEmail) {
setError(t("please_enter_name_and_email"));
return;
}
if (!emailRegex.test(trimmedEmail)) {
setError(t("invalid_email_address"));
return;
}
}
setIsLoading(true);
setError(null);
try {
onJoinAsGuest(trimmedName);
onUserNameConfirmed?.();
setIsOpen(false);
} catch (error) {
console.error("Error joining as guest:", error);
const errorMessage = error instanceof Error ? error.message : t("failed_to_join_call");
// Only create guest session if email is required and provided
if (requireEmailForGuests && trimmedEmail) {
const csrfResponse = await fetch("/api/csrf", { cache: "no-store" });
const { csrfToken } = await csrfResponse.json();
const response = await fetch("/api/video/guest-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bookingUid,
email: trimmedEmail,
name: trimmedName,
csrfToken,
}),
});
if (!response.ok) {
const errorData = await response.json();
const errorKey = errorData.error;
throw new Error(errorKey || "Failed to create guest session");
}
const { meetingPassword, meetingUrl } = await response.json();
onGuestCredentialsReceived({
meetingPassword,
meetingUrl,
userName: trimmedName,
});
setIsOpen(false);
} else {
// If email not required, use existing credentials from SSR props
if (!meetingPassword) {
throw new Error("Meeting password not available");
}
onGuestCredentialsReceived({
meetingPassword,
meetingUrl,
userName: trimmedName,
});
setIsOpen(false);
}
} catch (error) {
const errorKey = error instanceof Error ? error.message : "failed_to_join_call";
const errorMessage = t(errorKey) || errorKey;
setError(errorMessage);
} finally {
setIsLoading(false);
}
}, [userName, onJoinAsGuest, onUserNameConfirmed, t]);
}, [
userName,
userEmail,
bookingUid,
requireEmailForGuests,
t,
onGuestCredentialsReceived,
meetingPassword,
meetingUrl,
]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -351,38 +430,81 @@ export function LogInOverlay(props: LogInOverlayProps) {
[error]
);
const handleEmailChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setUserEmail(e.target.value);
if (error) {
setError(null);
}
},
[error]
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent
title={t("join_video_call")}
description={t("choose_how_you_d_like_to_appear_on_the_call")}
className="bg-white text-black dark:bg-black dark:text-white sm:max-w-[480px]">
<div className="pb-4">
className="bg-black/80 p-6 sm:max-w-lg">
<div className="mt-2 pb-4">
<div className="space-y-4">
<div>
<div className="font-semibold">{t("join_as_guest")}</div>
<p className="text-subtle text-sm">{t("ideal_for_one_time_calls")}</p>
</div>
<div className="flex gap-4">
<Input
type="text"
placeholder={t("your_name")}
className="w-full flex-1"
value={userName}
onChange={handleUserNameChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
autoFocus
/>
<Button color="secondary" onClick={handleContinueAsGuest} loading={isLoading}>
{t("continue")}
</Button>
</div>
{requireEmailForGuests ? (
<div className="flex flex-col gap-4">
<Input
type="text"
placeholder={t("your_name")}
className="w-full flex-1"
value={userName}
onChange={handleUserNameChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
autoFocus
/>
<Input
type="email"
placeholder={t("email_address")}
className="w-full flex-1"
value={userEmail}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
<Button
color="secondary"
className="w-fit self-end"
onClick={handleContinueAsGuest}
loading={isLoading}>
{t("continue")}
</Button>
</div>
) : (
<div className="flex gap-4">
<Input
type="text"
placeholder={t("your_name")}
className="w-full flex-1"
value={userName}
onChange={handleUserNameChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
autoFocus
/>
<Button color="secondary" onClick={handleContinueAsGuest} loading={isLoading}>
{t("continue")}
</Button>
</div>
)}
</div>
{error && (
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<div className="mt-4 rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<div className="flex">
<div className="ml-3">
<p className="text-sm font-medium text-red-800 dark:text-red-200">{error}</p>
@@ -392,7 +514,7 @@ export function LogInOverlay(props: LogInOverlayProps) {
)}
{/* Divider */}
<hr className="my-5 h-0.5 border-t-0 bg-neutral-100 dark:bg-white/10" />
<hr className="my-6 h-0.5 border-t-0 bg-neutral-100 dark:bg-white/10" />
<div className="mt-5 space-y-4">
<div>
@@ -487,7 +609,6 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
<div
className="prose-sm prose prose-invert"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(booking.description) }}
/>
</>
@@ -67,6 +67,8 @@
"disable_recording_for_organizer": "Disable recording for organizer",
"disable_transcription_for_guests": "Disable transcription for guests",
"disable_transcription_for_organizer": "Disable transcription for organizer",
"require_email_for_guests": "Require email for guests",
"require_email_for_guests_description": "When enabled, guests must provide their email address to join the call",
"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",
@@ -680,6 +682,8 @@
"continue_as_guest": "Continue as Guest",
"join_as_guest": "Join as Guest",
"ideal_for_one_time_calls": "Ideal for one-time calls",
"hosts_must_use_login": "Hosts must login to cal.com and then join this meeting",
"invalid_guest_email": "We don't recognize this email address for this meeting. Double-check you're using the same email that received the invitation.",
"any_location": "Any location",
"yes": "Yes",
"no": "No",
@@ -1453,6 +1457,8 @@
"track_your_meetings": "Track your meetings, get notifications, and manage your schedule more effectively.",
"track_meetings_and_manage_schedule": "Track your meetings and manage your schedule more effectively",
"failed_to_join_call": "Failed to join the call. Please try again.",
"please_enter_name": "Please enter your name",
"please_enter_name_and_email": "Please enter your name and email address",
"choose_how_you_d_like_to_join_call": "Choose how you'd like to join this call",
"choose_how_you_d_like_to_appear_on_the_call": "Choose how you'd like to appear on the call",
"new_event_trigger": "when new event is booked",
@@ -202,10 +202,9 @@ export const generateGuestMeetingTokenFromOwnerMeetingToken = async ({
userId,
}: {
meetingToken: string | null;
userId?: number;
userId?: number | string;
}) => {
if (!meetingToken) return null;
const token = await fetcher(`/meeting-tokens/${meetingToken}`).then(ZGetMeetingTokenResponseSchema.parse);
const guestMeetingToken = await postToDailyAPI("/meeting-tokens", {
properties: {
@@ -0,0 +1,52 @@
export type Host = {
id: number;
email: string;
};
export type Guest = {
email: string;
name: string;
};
type BookingInput = {
attendees?: { email: string; name: string }[] | null;
user?: { id: number; email: string } | null;
eventType?: {
hosts?: { userId: number; user: { email: string } }[];
users?: { id: number; email: string }[];
} | null;
};
export function getHostsAndGuests(booking: BookingInput): { hosts: Host[]; guests: Guest[] } {
const hostMap = new Map<number, Host>();
const addHost = (id: number, email: string) => {
if (!hostMap.has(id)) {
hostMap.set(id, { id, email });
}
};
booking?.eventType?.hosts?.forEach((host) => addHost(host.userId, host.user.email));
booking?.eventType?.users?.forEach((user) => addHost(user.id, user.email));
if (booking?.user?.id && booking?.user?.email) {
addHost(booking.user.id, booking.user.email);
}
const attendeeEmails = new Set(booking.attendees?.map((attendee) => attendee.email) ?? []);
const filteredHosts = Array.from(hostMap.values()).filter(
(host) => attendeeEmails.has(host.email) || host.id === booking.user?.id
);
const hostEmails = new Set(filteredHosts.map((host) => host.email));
const guests =
booking.attendees?.filter((attendee) => !hostEmails.has(attendee.email)).map((attendee) => ({
email: attendee.email,
name: attendee.name,
})) ?? [];
return {
hosts: filteredHosts,
guests,
};
}
@@ -559,7 +559,7 @@ export class BookingRepository {
});
}
async findBookingForMeetingPage({ bookingUid }: { bookingUid: string }) {
async findBookingIncludeCalVideoSettingsAndReferences({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
@@ -573,6 +573,22 @@ export class BookingRepository {
select: {
id: true,
hideOrganizerEmail: true,
hosts: {
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
},
users: {
select: {
id: true,
email: true,
},
},
calVideoSettings: {
select: {
disableRecordingForGuests: true,
@@ -582,6 +598,7 @@ export class BookingRepository {
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
requireEmailForGuests: true,
},
},
},
@@ -143,6 +143,23 @@ const CalVideoSettings = ({ calVideoSettings }: { calVideoSettings?: CalVideoSet
/>
)}
<Controller
name="calVideoSettings.requireEmailForGuests"
defaultValue={!!calVideoSettings?.requireEmailForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("require_email_for_guests")}
description={t("require_email_for_guests_description")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={calVideoSettings?.redirectUrlOnExit || ""}
@@ -30,7 +30,6 @@ export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["loc
export type TDestinationCalendar = { integration: string } | null;
export type TPrefillLocation = { credentialId?: number; type: string };
type LocationsProps = {
team: { id: number } | null;
destinationCalendar: TDestinationCalendar;
@@ -254,6 +254,7 @@ const calVideoSettingsSchema = z
disableTranscriptionForGuests: z.boolean().nullish(),
disableTranscriptionForOrganizer: z.boolean().nullish(),
redirectUrlOnExit: z.string().url().nullish(),
requireEmailForGuests: z.boolean().nullish(),
})
.optional()
.nullable();
@@ -301,4 +302,5 @@ export interface CalVideoSettings {
disableTranscriptionForGuests?: boolean;
disableTranscriptionForOrganizer?: boolean;
redirectUrlOnExit?: string;
requireEmailForGuests?: boolean;
}
@@ -770,6 +770,7 @@ export class EventTypeRepository {
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
requireEmailForGuests: true,
},
},
} satisfies Prisma.EventTypeSelect;
@@ -1067,6 +1068,7 @@ export class EventTypeRepository {
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
requireEmailForGuests: true,
},
},
} satisfies Prisma.EventTypeSelect;
@@ -1,4 +1,6 @@
import dayjs from "@calcom/dayjs";
import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests";
import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -18,40 +20,10 @@ type OriginalRescheduledBooking =
| null
| undefined;
export type Host = {
id: number;
email: string;
};
export type Booking = Awaited<ReturnType<typeof getBooking>>;
type Webhook = TWebhook;
export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"];
export function getHosts(booking: Booking): Host[] {
const hostMap = new Map<number, Host>();
const addHost = (id: number, email: string) => {
if (!hostMap.has(id)) {
hostMap.set(id, { id, email });
}
};
booking?.eventType?.hosts?.forEach((host) => addHost(host.userId, host.user.email));
booking?.eventType?.users?.forEach((user) => addHost(user.id, user.email));
// Add booking.user if not already included
if (booking?.user?.id && booking?.user?.email) {
addHost(booking.user.id, booking.user.email);
}
// Filter hosts to only include those who are also attendees
const attendeeEmails = new Set(booking.attendees?.map((attendee) => attendee.email));
const filteredHosts = Array.from(hostMap.values()).filter(
(host) => attendeeEmails.has(host.email) || host.id === booking.user?.id
);
return filteredHosts;
}
type ParticipantsWithEmail = (Participants[number] & { email?: string; isLoggedIn?: boolean })[];
export function sendWebhookPayload(
webhook: Webhook,
@@ -77,7 +49,7 @@ export function sendWebhookPayload(
attendees: booking.attendees,
endTime: booking.endTime,
participants,
...(!!hostEmail ? { hostEmail } : {}),
...(hostEmail ? { hostEmail } : {}),
...(originalRescheduledBooking ? { rescheduledBy: originalRescheduledBooking.rescheduledBy } : {}),
eventType: {
...booking.eventType,
@@ -109,19 +81,36 @@ export function calculateMaxStartTime(startTime: Date, time: number, timeUnit: T
.unix();
}
export function checkIfUserJoinedTheCall(userId: number, allParticipants: Participants): boolean {
function checkIfHostJoinedTheCall(email: string, allParticipants: ParticipantsWithEmail): boolean {
return allParticipants.some(
(participant) => participant.user_id && parseInt(participant.user_id) === userId
(participant) => participant.email && participant.isLoggedIn && participant.email === email
);
}
const getUserById = async (userId: number) => {
return prisma.user.findUnique({
where: { id: userId },
});
};
function checkIfGuestJoinedTheCall(email: string, allParticipants: ParticipantsWithEmail): boolean {
return allParticipants.some((participant) => participant.email && participant.email === email);
}
type ParticipantsWithEmail = (Participants[number] & { email?: string })[];
const getUserOrGuestById = async (id: string) => {
// Try User table (numeric IDs)
if (!isNaN(Number(id))) {
const user = await prisma.user.findUnique({
where: { id: parseInt(id) },
select: { email: true },
});
if (user) return { email: user.email, isLoggedIn: true };
}
// Try VideoCallGuest table (UUID)
const guestSession = await prisma.videoCallGuest
.findUnique({
where: { id },
select: { email: true },
})
.catch(() => null);
return { email: guestSession?.email, isLoggedIn: false };
};
export async function getParticipantsWithEmail(
allParticipants: Participants
@@ -130,8 +119,8 @@ export async function getParticipantsWithEmail(
allParticipants.map(async (participant) => {
if (!participant.user_id) return participant;
const user = await getUserById(parseInt(participant.user_id));
return { ...participant, email: user?.email };
const { email, isLoggedIn } = await getUserOrGuestById(participant.user_id);
return { ...participant, email, isLoggedIn };
})
);
@@ -149,6 +138,8 @@ export const prepareNoShowTrigger = async (
hostsThatJoinedTheCall: Host[];
numberOfHostsThatJoined: number;
didGuestJoinTheCall: boolean;
guestsThatJoinedTheCall: { email: string; name: string }[];
guestsThatDidntJoinTheCall: { email: string; name: string }[];
originalRescheduledBooking?: OriginalRescheduledBooking;
participants: ParticipantsWithEmail;
} | void> => {
@@ -198,14 +189,15 @@ export const prepareNoShowTrigger = async (
}
const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid);
const hosts = getHosts(booking);
const { hosts, guests } = getHostsAndGuests(booking);
const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants);
const participantsWithEmail = await getParticipantsWithEmail(allParticipants);
const hostsThatJoinedTheCall: Host[] = [];
const hostsThatDidntJoinTheCall: Host[] = [];
for (const host of hosts) {
if (checkIfUserJoinedTheCall(host.id, allParticipants)) {
if (checkIfHostJoinedTheCall(host.email, participantsWithEmail)) {
hostsThatJoinedTheCall.push(host);
} else {
hostsThatDidntJoinTheCall.push(host);
@@ -214,11 +206,26 @@ export const prepareNoShowTrigger = async (
const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length;
const didGuestJoinTheCall = meetingDetails.data.some(
(meeting) => meeting.max_participants > numberOfHostsThatJoined
);
const requireEmailForGuests = booking.eventType?.calVideoSettings?.requireEmailForGuests ?? false;
const participantsWithEmail = await getParticipantsWithEmail(allParticipants);
let didGuestJoinTheCall: boolean;
const guestsThatJoinedTheCall: { email: string; name: string }[] = [];
const guestsThatDidntJoinTheCall: { email: string; name: string }[] = [];
if (requireEmailForGuests) {
for (const guest of guests) {
if (checkIfGuestJoinedTheCall(guest.email, participantsWithEmail)) {
guestsThatJoinedTheCall.push(guest);
} else {
guestsThatDidntJoinTheCall.push(guest);
}
}
didGuestJoinTheCall = guestsThatJoinedTheCall.length > 0;
} else {
didGuestJoinTheCall = meetingDetails.data.some(
(meeting) => meeting.max_participants > numberOfHostsThatJoined
);
}
return {
hostsThatDidntJoinTheCall,
@@ -227,6 +234,8 @@ export const prepareNoShowTrigger = async (
numberOfHostsThatJoined,
webhook,
didGuestJoinTheCall,
guestsThatJoinedTheCall,
guestsThatDidntJoinTheCall,
originalRescheduledBooking,
participants: participantsWithEmail,
};
@@ -24,6 +24,11 @@ export const getBooking = async (bookingId: number) => {
id: true,
teamId: true,
parentId: true,
calVideoSettings: {
select: {
requireEmailForGuests: true,
},
},
hosts: {
select: {
userId: true,
@@ -159,6 +159,7 @@ describe("Trigger Guest No Show:", () => {
id: 1,
teamId: null,
parentId: null,
calVideoSettings: null,
},
webhook: {
...TEST_WEBHOOK,
@@ -265,6 +266,7 @@ describe("Trigger Guest No Show:", () => {
user_name: "MOCK_USER_NAME",
join_time: 0,
duration: 15,
isLoggedIn: true,
},
];
@@ -323,6 +325,7 @@ describe("Trigger Guest No Show:", () => {
id: 1,
teamId: null,
parentId: null,
calVideoSettings: null,
},
webhook: {
...TEST_WEBHOOK,
@@ -468,6 +471,7 @@ describe("Trigger Guest No Show:", () => {
join_time: 0,
duration: 15,
email: "organizer@example.com",
isLoggedIn: true,
},
];
@@ -526,6 +530,7 @@ describe("Trigger Guest No Show:", () => {
id: 1,
teamId: null,
parentId: null,
calVideoSettings: null,
},
webhook: {
...TEST_WEBHOOK,
@@ -1,26 +1,38 @@
import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests";
import { prisma } from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } from "./common";
import type { Host } from "./common";
const markAllGuestNoshowInBooking = async ({
const markGuestAsNoshowInBooking = async ({
bookingId,
hostsThatJoinedTheCall,
guestsThatDidntJoinTheCall,
}: {
bookingId: number;
hostsThatJoinedTheCall: Host[];
guestsThatDidntJoinTheCall?: { email: string; name: string }[];
}) => {
try {
const hostsThatJoinedTheCallEmails = hostsThatJoinedTheCall.map((h) => h.email);
await prisma.attendee.updateMany({
where: {
bookingId,
email: { notIn: hostsThatJoinedTheCallEmails },
},
data: { noShow: true },
});
if (guestsThatDidntJoinTheCall && guestsThatDidntJoinTheCall.length > 0) {
const guestEmailsThatDidntJoin = guestsThatDidntJoinTheCall.map((g) => g.email);
await prisma.attendee.updateMany({
where: {
bookingId,
email: { in: guestEmailsThatDidntJoin },
},
data: { noShow: true },
});
} else {
const hostsThatJoinedTheCallEmails = hostsThatJoinedTheCall.map((h) => h.email);
await prisma.attendee.updateMany({
where: {
bookingId,
email: { notIn: hostsThatJoinedTheCallEmails },
},
data: { noShow: true },
});
}
} catch (err) {
log.error("Error marking guests as no show in booking", err);
}
@@ -35,22 +47,46 @@ export async function triggerGuestNoShow(payload: string): Promise<void> {
booking,
hostsThatJoinedTheCall,
didGuestJoinTheCall,
guestsThatDidntJoinTheCall,
originalRescheduledBooking,
participants,
} = result;
const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit);
if (!didGuestJoinTheCall) {
await sendWebhookPayload(
webhook,
WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
booking,
maxStartTime,
participants,
originalRescheduledBooking
);
const requireEmailForGuests = booking.eventType?.calVideoSettings?.requireEmailForGuests ?? false;
await markAllGuestNoshowInBooking({ bookingId: booking.id, hostsThatJoinedTheCall });
if (requireEmailForGuests) {
if (guestsThatDidntJoinTheCall.length > 0) {
await Promise.all([
sendWebhookPayload(
webhook,
WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
booking,
maxStartTime,
participants,
originalRescheduledBooking
),
markGuestAsNoshowInBooking({
bookingId: booking.id,
hostsThatJoinedTheCall,
guestsThatDidntJoinTheCall,
}),
]);
}
} else {
if (!didGuestJoinTheCall) {
await Promise.all([
sendWebhookPayload(
webhook,
WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
booking,
maxStartTime,
participants,
originalRescheduledBooking
),
markGuestAsNoshowInBooking({ bookingId: booking.id, hostsThatJoinedTheCall }),
]);
}
}
}
@@ -160,6 +160,7 @@ describe("Trigger Host No Show:", () => {
id: 1,
teamId: null,
parentId: null,
calVideoSettings: null,
},
webhook: {
...TEST_WEBHOOK,
@@ -324,6 +325,7 @@ describe("Trigger Host No Show:", () => {
id: 1,
teamId: null,
parentId: null,
calVideoSettings: null,
},
webhook: {
...TEST_WEBHOOK,
@@ -528,6 +530,7 @@ describe("Trigger Host No Show:", () => {
id: 1,
teamId: null,
parentId: null,
calVideoSettings: null,
},
webhook: {
...TEST_WEBHOOK,
@@ -1,7 +1,8 @@
import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests";
import { prisma } from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { Booking, Host } from "./common";
import type { Booking } from "./common";
import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } from "./common";
const markHostsAsNoShowInBooking = async (booking: Booking, hostsThatDidntJoinTheCall: Host[]) => {
@@ -0,0 +1,30 @@
import type { PrismaClient } from "@calcom/prisma";
export class VideoCallGuestRepository {
constructor(private prismaClient: PrismaClient) {}
async upsertVideoCallGuest({
bookingUid,
email,
name,
}: {
bookingUid: string;
email: string;
name: string;
}) {
return await this.prismaClient.videoCallGuest.upsert({
where: {
bookingUid_email: {
bookingUid,
email,
},
},
update: { name },
create: {
bookingUid,
email,
name,
},
});
}
}
@@ -20,6 +20,7 @@ export class CalVideoSettingsRepository {
disableTranscriptionForGuests?: boolean | null;
disableTranscriptionForOrganizer?: boolean | null;
redirectUrlOnExit?: string | null;
requireEmailForGuests?: boolean | null;
};
}) {
return await prisma.calVideoSettings.create({
@@ -31,6 +32,7 @@ export class CalVideoSettingsRepository {
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
requireEmailForGuests: calVideoSettings.requireEmailForGuests ?? false,
eventTypeId,
},
});
@@ -49,6 +51,7 @@ export class CalVideoSettingsRepository {
enableAutomaticTranscription?: boolean | null;
enableAutomaticRecordingForOrganizer?: boolean | null;
redirectUrlOnExit?: string | null;
requireEmailForGuests?: boolean | null;
};
}) {
return await prisma.calVideoSettings.upsert({
@@ -61,6 +64,7 @@ export class CalVideoSettingsRepository {
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
requireEmailForGuests: calVideoSettings.requireEmailForGuests ?? false,
updatedAt: new Date(),
},
create: {
@@ -71,6 +75,7 @@ export class CalVideoSettingsRepository {
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
requireEmailForGuests: calVideoSettings.requireEmailForGuests ?? false,
eventTypeId,
},
});
@@ -184,6 +184,7 @@ export const useEventTypeForm = ({
enableAutomaticRecordingForOrganizer: z.boolean().nullable(),
disableTranscriptionForGuests: z.boolean().nullable(),
disableTranscriptionForOrganizer: z.boolean().nullable(),
requireEmailForGuests: z.boolean().nullable(),
})
.optional()
.nullable(),
@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "public"."CalVideoSettings" ADD COLUMN "requireEmailForGuests" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "public"."VideoCallGuest" (
"id" TEXT NOT NULL,
"bookingUid" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"joinedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VideoCallGuest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VideoCallGuest_bookingUid_idx" ON "public"."VideoCallGuest"("bookingUid");
-- CreateIndex
CREATE INDEX "VideoCallGuest_email_idx" ON "public"."VideoCallGuest"("email");
-- CreateIndex
CREATE UNIQUE INDEX "VideoCallGuest_bookingUid_email_key" ON "public"."VideoCallGuest"("bookingUid", "email");
+15
View File
@@ -107,10 +107,25 @@ model CalVideoSettings {
redirectUrlOnExit String?
disableTranscriptionForGuests Boolean @default(false)
disableTranscriptionForOrganizer Boolean @default(false)
requireEmailForGuests Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VideoCallGuest {
id String @id @default(uuid())
bookingUid String
email String
name String
joinedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([bookingUid, email])
@@index([bookingUid])
@@index([email])
}
model EventType {
id Int @id @default(autoincrement())
/// @zod.string.min(1)
@@ -92,6 +92,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
requireEmailForGuests: calVideoSettings.requireEmailForGuests ?? false,
},
};
}
@@ -50,6 +50,7 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
disableRecordingForGuests: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
requireEmailForGuests: true,
redirectUrlOnExit: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
@@ -139,6 +139,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
disableRecordingForGuests: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
requireEmailForGuests: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
@@ -44,6 +44,7 @@ const calVideoSettingsSchema = z
disableTranscriptionForGuests: z.boolean().nullish(),
disableTranscriptionForOrganizer: z.boolean().nullish(),
redirectUrlOnExit: z.string().url().nullish(),
requireEmailForGuests: z.boolean().nullish(),
})
.optional()
.nullable();