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:
co-authored by
Volnei Munhoz
parent
bf0fbe4888
commit
c196ff1095
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user