From c196ff1095ae43aa62cc7ebbe993523303796315 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:45:41 +0530 Subject: [PATCH] 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 --- apps/web/app/api/cancel/route.ts | 9 +- apps/web/app/api/video/guest-session/route.ts | 94 +++++++ apps/web/lib/validateCsrfToken.ts | 13 + .../web/lib/video/[uid]/getServerSideProps.ts | 24 +- .../videos/views/videos-single-view.tsx | 253 +++++++++++++----- apps/web/public/static/locales/en/common.json | 6 + .../dailyvideo/lib/VideoApiAdapter.ts | 3 +- .../bookings/lib/getHostsAndGuests.ts | 52 ++++ .../repositories/BookingRepository.ts | 19 +- .../components/locations/CalVideoSettings.tsx | 17 ++ .../components/locations/Locations.tsx | 1 - packages/features/eventtypes/lib/types.ts | 2 + .../repositories/eventTypeRepository.ts | 2 + .../tasker/tasks/triggerNoShow/common.ts | 105 ++++---- .../tasker/tasks/triggerNoShow/getBooking.ts | 5 + .../triggerNoShow/triggerGuestNoShow.test.ts | 5 + .../tasks/triggerNoShow/triggerGuestNoShow.ts | 78 ++++-- .../triggerNoShow/triggerHostNoShow.test.ts | 3 + .../tasks/triggerNoShow/triggerHostNoShow.ts | 3 +- .../repositories/VideoCallGuestRepository.ts | 30 +++ .../lib/server/repository/calVideoSettings.ts | 5 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 24 ++ packages/prisma/schema.prisma | 15 ++ .../viewer/eventTypes/heavy/create.handler.ts | 1 + .../eventTypes/heavy/duplicate.handler.ts | 1 + .../viewer/eventTypes/heavy/update.handler.ts | 1 + .../server/routers/viewer/eventTypes/types.ts | 1 + 28 files changed, 618 insertions(+), 155 deletions(-) create mode 100644 apps/web/app/api/video/guest-session/route.ts create mode 100644 apps/web/lib/validateCsrfToken.ts create mode 100644 packages/features/bookings/lib/getHostsAndGuests.ts create mode 100644 packages/features/video-call-guest/repositories/VideoCallGuestRepository.ts create mode 100644 packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql diff --git a/apps/web/app/api/cancel/route.ts b/apps/web/app/api/cancel/route.ts index a99ee46b73..723c76ee67 100644 --- a/apps/web/app/api/cancel/route.ts +++ b/apps/web/app/api/cancel/route.ts @@ -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()) }); diff --git a/apps/web/app/api/video/guest-session/route.ts b/apps/web/app/api/video/guest-session/route.ts new file mode 100644 index 0000000000..1a1c7a5cd3 --- /dev/null +++ b/apps/web/app/api/video/guest-session/route.ts @@ -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); diff --git a/apps/web/lib/validateCsrfToken.ts b/apps/web/lib/validateCsrfToken.ts new file mode 100644 index 0000000000..3841f4c60f --- /dev/null +++ b/apps/web/lib/validateCsrfToken.ts @@ -0,0 +1,13 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function validateCsrfToken(csrfToken: string): Promise { + 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; +} diff --git a/apps/web/lib/video/[uid]/getServerSideProps.ts b/apps/web/lib/video/[uid]/getServerSideProps.ts index a7f5b6b09e..5d28cc47cd 100644 --- a/apps/web/lib/video/[uid]/getServerSideProps.ts +++ b/apps/web/lib/video/[uid]/getServerSideProps.ts @@ -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, }, }; } diff --git a/apps/web/modules/videos/views/videos-single-view.tsx b/apps/web/modules/videos/views/videos-single-view.tsx index fcb54c4339..8e9f7e310a 100644 --- a/apps/web/modules/videos/views/videos-single-view.tsx +++ b/apps/web/modules/videos/views/videos-single-view.tsx @@ -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(null); - const [userNameForCall, setUserNameForCall] = useState( - overrideName ?? loggedInUserName ?? undefined - ); - const [isUserNameConfirmed, setIsUserNameConfirmed] = useState(!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(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 ( {isCallFrameReady && ( @@ -205,14 +203,16 @@ export default function JoinCall(props: PageProps) { /> )} - {displayLogInOverlay && !isUserNameConfirmed && ( + {!hideLoginModal && ( )} @@ -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(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) => { @@ -351,38 +430,81 @@ export function LogInOverlay(props: LogInOverlayProps) { [error] ); + const handleEmailChange = useCallback( + (e: React.ChangeEvent) => { + setUserEmail(e.target.value); + if (error) { + setError(null); + } + }, + [error] + ); + return ( -
+ className="bg-black/80 p-6 sm:max-w-lg"> +
{t("join_as_guest")}

{t("ideal_for_one_time_calls")}

-
- - -
+ {requireEmailForGuests ? ( +
+ + + + + +
+ ) : ( +
+ + +
+ )}
{error && ( -
+

{error}

@@ -392,7 +514,7 @@ export function LogInOverlay(props: LogInOverlayProps) { )} {/* Divider */} -
+
@@ -487,7 +609,6 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 29bab1f1a4..c216e39f39 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index a82104a996..dd20da3b62 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -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: { diff --git a/packages/features/bookings/lib/getHostsAndGuests.ts b/packages/features/bookings/lib/getHostsAndGuests.ts new file mode 100644 index 0000000000..1b1dd22599 --- /dev/null +++ b/packages/features/bookings/lib/getHostsAndGuests.ts @@ -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(); + + 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, + }; +} diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index b1aafd851d..b78d568cd9 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -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, }, }, }, diff --git a/packages/features/eventtypes/components/locations/CalVideoSettings.tsx b/packages/features/eventtypes/components/locations/CalVideoSettings.tsx index b5d78404b0..6583127bd5 100644 --- a/packages/features/eventtypes/components/locations/CalVideoSettings.tsx +++ b/packages/features/eventtypes/components/locations/CalVideoSettings.tsx @@ -143,6 +143,23 @@ const CalVideoSettings = ({ calVideoSettings }: { calVideoSettings?: CalVideoSet /> )} + { + return ( + } + /> + ); + }} + /> + ["loc export type TDestinationCalendar = { integration: string } | null; export type TPrefillLocation = { credentialId?: number; type: string }; - type LocationsProps = { team: { id: number } | null; destinationCalendar: TDestinationCalendar; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index eb136d56d1..b614d3e715 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -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; } diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 37a9d2658d..2e2e5da674 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -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; diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 658d794a43..9c9820f931 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -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>; type Webhook = TWebhook; export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"]; - -export function getHosts(booking: Booking): Host[] { - const hostMap = new Map(); - - 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, }; diff --git a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts index fcef2cdc68..21a13d860b 100644 --- a/packages/features/tasker/tasks/triggerNoShow/getBooking.ts +++ b/packages/features/tasker/tasks/triggerNoShow/getBooking.ts @@ -24,6 +24,11 @@ export const getBooking = async (bookingId: number) => { id: true, teamId: true, parentId: true, + calVideoSettings: { + select: { + requireEmailForGuests: true, + }, + }, hosts: { select: { userId: true, diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts index 01afe6e3d3..ce7ed867e5 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts @@ -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, diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts index d7c27f34ce..b560da942f 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts @@ -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 { 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 }), + ]); + } } } diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts index aedfe320c0..c554d821eb 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts @@ -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, diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts index c7ec3040b6..c22193ec78 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts @@ -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[]) => { diff --git a/packages/features/video-call-guest/repositories/VideoCallGuestRepository.ts b/packages/features/video-call-guest/repositories/VideoCallGuestRepository.ts new file mode 100644 index 0000000000..81cdf672a7 --- /dev/null +++ b/packages/features/video-call-guest/repositories/VideoCallGuestRepository.ts @@ -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, + }, + }); + } +} diff --git a/packages/lib/server/repository/calVideoSettings.ts b/packages/lib/server/repository/calVideoSettings.ts index 7ce89725d9..4466be22b7 100644 --- a/packages/lib/server/repository/calVideoSettings.ts +++ b/packages/lib/server/repository/calVideoSettings.ts @@ -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, }, }); diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 3ea864c0e0..467e581e00 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -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(), diff --git a/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql b/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql new file mode 100644 index 0000000000..b3e3708ff2 --- /dev/null +++ b/packages/prisma/migrations/20251027102656_add_video_call_guest/migration.sql @@ -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"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 5b2b5460e5..3e46c0abc1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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) diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts index f9f34feecd..81d54f1308 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/create.handler.ts @@ -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, }, }; } diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts index 67a136bad8..8f3d5849bd 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 97c7a64163..13750114b7 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 779f0a7a1c..9c767ad409 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -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();