feat: Instant Meeting (#12345)

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
Peer Richelsen 2023-12-19 19:01:42 +00:00 committed by GitHub
parent 6d5983fabc
commit 200ce6932d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1328 additions and 79 deletions

View File

@ -0,0 +1,18 @@
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import InstantEventController from "./InstantEventController";
export const EventInstantTab = ({
eventType,
isTeamEvent,
}: Pick<EventTypeSetupProps, "eventType"> & { isTeamEvent: boolean }) => {
const paymentAppData = getPaymentAppData(eventType);
const requirePayment = paymentAppData.price > 0;
return (
<InstantEventController paymentEnabled={requirePayment} eventType={eventType} isTeamEvent={isTeamEvent} />
);
};

View File

@ -214,6 +214,14 @@ function EventTypeSingleLayout({
}
const showWebhooks = !(isManagedEventType || isChildrenManagedEventType);
if (showWebhooks) {
if (team) {
navigation.push({
name: "instant_tab_title",
href: `/event-types/${eventType.id}?tabName=instant`,
icon: Zap,
info: `instant_event_tab_description`,
});
}
navigation.push({
name: "webhooks",
href: `/event-types/${eventType.id}?tabName=webhooks`,

View File

@ -0,0 +1,99 @@
import { useSession } from "next-auth/react";
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, EmptyScreen, SettingsToggle } from "@calcom/ui";
import { Zap } from "@calcom/ui/components/icon";
type InstantEventControllerProps = {
eventType: EventTypeSetup;
paymentEnabled: boolean;
isTeamEvent: boolean;
};
export default function InstantEventController({
eventType,
paymentEnabled,
isTeamEvent,
}: InstantEventControllerProps) {
const { t } = useLocale();
const session = useSession();
const [instantEventState, setInstantEventState] = useState<boolean>(eventType?.isInstantEvent ?? false);
const formMethods = useFormContext<FormValues>();
const { shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const instantLocked = shouldLockDisableProps("isInstantEvent");
const isOrg = !!session.data?.user?.org?.id;
if (session.status === "loading") return <></>;
return (
<LicenseRequired>
<div className="block items-start sm:flex">
{!isOrg || !isTeamEvent ? (
<EmptyScreen
headline={t("instant_tab_title")}
Icon={Zap}
description={t("uprade_to_create_instant_bookings")}
buttonRaw={<Button href="/enterprise">{t("upgrade")}</Button>}
/>
) : (
<div className={!paymentEnabled ? "w-full" : ""}>
{paymentEnabled ? (
<Alert severity="warning" title={t("warning_payment_instant_meeting_event")} />
) : (
<>
<Alert
className="mb-4"
severity="warning"
title={t("warning_instant_meeting_experimental")}
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
instantEventState && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
title={t("instant_tab_title")}
{...instantLocked}
description={t("instant_event_tab_description")}
checked={instantEventState}
data-testid="instant-event-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("isInstantEvent", false);
setInstantEventState(false);
} else {
formMethods.setValue("isInstantEvent", true);
setInstantEventState(true);
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{instantEventState && (
<div data-testid="instant-event-collapsible" className="flex flex-col gap-2 text-sm">
<p>{t("warning_payment_instant_meeting_event")}</p>
</div>
)}
</div>
</SettingsToggle>
</>
)}
</div>
)}
</div>
</LicenseRequired>
);
}

View File

@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleInstantMeeting from "@calcom/features/instant-meeting/handleInstantMeeting";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `instant.event-${userIp}`,
});
const session = await getServerSession({ req, res });
req.userId = session?.user?.id || -1;
const booking = await handleInstantMeeting(req);
return booking;
}
export default defaultResponder(handler);

View File

@ -0,0 +1,92 @@
import { useSession } from "next-auth/react";
import { Trans } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { TRPCClientError } from "@calcom/trpc/react";
import { Button, EmptyScreen, Alert } from "@calcom/ui";
import { Zap } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
function ConnectAndJoin() {
const { t } = useLocale();
const router = useRouter();
const token = getQueryParam("token");
const [meetingUrl, setMeetingUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | undefined>();
const session = useSession();
const isUserPartOfOrg = session.status === "authenticated" && !!session.data.user?.org;
const mutation = trpc.viewer.connectAndJoin.useMutation({
onSuccess: (res) => {
if (res.meetingUrl && !res.isBookingAlreadyAcceptedBySomeoneElse) {
router.push(res.meetingUrl);
} else if (res.isBookingAlreadyAcceptedBySomeoneElse && res.meetingUrl) {
setMeetingUrl(res.meetingUrl);
}
},
onError: (err) => {
console.log("err", err, err instanceof TRPCClientError);
if (err instanceof TRPCClientError) {
setErrorMessage(t(err.message));
} else {
setErrorMessage(t("something_went_wrong"));
}
},
});
if (session.status === "loading") return <p>{t("loading")}</p>;
if (!token) return <p>{t("token_not_found")}</p>;
return (
<div className="mx-8 mt-12 block items-start sm:flex">
{session ? (
<EmptyScreen
headline={t("instant_tab_title")}
Icon={Zap}
description={t("uprade_to_create_instant_bookings")}
buttonRaw={
<div className="flex flex-col items-center justify-center gap-4">
{meetingUrl ? (
<div className="text-default flex flex-col items-center gap-2 text-center text-sm font-normal">
<Trans i18nKey="some_other_host_already_accepted_the_meeting">
Some other host already accepted the meeting. Do you still want to join?
<Link className="inline-block cursor-pointer underline" href={meetingUrl}>
Continue to Meeting Url
</Link>
</Trans>
</div>
) : (
<Button
loading={mutation.isLoading}
tooltip={isUserPartOfOrg ? t("join_meeting") : t("not_part_of_org")}
disabled={!isUserPartOfOrg}
onClick={() => {
mutation.mutate({ token });
}}>
{t("join_meeting")}
</Button>
)}
{errorMessage && <Alert severity="error" message={errorMessage} />}
</div>
}
/>
) : (
<div>{t("you_must_be_logged_in_to", { url: WEBAPP_URL })}</div>
)}
</div>
);
}
ConnectAndJoin.requiresLicense = true;
ConnectAndJoin.PageWrapper = PageWrapper;
export default ConnectAndJoin;

View File

@ -63,6 +63,10 @@ const EventAdvancedTab = dynamic(() =>
import("@components/eventtype/EventAdvancedTab").then((mod) => mod.EventAdvancedTab)
);
const EventInstantTab = dynamic(() =>
import("@components/eventtype/EventInstantTab").then((mod) => mod.EventInstantTab)
);
const EventRecurringTab = dynamic(() =>
import("@components/eventtype/EventRecurringTab").then((mod) => mod.EventRecurringTab)
);
@ -84,6 +88,7 @@ export type FormValues = {
eventTitle: string;
eventName: string;
slug: string;
isInstantEvent: boolean;
length: number;
offsetStart: number;
description: string;
@ -149,6 +154,7 @@ const querySchema = z.object({
"availability",
"apps",
"limits",
"instant",
"recurring",
"team",
"advanced",
@ -248,6 +254,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
title: eventType.title,
locations: eventType.locations || [],
recurringEvent: eventType.recurringEvent || null,
isInstantEvent: eventType.isInstantEvent,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
@ -410,6 +417,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
team: <EventTeamTab teamMembers={teamMembers} team={team} eventType={eventType} />,
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: (

View File

@ -0,0 +1,121 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export default function Type({
slug,
user,
booking,
away,
isEmbed,
isBrandingHidden,
entity,
duration,
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={undefined}
hideBranding={isBrandingHidden}
isTeamEvent
entity={entity}
bookingData={booking}
/>
<Booker
username={user}
eventSlug={slug}
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
isTeamEvent
isInstantMeeting
entity={entity}
duration={duration}
/>
</main>
);
}
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
slug: z.string().transform((s) => slugify(s)),
});
Type.PageWrapper = PageWrapper;
Type.isBookingPage = true;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { duration: queryDuration } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const team = await prisma.team.findFirst({
where: {
...getSlugOrRequestedSlug(teamSlug),
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
hideBranding: true,
},
});
if (!team) {
return {
notFound: true,
} as const;
}
const org = isValidOrgDomain ? currentOrgDomain : null;
const eventData = await ssr.viewer.public.event.fetch({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
org,
});
if (!eventData || !org) {
return {
notFound: true,
} as const;
}
return {
props: {
entity: eventData.entity,
duration: getMultipleDurationValue(
eventData.metadata?.multipleDuration,
queryDuration,
eventData.length
),
booking: null,
away: false,
user: teamSlug,
teamId: team.id,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
themeBasis: null,
},
};
};

View File

@ -31,6 +31,7 @@ export default function Type({
isBrandingHidden,
entity,
duration,
isInstantMeeting,
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
@ -48,6 +49,7 @@ export default function Type({
eventSlug={slug}
bookingData={booking}
isAway={away}
isInstantMeeting={isInstantMeeting}
hideBranding={isBrandingHidden}
isTeamEvent
entity={entity}
@ -71,7 +73,7 @@ const paramsSchema = z.object({
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getServerSession(context);
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration } = context.query;
const { rescheduleUid, duration: queryDuration, isInstantMeeting: queryIsInstantMeeting } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
@ -143,6 +145,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
themeBasis: null,
},
};

View File

@ -113,6 +113,7 @@
"requested_to_reschedule_subject_attendee": "Action Required Reschedule: Please book a new time for {{eventType}} with {{name}}",
"hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}",
"please_book_a_time_sometime_later":"No one was available at the moment. Please book for sometime later",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Join by {{entryPoint}}",
"notes": "Notes",
@ -129,12 +130,16 @@
"meeting_id": "Meeting ID",
"meeting_password": "Meeting Password",
"meeting_url": "Meeting URL",
"meeting_url_not_found":"Meeting URL not found",
"token_not_found":"Token not found",
"some_other_host_already_accepted_the_meeting":"Some other host already accepted the meeting. Do you still want to join? <1>Continue to Meeting Url</1>",
"meeting_request_rejected": "Your meeting request has been rejected",
"rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}",
"hi": "Hi",
"join_team": "Join team",
"manage_this_team": "Manage this team",
"team_info": "Team Info",
"join_meeting":"Join Meeting",
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
@ -435,6 +440,7 @@
"browse_api_documentation": "Browse our API documentation",
"leverage_our_api": "Leverage our API for full control and customizability.",
"create_webhook": "Create Webhook",
"instant_meeting_created":"Instant Meeting Created",
"booking_cancelled": "Booking Canceled",
"booking_rescheduled": "Booking Rescheduled",
"recording_ready": "Recording Download Link Ready",
@ -686,6 +692,7 @@
"add_members": "Add members...",
"no_assigned_members": "No assigned members",
"assigned_to": "Assigned to",
"you_must_be_logged_in_to":"You must be logged in to {{url}}",
"start_assigning_members_above": "Start assigning members above",
"locked_fields_admin_description": "Members will not be able to edit this",
"locked_fields_member_description": "This option was locked by the team admin",
@ -798,6 +805,9 @@
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
"recurring_event": "Recurring Event",
"recurring_event_description": "People can subscribe for recurring events",
"cannot_be_used_with_paid_event_types":"It cannot be used with paid event types",
"warning_payment_instant_meeting_event":"Instant Meetings are not supported with recurring events and payment apps yet",
"warning_instant_meeting_experimental":"Experimental: Instant Meeting Events are currently experimental.",
"starting": "Starting",
"disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.",
@ -1321,6 +1331,7 @@
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
"pro": "Pro",
"removes_cal_branding": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}.'",
"instant_meeting_with_title":"Instant Meeting with {{name}}",
"profile_picture": "Profile Picture",
"upload": "Upload",
"add_profile_photo": "Add profile photo",
@ -2026,6 +2037,8 @@
"add_times_to_your_email": "Select a few available times and embed them in your Email",
"select_time": "Select Time",
"select_date": "Select Date",
"connecting_you_to_someone":"We are connecting you to someone.",
"please_do_not_close_this_tab":"Please do not close this tab",
"see_all_available_times": "See all available times",
"org_team_names_example_1": "e.g. Marketing Team",
"org_team_names_example_2": "e.g. Sales Team",
@ -2048,6 +2061,7 @@
"cal_video_logo_upload_instruction":"To ensure your logo is visible against Cal video's dark background, please upload a light-colored image in PNG or SVG format to maintain transparency.",
"org_admin_other_teams": "Other teams",
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
"not_part_of_org":"You are not part of any organization",
"no_other_teams_found": "No other teams found",
"no_other_teams_found_description": "There are no other teams in this organization.",
"attendee_first_name_variable": "Attendee first name",
@ -2168,6 +2182,10 @@
"need_help": "Need help?",
"troubleshooter": "Troubleshooter",
"please_install_a_calendar": "Please install a calendar",
"instant_tab_title": "Instant Booking",
"instant_event_tab_description": "Let people book immediately",
"uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types",
"dont_want_to_wait": "Don't want to wait?",
"meeting_started": "Meeting Started",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -145,6 +145,35 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
};
};
async function createInstantMeeting(endTime: string) {
// added a 1 hour buffer for room expiration
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
const body = {
privacy: "public",
properties: {
enable_prejoin_ui: true,
enable_knocking: true,
enable_screenshare: true,
enable_chat: true,
exp: exp,
enable_recording: "cloud",
},
};
const dailyEvent = await postToDailyAPI("/rooms", body).then(dailyReturnTypeSchema.parse);
const meetingToken = await postToDailyAPI("/meeting-tokens", {
properties: { room_name: dailyEvent.name, exp: dailyEvent.config.exp, is_owner: true },
}).then(meetingTokenSchema.parse);
return Promise.resolve({
type: "daily_video",
id: dailyEvent.name,
password: meetingToken.token,
url: dailyEvent.url,
});
}
return {
/** Daily doesn't need to return busy times, so we return empty */
getAvailability: () => {
@ -168,6 +197,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
throw new Error("Something went wrong! Unable to get recording");
}
},
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
try {
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=172800`).then(

View File

@ -104,6 +104,7 @@ module.exports = {
},
animation: {
"fade-in-up": "fade-in-up 600ms var(--animation-delay, 0ms) cubic-bezier(.21,1.02,.73,1) forwards",
"fade-in-bottom": "fade-in-bottom cubic-bezier(.21,1.02,.73,1) forwards",
spinning: "spinning 0.75s linear infinite",
},
boxShadow: {

View File

@ -204,6 +204,28 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => {
return videoAdapter?.createMeeting(calEvent);
};
export const createInstantMeetingWithCalVideo = async (endTime: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
try {
dailyAppKeys = await getDailyAppKeys();
} catch (e) {
return;
}
const [videoAdapter] = await getVideoAdapters([
{
id: 0,
appId: "daily-video",
type: "daily_video",
userId: null,
user: { email: "" },
teamId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.createInstantCalVideoRoom?.(endTime);
};
const getRecordingsOfCalVideoByRoomName = async (
roomName: string
): Promise<GetRecordingsResponseSchema | undefined> => {

View File

@ -1,5 +1,6 @@
import { LazyMotion, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
@ -9,8 +10,10 @@ import dayjs from "@calcom/dayjs";
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { AvatarGroup, Button } from "@calcom/ui";
import { AvailableTimeSlots } from "./components/AvailableTimeSlots";
import { BookEventForm } from "./components/BookEventForm";
@ -46,6 +49,7 @@ const BookerComponent = ({
durationConfig,
duration,
hashedLink,
isInstantMeeting = false,
}: BookerProps) => {
/**
* Prioritize dateSchedule load
@ -149,6 +153,7 @@ const BookerComponent = ({
isTeamEvent,
org: entity.orgSlug,
durationConfig,
isInstantMeeting,
});
useEffect(() => {
@ -220,6 +225,13 @@ const BookerComponent = ({
return (
<>
{event.data ? <BookingPageTagManager eventType={event.data} /> : null}
{bookerState !== "booking" && event.data?.isInstantEvent && (
<div
className="animate-fade-in-up fixed bottom-2 z-40 my-2 opacity-0"
style={{ animationDelay: "2s" }}>
<InstantBooking />
</div>
)}
<div
className={classNames(
// In a popup embed, if someone clicks outside the main(having main class or main tag), it closes the embed
@ -240,22 +252,24 @@ const BookerComponent = ({
!isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-subtle"
)}>
<AnimatePresence>
<BookerSection
area="header"
className={classNames(
layout === BookerLayouts.MONTH_VIEW && "fixed top-4 z-10 ltr:right-4 rtl:left-4",
(layout === BookerLayouts.COLUMN_VIEW || layout === BookerLayouts.WEEK_VIEW) &&
"bg-default dark:bg-muted sticky top-0 z-10"
)}>
<Header
username={username}
eventSlug={eventSlug}
enabledLayouts={bookerLayouts.enabledLayouts}
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
isMobile={isMobile}
nextSlots={nextSlots}
/>
</BookerSection>
{!isInstantMeeting && (
<BookerSection
area="header"
className={classNames(
layout === BookerLayouts.MONTH_VIEW && "fixed top-4 z-10 ltr:right-4 rtl:left-4",
(layout === BookerLayouts.COLUMN_VIEW || layout === BookerLayouts.WEEK_VIEW) &&
"bg-default dark:bg-muted sticky top-0 z-10"
)}>
<Header
username={username}
eventSlug={eventSlug}
enabledLayouts={bookerLayouts.enabledLayouts}
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
isMobile={isMobile}
nextSlots={nextSlots}
/>
</BookerSection>
)}
<StickyOnDesktop
key="meta"
className={classNames(
@ -367,3 +381,54 @@ export const Booker = (props: BookerProps) => {
</LazyMotion>
);
};
export const InstantBooking = () => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
return (
<div className=" bg-default border-subtle mx-2 block items-center gap-3 rounded-xl border p-[6px] text-sm shadow-sm delay-1000 sm:flex">
<div className="flex items-center gap-3 ps-1">
{/* TODO: max. show 4 people here */}
<div className="relative">
<AvatarGroup
size="sm"
className="relative"
items={[
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
]}
/>
<div className="border-muted absolute -bottom-0.5 -right-1 h-2 w-2 rounded-full border bg-green-500" />
</div>
<div>{t("dont_want_to_wait")}</div>
</div>
<div className="mt-2 sm:mt-0">
<Button
color="primary"
onClick={() => {
const newPath = `${pathname}?isInstantMeeting=true`;
router.push(newPath);
}}
size="sm"
className="w-full justify-center rounded-lg sm:w-auto">
{t("connect_now")}
</Button>
</div>
</div>
);
};

View File

@ -3,7 +3,7 @@ import type { UseMutationResult } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { FieldError } from "react-hook-form";
import { useForm } from "react-hook-form";
@ -12,6 +12,7 @@ import { z } from "zod";
import type { EventLocationType } from "@calcom/app-store/locations";
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import dayjs from "@calcom/dayjs";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { VerifyCodeDialog } from "@calcom/features/bookings/components/VerifyCodeDialog";
import {
createBooking,
@ -19,16 +20,20 @@ import {
mapBookingToMutationInput,
mapRecurringBookingToMutationInput,
useTimePreferences,
createInstantBooking,
} from "@calcom/features/bookings/lib";
import getBookingResponsesSchema, {
getBookingResponsesPartialSchema,
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc";
import { Dialog, DialogContent } from "@calcom/ui";
import { Alert, Button, EmptyScreen, Form, showToast } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
@ -65,6 +70,7 @@ export const BookEventForm = ({ onCancel, hashedLink }: BookEventFormProps) => {
const bookingData = useBookerStore((state) => state.bookingData);
const duration = useBookerStore((state) => state.selectedDuration);
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const isRescheduling = !!rescheduleUid && !!bookingData;
const eventQuery = useEvent();
const eventType = eventQuery.data;
@ -115,6 +121,7 @@ export const BookEventForm = ({ onCancel, hashedLink }: BookEventFormProps) => {
eventQuery={eventQuery}
rescheduleUid={rescheduleUid}
hashedLink={hashedLink}
isInstantMeeting={isInstantMeeting}
/>
);
};
@ -126,12 +133,14 @@ export const BookEventFormChild = ({
eventQuery,
rescheduleUid,
hashedLink,
isInstantMeeting,
}: BookEventFormProps & {
initialValues: DefaultValues;
isRescheduling: boolean;
eventQuery: ReturnType<typeof useEvent>;
rescheduleUid: string | null;
hashedLink?: string | null;
isInstantMeeting?: boolean;
}) => {
const eventType = eventQuery.data;
const bookingFormSchema = z
@ -164,6 +173,8 @@ export const BookEventFormChild = ({
const timeslot = useBookerStore((state) => state.selectedTimeslot);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
const username = useBookerStore((state) => state.username);
const [expiryTime, setExpiryTime] = useState<Date | undefined>();
type BookingFormValues = {
locationType?: EventLocationType["type"];
responses: z.infer<typeof bookingFormSchema>["responses"] | null;
@ -230,6 +241,18 @@ export const BookEventFormChild = ({
},
});
const createInstantBookingMutation = useMutation(createInstantBooking, {
onSuccess: (responseData) => {
updateQueryParam("bookingId", responseData.bookingId);
setExpiryTime(responseData.expires);
},
onError: (err, _, ctx) => {
console.error("Error creating instant booking", err);
errorRef && errorRef.current?.scrollIntoView({ behavior: "smooth" });
},
});
const createRecurringBookingMutation = useMutation(createRecurringBooking, {
onSuccess: async (responseData) => {
const booking = responseData[0] || {};
@ -344,7 +367,9 @@ export const BookEventFormChild = ({
hashedLink,
};
if (eventQuery.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
if (isInstantMeeting) {
createInstantBookingMutation.mutate(mapBookingToMutationInput(bookingInput));
} else if (eventQuery.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
createRecurringBookingMutation.mutate(
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount)
);
@ -384,6 +409,7 @@ export const BookEventFormChild = ({
/>
{(createBookingMutation.isError ||
createRecurringBookingMutation.isError ||
createInstantBookingMutation.isError ||
bookingForm.formState.errors["globalError"]) && (
<div data-testid="booking-fail">
<Alert
@ -395,6 +421,7 @@ export const BookEventFormChild = ({
bookingForm.formState.errors["globalError"],
createBookingMutation,
createRecurringBookingMutation,
createInstantBookingMutation,
t,
responseVercelIdHeader
)}
@ -402,22 +429,32 @@ export const BookEventFormChild = ({
</div>
)}
<div className="modalsticky mt-auto flex justify-end space-x-2 rtl:space-x-reverse">
{!!onCancel && (
<Button color="minimal" type="button" onClick={onCancel} data-testid="back">
{t("back")}
{isInstantMeeting ? (
<Button type="submit" color="primary" loading={createInstantBookingMutation.isLoading}>
{t("confirm")}
</Button>
) : (
<>
{!!onCancel && (
<Button color="minimal" type="button" onClick={onCancel} data-testid="back">
{t("back")}
</Button>
)}
<Button
type="submit"
color="primary"
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
data-testid={
rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"
}>
{rescheduleUid && bookingData
? t("reschedule")
: renderConfirmNotVerifyEmailButtonCond
? t("confirm")
: t("verify_email_email_button")}
</Button>
</>
)}
<Button
type="submit"
color="primary"
loading={createBookingMutation.isLoading || createRecurringBookingMutation.isLoading}
data-testid={rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"}>
{rescheduleUid && bookingData
? t("reschedule")
: renderConfirmNotVerifyEmailButtonCond
? t("confirm")
: t("verify_email_email_button")}
</Button>
</div>
</Form>
<VerifyCodeDialog
@ -431,10 +468,74 @@ export const BookEventFormChild = ({
}}
isUserSessionRequiredToVerify={false}
/>
<RedirectToInstantMeetingModal expiryTime={expiryTime} />
</div>
);
};
const RedirectToInstantMeetingModal = ({ expiryTime }: { expiryTime?: Date }) => {
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const bookingId = parseInt(getQueryParam("bookingId") || "0");
const hasInstantMeetingTokenExpired = expiryTime && new Date(expiryTime) < new Date();
const instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
{
bookingId: bookingId,
},
{
enabled: !!bookingId && !hasInstantMeetingTokenExpired,
refetchInterval: 2000,
onSuccess: (data) => {
try {
showToast(t("something_went_wrong_on_our_end"), "error");
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
data.booking?.metadata || {}
)?.videoCallUrl;
if (locationVideoCallUrl) {
router.push(locationVideoCallUrl);
} else {
showToast(t("something_went_wrong_on_our_end"), "error");
}
} catch (err) {
showToast(t("something_went_wrong_on_our_end"), "error");
}
},
}
);
return (
<Dialog open={!!bookingId}>
<DialogContent enableOverflow className="py-8">
<div>
{hasInstantMeetingTokenExpired ? (
<div>
<p className="font-medium">{t("please_book_a_time_sometime_later")}</p>
<Button
className="mt-4"
onClick={() => {
window.location.href = pathname;
}}
color="primary">
{t("go_back")}
</Button>
</div>
) : (
<div>
<p className="font-medium">{t("connecting_you_to_someone")}</p>
<p className="font-medium">{t("please_do_not_close_this_tab")}</p>
<Spinner className="relative mt-8" />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
const getError = (
globalError: FieldError | undefined,
// It feels like an implementation detail to reimplement the types of useMutation here.
@ -444,12 +545,13 @@ const getError = (
bookingMutation: UseMutationResult<any, any, any, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
recurringBookingMutation: UseMutationResult<any, any, any, any>,
createInstantBookingMutation: UseMutationResult<any, any, any, any>,
t: TFunction,
responseVercelIdHeader: string | null
) => {
if (globalError) return globalError.message;
const error = bookingMutation.error || recurringBookingMutation.error;
const error = bookingMutation.error || recurringBookingMutation.error || createInstantBookingMutation.error;
return error.message ? (
<>

View File

@ -2,6 +2,7 @@ import { useFormContext } from "react-hook-form";
import type { LocationObject } from "@calcom/app-store/locations";
import { getOrganizerInputLocationTypes } from "@calcom/app-store/locations";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
@ -27,12 +28,16 @@ export const BookingFields = ({
const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
return (
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
// The logic here intends to make modifications to booking fields based on the way we want to specifically show Booking Form
<div>
{fields.map((field, index) => {
// Don't Display Location field in case of instant meeting as only Cal Video is supported
if (isInstantMeeting && field.name === "location") return null;
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
let readOnly =

View File

@ -27,6 +27,7 @@ type StoreInitializeType = {
seatReferenceUid?: string;
durationConfig?: number[] | null;
org?: string | null;
isInstantMeeting?: boolean;
};
type SeatedEventData = {
@ -131,6 +132,8 @@ export type BookerStore = {
org?: string | null;
seatedEventData: SeatedEventData;
setSeatedEventData: (seatedEventData: SeatedEventData) => void;
isInstantMeeting?: boolean;
};
/**
@ -225,6 +228,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
isTeamEvent,
durationConfig,
org,
isInstantMeeting,
}: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
@ -270,6 +274,21 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
// force clear this.
if (rescheduleUid && bookingData) set({ selectedTimeslot: null });
if (month) set({ month });
if (isInstantMeeting) {
const month = dayjs().format("YYYY-MM");
const selectedDate = dayjs().format("YYYY-MM-DD");
const selectedTimeslot = new Date().toISOString();
set({
month,
selectedDate,
selectedTimeslot,
isInstantMeeting,
});
updateQueryParam("month", month);
updateQueryParam("date", selectedDate ?? "");
updateQueryParam("slot", selectedTimeslot ?? "");
}
//removeQueryParam("layout");
},
durationConfig: null,
@ -308,6 +327,7 @@ export const useInitializeBookerStore = ({
isTeamEvent,
durationConfig,
org,
isInstantMeeting,
}: StoreInitializeType) => {
const initializeStore = useBookerStore((state) => state.initialize);
useEffect(() => {
@ -323,6 +343,7 @@ export const useInitializeBookerStore = ({
org,
verifiedEmail,
durationConfig,
isInstantMeeting,
});
}, [
initializeStore,
@ -337,5 +358,6 @@ export const useInitializeBookerStore = ({
isTeamEvent,
verifiedEmail,
durationConfig,
isInstantMeeting,
]);
};

View File

@ -72,6 +72,7 @@ export interface BookerProps {
* Refers to the private link from event types page.
*/
hashedLink?: string | null;
isInstantMeeting?: boolean;
}
export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";

View File

@ -112,6 +112,7 @@ export const EventMetaBlock = ({
export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: EventDetailsProps) => {
const { t } = useLocale();
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const isInstantMeeting = useBookerStore((store) => store.isInstantMeeting);
return (
<>
@ -129,7 +130,7 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
);
case EventDetailBlocks.LOCATION:
if (!event?.locations?.length) return null;
if (!event?.locations?.length || isInstantMeeting) return null;
return (
<EventMetaBlock key={block}>
<AvailableEventLocations locations={event.locations} />

View File

@ -0,0 +1,8 @@
import { post } from "@calcom/lib/fetch-wrapper";
import type { BookingCreateBody, InstatBookingResponse } from "../types";
export const createInstantBooking = async (data: BookingCreateBody) => {
const response = await post<BookingCreateBody, InstatBookingResponse>("/api/book/instant-event", data);
return response;
};

View File

@ -549,7 +549,7 @@ async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boole
});
}
async function getBookingData({
export async function getBookingData({
req,
isNotAnApiCall,
eventType,
@ -791,7 +791,7 @@ async function createBooking({
return prisma.booking.create(createBookingObj);
}
function getCustomInputsResponses(
export function getCustomInputsResponses(
reqBody: {
responses?: Record<string, object>;
customInputs?: z.infer<typeof bookingCreateSchemaLegacyPropsForApi>["customInputs"];

View File

@ -5,3 +5,4 @@ export {
} from "./book-event-form/booking-to-mutation-input-mapper";
export { createBooking } from "./create-booking";
export { createRecurringBooking } from "./create-recurring-booking";
export { createInstantBooking } from "./create-instant-booking";

View File

@ -32,3 +32,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & {
export type BookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
>;
export type InstatBookingResponse = Awaited<
ReturnType<typeof import("@calcom/features/instant-meeting/handleInstantMeeting").default>
>;

View File

@ -23,6 +23,7 @@ const eventClasses = cva(
PENDING: "bg-default text-emphasis border-[1px] border-dashed border-gray-900",
REJECTED: "",
CANCELLED: "",
AWAITING_HOST: "",
},
disabled: {
true: "hover:cursor-default",
@ -37,6 +38,7 @@ const eventClasses = cva(
PENDING: "border-gray-900",
REJECTED: "border-gray-900",
CANCELLED: "border-gray-900",
AWAITING_HOST: "",
custom: "",
},
},

View File

@ -1,5 +1,11 @@
export const Spinner = () => (
<div className="fixed left-[calc(50%+calc(var(--booker-meta-width,0px)/2))] top-1/2 z-[80] h-10 w-10 -translate-x-1/2 -translate-y-1/2">
import { classNames } from "@calcom/lib";
export const Spinner = ({ className }: { className?: string }) => (
<div
className={classNames(
"fixed left-[calc(50%+calc(var(--booker-meta-width,0px)/2))] top-1/2 z-[80] h-10 w-10 -translate-x-1/2 -translate-y-1/2",
className
)}>
<svg className="h-10 w-10" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
className="fill-default"

View File

@ -27,6 +27,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
description: true,
eventName: true,
slug: true,
isInstantEvent: true,
schedulingType: true,
length: true,
locations: true,
@ -190,6 +191,7 @@ export const getPublicEvent = async (
orgSlug: org,
name: unPublishedOrgUser?.organization?.name ?? null,
},
isInstantEvent: false,
};
}
@ -251,6 +253,7 @@ export const getPublicEvent = async (
name: (event.owner?.organization?.name || event.team?.parent?.name || event.team?.name) ?? null,
},
isDynamic: false,
isInstantEvent: event.isInstantEvent,
};
};

View File

@ -0,0 +1,211 @@
import { Prisma } from "@prisma/client";
import { randomBytes } from "crypto";
import type { NextApiRequest } from "next";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { createInstantMeetingWithCalVideo } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import {
getEventTypesFromDB,
getBookingData,
getCustomInputsResponses,
} from "@calcom/features/bookings/lib/handleNewBooking";
import { getFullName } from "@calcom/features/form-builder/utils";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
const handleInstantMeetingWebhookTrigger = async (args: {
subscriberOptions: GetSubscriberOptions;
webhookData: Record<string, unknown>;
}) => {
try {
const eventTrigger = WebhookTriggerEvents.INSTANT_MEETING;
const subscribers = await getWebhooks(args.subscriberOptions);
const { webhookData } = args;
const promises = subscribers.map((sub) => {
sendGenericWebhookPayload({
secretKey: sub.secret,
triggerEvent: eventTrigger,
createdAt: new Date().toISOString(),
webhook: sub,
data: webhookData,
}).catch((e) => {
console.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`,
sub,
e
);
});
});
await Promise.all(promises);
} catch (error) {
console.error("Error executing webhook", error);
logger.error("Error while sending webhook", error);
}
};
async function handler(req: NextApiRequest) {
let eventType = await getEventTypesFromDB(req.body.eventTypeId);
eventType = {
...eventType,
bookingFields: getBookingFieldsWithSystemFields(eventType),
};
if (!eventType.team?.id) {
throw new Error("Only Team Event Types are supported for Instant Meeting");
}
const reqBody = await getBookingData({
req,
isNotAnApiCall: true,
eventType,
});
const { email: bookerEmail, name: bookerName } = reqBody;
const translator = short();
const seed = `${reqBody.email}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const attendeeTimezone = reqBody.timeZone;
const attendeeLanguage = reqBody.language;
const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common");
const fullName = getFullName(bookerName);
const invitee = [
{
email: bookerEmail,
name: fullName,
timeZone: attendeeTimezone,
locale: attendeeLanguage ?? "en",
},
];
const guests = (reqBody.guests || []).reduce((guestArray, guest) => {
guestArray.push({
email: guest,
name: "",
timeZone: attendeeTimezone,
locale: "en",
});
return guestArray;
}, [] as typeof invitee);
const attendeesList = [...invitee, ...guests];
const calVideoMeeting = await createInstantMeetingWithCalVideo(dayjs.utc(reqBody.end).toISOString());
if (!calVideoMeeting) {
throw new Error("Cal Video Meeting Creation Failed");
}
eventType.team.id;
const bookingReferenceToCreate = [
{
type: calVideoMeeting.type,
uid: calVideoMeeting.id,
meetingId: calVideoMeeting.id,
meetingPassword: calVideoMeeting.password,
meetingUrl: calVideoMeeting.url,
},
];
// Create Partial
const newBookingData: Prisma.BookingCreateInput = {
uid,
responses: reqBody.responses === null ? Prisma.JsonNull : reqBody.responses,
title: tAttendees("instant_meeting_with_title", { name: invitee[0].name }),
startTime: dayjs.utc(reqBody.start).toDate(),
endTime: dayjs.utc(reqBody.end).toDate(),
description: reqBody.notes,
customInputs: isPrismaObjOrUndefined(customInputs),
status: BookingStatus.AWAITING_HOST,
references: {
create: bookingReferenceToCreate,
},
location: "integrations:daily",
eventType: {
connect: {
id: reqBody.eventTypeId,
},
},
metadata: { ...reqBody.metadata, videoCallUrl: `${WEBAPP_URL}/video/${uid}` },
attendees: {
createMany: {
data: attendeesList,
},
},
};
const createBookingObj = {
include: {
attendees: true,
},
data: newBookingData,
};
const newBooking = await prisma.booking.create(createBookingObj);
// Create Instant Meeting Token
const token = randomBytes(32).toString("hex");
const instantMeetingToken = await prisma.instantMeetingToken.create({
data: {
token,
expires: new Date(new Date().getTime() + 1000 * 60 * 5),
team: {
connect: {
id: eventType.team.id,
},
},
booking: {
connect: {
id: newBooking.id,
},
},
updatedAt: new Date().toISOString(),
},
});
// Trigger Webhook
const subscriberOptions: GetSubscriberOptions = {
userId: null,
eventTypeId: eventType.id,
triggerEvent: WebhookTriggerEvents.INSTANT_MEETING,
teamId: eventType.team.id,
};
const webhookData = {
triggerEvent: WebhookTriggerEvents.INSTANT_MEETING,
uid: newBooking.uid,
responses: newBooking.responses,
connectAndJoinUrl: `${WEBAPP_URL}/connect-and-join?token=${token}`,
eventTypeId: eventType.id,
eventTypeTitle: eventType.title,
customInputs: newBooking.customInputs,
};
await handleInstantMeetingWebhookTrigger({
subscriberOptions,
webhookData,
});
return {
message: "Success",
meetingTokenId: instantMeetingToken.id,
bookingId: newBooking.id,
expires: instantMeetingToken.expires,
};
}
export default handler;

View File

@ -41,6 +41,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
{ value: WebhookTriggerEvents.MEETING_ENDED, label: "meeting_ended" },
{ value: WebhookTriggerEvents.MEETING_STARTED, label: "meeting_started" },
{ value: WebhookTriggerEvents.RECORDING_READY, label: "recording_ready" },
{ value: WebhookTriggerEvents.INSTANT_MEETING, label: "instant_meeting_created" },
],
"routing-forms": [{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" }],
} as const;
@ -71,7 +72,9 @@ const WebhookForm = (props: {
subscriberUrl: props.webhook?.subscriberUrl || "",
active: props.webhook ? props.webhook.active : true,
eventTriggers: !props.webhook
? translatedTriggerOptions.map((option) => option.value)
? translatedTriggerOptions
.filter((option) => option.value !== WebhookTriggerEvents.INSTANT_MEETING)
.map((option) => option.value)
: props.webhook.eventTriggers,
secret: props?.webhook?.secret || "",
payloadTemplate: props?.webhook?.payloadTemplate || undefined,

View File

@ -14,6 +14,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
WebhookTriggerEvents.BOOKING_REQUESTED,
WebhookTriggerEvents.BOOKING_REJECTED,
WebhookTriggerEvents.RECORDING_READY,
WebhookTriggerEvents.INSTANT_MEETING,
] as const,
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const,
};

View File

@ -80,6 +80,7 @@ export default async function getEventTypeById({
slug: true,
description: true,
length: true,
isInstantEvent: true,
offsetStart: true,
hidden: true,
locations: true,

View File

@ -74,6 +74,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
slug: faker.lorem.slug(),
description: faker.lorem.paragraph(),
position: 1,
isInstantEvent: false,
locations: null,
length: 15,
offsetStart: 0,

View File

@ -0,0 +1,36 @@
-- AlterEnum
ALTER TYPE "BookingStatus" ADD VALUE 'awaiting_host';
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'INSTANT_MEETING';
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "isInstantEvent" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "InstantMeetingToken" (
"id" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"teamId" INTEGER NOT NULL,
"bookingId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InstantMeetingToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "InstantMeetingToken_token_key" ON "InstantMeetingToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "InstantMeetingToken_bookingId_key" ON "InstantMeetingToken"("bookingId");
-- CreateIndex
CREATE INDEX "InstantMeetingToken_token_idx" ON "InstantMeetingToken"("token");
-- AddForeignKey
ALTER TABLE "InstantMeetingToken" ADD CONSTRAINT "InstantMeetingToken_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InstantMeetingToken" ADD CONSTRAINT "InstantMeetingToken_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -118,6 +118,7 @@ model EventType {
bookingLimits Json?
/// @zod.custom(imports.intervalLimitsType)
durationLimits Json?
isInstantEvent Boolean @default(false)
@@unique([userId, slug])
@@unique([teamId, slug])
@ -275,43 +276,44 @@ model User {
}
model Team {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
/// @zod.min(1)
name String
name String
/// @zod.min(1)
slug String?
logo String?
logoUrl String?
calVideoLogo String?
appLogo String?
appIconLogo String?
bio String?
hideBranding Boolean @default(false)
isPrivate Boolean @default(false)
hideBookATeamMember Boolean @default(false)
members Membership[]
eventTypes EventType[]
workflows Workflow[]
createdAt DateTime @default(now())
slug String?
logo String?
logoUrl String?
calVideoLogo String?
appLogo String?
appIconLogo String?
bio String?
hideBranding Boolean @default(false)
isPrivate Boolean @default(false)
hideBookATeamMember Boolean @default(false)
members Membership[]
eventTypes EventType[]
workflows Workflow[]
createdAt DateTime @default(now())
/// @zod.custom(imports.teamMetadataSchema)
metadata Json?
theme String?
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
verifiedNumbers VerifiedNumber[]
parentId Int?
parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade)
children Team[] @relation("organization")
orgUsers User[] @relation("scope")
inviteTokens VerificationToken[]
webhooks Webhook[]
timeFormat Int?
timeZone String @default("Europe/London")
weekStart String @default("Sunday")
routingForms App_RoutingForms_Form[]
apiKeys ApiKey[]
credentials Credential[]
accessCodes AccessCode[]
metadata Json?
theme String?
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
verifiedNumbers VerifiedNumber[]
parentId Int?
parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade)
children Team[] @relation("organization")
orgUsers User[] @relation("scope")
inviteTokens VerificationToken[]
webhooks Webhook[]
timeFormat Int?
timeZone String @default("Europe/London")
weekStart String @default("Sunday")
routingForms App_RoutingForms_Form[]
apiKeys ApiKey[]
credentials Credential[]
accessCodes AccessCode[]
instantMeetingTokens InstantMeetingToken[]
@@unique([slug, parentId])
}
@ -353,6 +355,21 @@ model VerificationToken {
@@index([teamId])
}
model InstantMeetingToken {
id Int @id @default(autoincrement())
token String @unique
expires DateTime
teamId Int
team Team @relation(fields: [teamId], references: [id])
bookingId Int? @unique
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([token])
}
model BookingReference {
id Int @id @default(autoincrement())
/// @zod.min(1)
@ -389,10 +406,11 @@ model Attendee {
}
enum BookingStatus {
CANCELLED @map("cancelled")
ACCEPTED @map("accepted")
REJECTED @map("rejected")
PENDING @map("pending")
CANCELLED @map("cancelled")
ACCEPTED @map("accepted")
REJECTED @map("rejected")
PENDING @map("pending")
AWAITING_HOST @map("awaiting_host")
}
model Booking {
@ -435,6 +453,7 @@ model Booking {
isRecorded Boolean @default(false)
iCalUID String? @default("")
iCalSequence Int @default(0)
instantMeetingToken InstantMeetingToken?
@@index([eventTypeId])
@@index([userId])
@ -572,6 +591,7 @@ enum WebhookTriggerEvents {
MEETING_ENDED
MEETING_STARTED
RECORDING_READY
INSTANT_MEETING
}
model Webhook {

View File

@ -552,6 +552,7 @@ export const downloadLinkSchema = z.object({
export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect, "id">]: true } = {
title: true,
description: true,
isInstantEvent: true,
currency: true,
periodDays: true,
position: true,

View File

@ -3,6 +3,7 @@ import { router } from "../../trpc";
import { ZAppByIdInputSchema } from "./appById.schema";
import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema";
import { ZAwayInputSchema } from "./away.schema";
import { ZConnectAndJoinInputSchema } from "./connectAndJoin.schema";
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
import { ZDeleteCredentialInputSchema } from "./deleteCredential.schema";
import { ZDeleteMeInputSchema } from "./deleteMe.schema";
@ -45,6 +46,7 @@ type AppsRouterHandlerCache = {
updateUserDefaultConferencingApp?: typeof import("./updateUserDefaultConferencingApp.handler").updateUserDefaultConferencingAppHandler;
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
};
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
@ -432,4 +434,17 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery({ ctx });
}),
connectAndJoin: authedProcedure.input(ZConnectAndJoinInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.connectAndJoin) {
UNSTABLE_HANDLER_CACHE.connectAndJoin = (await import("./connectAndJoin.handler")).Handler;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.connectAndJoin) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.connectAndJoin({ ctx, input });
}),
});

View File

@ -0,0 +1,218 @@
import { sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import type { TConnectAndJoinInputSchema } from "./connectAndJoin.schema";
type Options = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TConnectAndJoinInputSchema;
};
export const Handler = async ({ ctx, input }: Options) => {
const { token } = input;
const { user } = ctx;
const isLoggedInUserPartOfOrg = !!user.organization.id;
if (!isLoggedInUserPartOfOrg) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Logged in user is not member of Organization" });
}
const tOrganizer = await getTranslation(user?.locale ?? "en", "common");
const instantMeetingToken = await prisma.instantMeetingToken.findUnique({
select: {
expires: true,
teamId: true,
booking: {
select: {
id: true,
status: true,
user: {
select: {
id: true,
},
},
},
},
},
where: {
token,
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
});
// Check if logged in user belong to current team
if (!instantMeetingToken) {
throw new TRPCError({ code: "BAD_REQUEST", message: "token_not_found" });
}
if (!instantMeetingToken.booking?.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "token_invalid_expired" });
}
// Check if token has not expired
if (instantMeetingToken.expires < new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "token_invalid_expired" });
}
// Check if Booking is already accepted by any other user
let isBookingAlreadyAcceptedBySomeoneElse = false;
if (
instantMeetingToken.booking.status === BookingStatus.ACCEPTED &&
instantMeetingToken.booking?.user?.id !== user.id
) {
isBookingAlreadyAcceptedBySomeoneElse = true;
}
// Update User in Booking
const updatedBooking = await prisma.booking.update({
where: {
id: instantMeetingToken.booking.id,
},
data: {
...(isBookingAlreadyAcceptedBySomeoneElse
? { status: BookingStatus.ACCEPTED }
: {
status: BookingStatus.ACCEPTED,
user: {
connect: {
id: user.id,
},
},
}),
},
select: {
title: true,
description: true,
customInputs: true,
startTime: true,
references: true,
endTime: true,
attendees: true,
eventTypeId: true,
responses: true,
metadata: true,
eventType: {
select: {
id: true,
owner: true,
teamId: true,
title: true,
slug: true,
requiresConfirmation: true,
currency: true,
length: true,
description: true,
price: true,
bookingFields: true,
disableGuests: true,
metadata: true,
customInputs: true,
parentId: true,
},
},
location: true,
userId: true,
id: true,
uid: true,
status: true,
scheduledJobs: true,
},
});
const locationVideoCallUrl = bookingMetadataSchema.parse(updatedBooking.metadata || {})?.videoCallUrl;
if (!locationVideoCallUrl) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "meeting_url_not_found" });
}
const videoCallReference = updatedBooking.references.find((reference) => reference.type.includes("_video"));
const videoCallData = {
type: videoCallReference?.type,
id: videoCallReference?.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference?.meetingUrl,
};
const { eventType } = updatedBooking;
// Send Scheduled Email to Organizer and Attendees
const translations = new Map();
const attendeesListPromises = updatedBooking.attendees.map(async (attendee) => {
const locale = attendee.locale ?? "en";
let translate = translations.get(locale);
if (!translate) {
translate = await getTranslation(locale, "common");
translations.set(locale, translate);
}
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate,
locale,
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: updatedBooking?.eventType?.slug as string,
title: updatedBooking.title,
description: updatedBooking.description,
...getCalEventResponses({
bookingFields: eventType?.bookingFields ?? null,
booking: updatedBooking,
}),
customInputs: isPrismaObjOrUndefined(updatedBooking.customInputs),
startTime: updatedBooking.startTime.toISOString(),
endTime: updatedBooking.endTime.toISOString(),
organizer: {
email: user.email,
name: user.name || "Unnamed",
username: user.username || undefined,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: updatedBooking.location ?? "",
uid: updatedBooking.uid,
requiresConfirmation: false,
eventTypeId: eventType?.id,
videoCallData,
};
await sendScheduledEmails(
{
...evt,
},
undefined,
false,
false
);
return { isBookingAlreadyAcceptedBySomeoneElse, meetingUrl: locationVideoCallUrl };
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZConnectAndJoinInputSchema = z.object({
token: z.string(),
});
export type TConnectAndJoinInputSchema = z.infer<typeof ZConnectAndJoinInputSchema>;

View File

@ -6,6 +6,7 @@ import { ZEditLocationInputSchema } from "./editLocation.schema";
import { ZFindInputSchema } from "./find.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
import { bookingsProcedure } from "./util";
@ -16,6 +17,7 @@ type BookingsRouterHandlerCache = {
confirm?: typeof import("./confirm.handler").confirmHandler;
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
find?: typeof import("./find.handler").getHandler;
getInstantBookingLocation?: typeof import("./getInstantBookingLocation.handler").getHandler;
};
const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
@ -124,4 +126,24 @@ export const bookingsRouter = router({
input,
});
}),
getInstantBookingLocation: publicProcedure
.input(ZInstantBookingInputSchema)
.query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getInstantBookingLocation) {
UNSTABLE_HANDLER_CACHE.getInstantBookingLocation = await import(
"./getInstantBookingLocation.handler"
).then((mod) => mod.getHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getInstantBookingLocation) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getInstantBookingLocation({
ctx,
input,
});
}),
});

View File

@ -370,7 +370,7 @@ async function getBookings({
}
return prev;
},
{ ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [] } as {
{ ACCEPTED: [], CANCELLED: [], REJECTED: [], PENDING: [], AWAITING_HOST: [] } as {
[key in BookingStatus]: Date[];
}
);

View File

@ -0,0 +1,39 @@
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { TInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
type GetOptions = {
ctx: {
prisma: PrismaClient;
};
input: TInstantBookingInputSchema;
};
export const getHandler = async ({ ctx, input }: GetOptions) => {
const { prisma } = ctx;
const { bookingId } = input;
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
uid: true,
location: true,
metadata: true,
startTime: true,
status: true,
endTime: true,
description: true,
eventTypeId: true,
},
});
// Don't leak anything private from the booking
return {
booking,
};
};

View File

@ -0,0 +1,9 @@
import { z } from "zod";
const ZInstantBookingInputSchema = z.object({
bookingId: z.number(),
});
export type TInstantBookingInputSchema = z.infer<typeof ZInstantBookingInputSchema>;
export { ZInstantBookingInputSchema };

View File

@ -7,6 +7,7 @@ import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
export const EventTypeUpdateInput = _EventTypeModel
/** Optional fields */
.extend({
isInstantEvent: z.boolean().optional(),
customInputs: z.array(customInputSchema).optional(),
destinationCalendar: _DestinationCalendarModel.pick({
integration: true,

View File

@ -24,6 +24,8 @@ export type VideoApiAdapter =
getRecordings?(roomName: string): Promise<GetRecordingsResponseSchema>;
getRecordingDownloadLink?(recordingId: string): Promise<GetAccessLinkResponseSchema>;
createInstantCalVideoRoom?(endTime: string): Promise<VideoCallData>;
}
| undefined;