diff --git a/apps/web/components/eventtype/EventInstantTab.tsx b/apps/web/components/eventtype/EventInstantTab.tsx new file mode 100644 index 0000000000..8182a61b2b --- /dev/null +++ b/apps/web/components/eventtype/EventInstantTab.tsx @@ -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 & { isTeamEvent: boolean }) => { + const paymentAppData = getPaymentAppData(eventType); + + const requirePayment = paymentAppData.price > 0; + + return ( + + ); +}; diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index a618d5e793..7a9a517eff 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -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`, diff --git a/apps/web/components/eventtype/InstantEventController.tsx b/apps/web/components/eventtype/InstantEventController.tsx new file mode 100644 index 0000000000..ec34df6632 --- /dev/null +++ b/apps/web/components/eventtype/InstantEventController.tsx @@ -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(eventType?.isInstantEvent ?? false); + const formMethods = useFormContext(); + + 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 ( + +
+ {!isOrg || !isTeamEvent ? ( + {t("upgrade")}} + /> + ) : ( +
+ {paymentEnabled ? ( + + ) : ( + <> + + { + if (!e) { + formMethods.setValue("isInstantEvent", false); + setInstantEventState(false); + } else { + formMethods.setValue("isInstantEvent", true); + setInstantEventState(true); + } + }}> +
+ {instantEventState && ( +
+

{t("warning_payment_instant_meeting_event")}

+
+ )} +
+
+ + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/pages/api/book/instant-event.ts b/apps/web/pages/api/book/instant-event.ts new file mode 100644 index 0000000000..57fa795f5d --- /dev/null +++ b/apps/web/pages/api/book/instant-event.ts @@ -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); diff --git a/apps/web/pages/connect-and-join.tsx b/apps/web/pages/connect-and-join.tsx new file mode 100644 index 0000000000..afc84f3e88 --- /dev/null +++ b/apps/web/pages/connect-and-join.tsx @@ -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(null); + const [errorMessage, setErrorMessage] = useState(); + + 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

{t("loading")}

; + + if (!token) return

{t("token_not_found")}

; + + return ( +
+ {session ? ( + + {meetingUrl ? ( +
+ + Some other host already accepted the meeting. Do you still want to join? + + Continue to Meeting Url + + +
+ ) : ( + + )} + {errorMessage && } +
+ } + /> + ) : ( +
{t("you_must_be_logged_in_to", { url: WEBAPP_URL })}
+ )} + + ); +} + +ConnectAndJoin.requiresLicense = true; +ConnectAndJoin.PageWrapper = PageWrapper; + +export default ConnectAndJoin; diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index b301363bfc..5d8069c17e 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -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: , limits: , advanced: , + instant: , recurring: , apps: , workflows: ( diff --git a/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx b/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx new file mode 100644 index 0000000000..b0b46e89ce --- /dev/null +++ b/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx @@ -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 & EmbedProps; + +export default function Type({ + slug, + user, + booking, + away, + isEmbed, + isBrandingHidden, + entity, + duration, +}: PageProps) { + return ( +
+ + +
+ ); +} + +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, + }, + }; +}; diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 55bbabb7c6..781f17d4b8 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -31,6 +31,7 @@ export default function Type({ isBrandingHidden, entity, duration, + isInstantMeeting, }: PageProps) { return (
@@ -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, }, }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 062d65f923..4fb8a6f3a8 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 5c070c2ea1..79f3e20529 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -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 => { try { const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=172800`).then( diff --git a/packages/config/tailwind-preset.js b/packages/config/tailwind-preset.js index 90fcaae250..6e70d0c465 100644 --- a/packages/config/tailwind-preset.js +++ b/packages/config/tailwind-preset.js @@ -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: { diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 7bb3118949..854bf4a996 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -204,6 +204,28 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => { return videoAdapter?.createMeeting(calEvent); }; +export const createInstantMeetingWithCalVideo = async (endTime: string) => { + let dailyAppKeys: Awaited>; + 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 => { diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index fe3a1d85bb..7ffbb1c9d1 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -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 ? : null} + {bookerState !== "booking" && event.data?.isInstantEvent && ( +
+ +
+ )}
- -
- + {!isInstantMeeting && ( + +
+ + )} { ); }; + +export const InstantBooking = () => { + const { t } = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + + return ( +
+
+ {/* TODO: max. show 4 people here */} +
+ +
+
+
{t("dont_want_to_wait")}
+
+
+ +
+
+ ); +}; diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index dc8be1e773..9bdae54dd8 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -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; 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(); + type BookingFormValues = { locationType?: EventLocationType["type"]; responses: z.infer["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"]) && (
)}
- {!!onCancel && ( - + ) : ( + <> + {!!onCancel && ( + + )} + + )} -
+
); }; +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 ( + + +
+ {hasInstantMeetingTokenExpired ? ( +
+

{t("please_book_a_time_sometime_later")}

+ +
+ ) : ( +
+

{t("connecting_you_to_someone")}

+

{t("please_do_not_close_this_tab")}

+ +
+ )} +
+
+
+ ); +}; + 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, // eslint-disable-next-line @typescript-eslint/no-explicit-any recurringBookingMutation: UseMutationResult, + createInstantBookingMutation: UseMutationResult, 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 ? ( <> diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx index 42803e7ae2..1a754d3e6f 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx @@ -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
{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 = diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index fae38dfb88..da950c23fa 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -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((set, get) => ({ isTeamEvent, durationConfig, org, + isInstantMeeting, }: StoreInitializeType) => { const selectedDateInStore = get().selectedDate; @@ -270,6 +274,21 @@ export const useBookerStore = create((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, ]); }; diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index e36c2c2d8e..cde630fb1c 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -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"; diff --git a/packages/features/bookings/components/event-meta/Details.tsx b/packages/features/bookings/components/event-meta/Details.tsx index 6a5bac8202..ead5e44f32 100644 --- a/packages/features/bookings/components/event-meta/Details.tsx +++ b/packages/features/bookings/components/event-meta/Details.tsx @@ -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 ( diff --git a/packages/features/bookings/lib/create-instant-booking.ts b/packages/features/bookings/lib/create-instant-booking.ts new file mode 100644 index 0000000000..1431e2ef5e --- /dev/null +++ b/packages/features/bookings/lib/create-instant-booking.ts @@ -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("/api/book/instant-event", data); + return response; +}; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5c878434f5..c7d341e883 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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; customInputs?: z.infer["customInputs"]; diff --git a/packages/features/bookings/lib/index.ts b/packages/features/bookings/lib/index.ts index a9a36c1264..c0602ec11d 100644 --- a/packages/features/bookings/lib/index.ts +++ b/packages/features/bookings/lib/index.ts @@ -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"; diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 7c2b070133..ef60006518 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -32,3 +32,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & { export type BookingResponse = Awaited< ReturnType >; + +export type InstatBookingResponse = Awaited< + ReturnType +>; diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx index 8cb854ae09..1e508b733a 100644 --- a/packages/features/calendars/weeklyview/components/event/Event.tsx +++ b/packages/features/calendars/weeklyview/components/event/Event.tsx @@ -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: "", }, }, diff --git a/packages/features/calendars/weeklyview/components/spinner/Spinner.tsx b/packages/features/calendars/weeklyview/components/spinner/Spinner.tsx index b8826b9937..2d5dac0a57 100644 --- a/packages/features/calendars/weeklyview/components/spinner/Spinner.tsx +++ b/packages/features/calendars/weeklyview/components/spinner/Spinner.tsx @@ -1,5 +1,11 @@ -export const Spinner = () => ( -
+import { classNames } from "@calcom/lib"; + +export const Spinner = ({ className }: { className?: string }) => ( +
()({ 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, }; }; diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts new file mode 100644 index 0000000000..62c482b0d8 --- /dev/null +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -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; +}) => { + 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; diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index d55061b06f..fc1ba77b34 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -41,6 +41,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record 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, diff --git a/packages/features/webhooks/lib/constants.ts b/packages/features/webhooks/lib/constants.ts index 8630da4879..317e88e16e 100644 --- a/packages/features/webhooks/lib/constants.ts +++ b/packages/features/webhooks/lib/constants.ts @@ -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, }; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index b95cfd692c..94b6e14cf0 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -80,6 +80,7 @@ export default async function getEventTypeById({ slug: true, description: true, length: true, + isInstantEvent: true, offsetStart: true, hidden: true, locations: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 6f9ced75a2..b6238d9349 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -74,6 +74,7 @@ export const buildEventType = (eventType?: Partial): EventType => { slug: faker.lorem.slug(), description: faker.lorem.paragraph(), position: 1, + isInstantEvent: false, locations: null, length: 15, offsetStart: 0, diff --git a/packages/prisma/migrations/20231213153230_add_instant_meeting/migration.sql b/packages/prisma/migrations/20231213153230_add_instant_meeting/migration.sql new file mode 100644 index 0000000000..7be3c8fa79 --- /dev/null +++ b/packages/prisma/migrations/20231213153230_add_instant_meeting/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 7560ce3b1a..abb778499e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 7741375f0f..99db8fd19c 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -552,6 +552,7 @@ export const downloadLinkSchema = z.object({ export const allManagedEventTypeProps: { [k in keyof Omit]: true } = { title: true, description: true, + isInstantEvent: true, currency: true, periodDays: true, position: true, diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx index 5dc20024bb..1d112aaa42 100644 --- a/packages/trpc/server/routers/loggedInViewer/_router.tsx +++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx @@ -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 }); + }), }); diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts new file mode 100644 index 0000000000..32fc2c70f9 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts @@ -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; + }; + 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 }; +}; diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.schema.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.schema.ts new file mode 100644 index 0000000000..43ae27a2f5 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZConnectAndJoinInputSchema = z.object({ + token: z.string(), +}); + +export type TConnectAndJoinInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 294541b017..bece7c9c2b 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 12259073aa..1a26e0e9d2 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -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[]; } ); diff --git a/packages/trpc/server/routers/viewer/bookings/getInstantBookingLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/getInstantBookingLocation.handler.ts new file mode 100644 index 0000000000..f1e808ef34 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getInstantBookingLocation.handler.ts @@ -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, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/getInstantBookingLocation.schema.ts b/packages/trpc/server/routers/viewer/bookings/getInstantBookingLocation.schema.ts new file mode 100644 index 0000000000..fd338c7663 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getInstantBookingLocation.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +const ZInstantBookingInputSchema = z.object({ + bookingId: z.number(), +}); + +export type TInstantBookingInputSchema = z.infer; + +export { ZInstantBookingInputSchema }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index eeec8f8919..26e92e99bd 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -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, diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts index cc1dfd109e..45553f6cc4 100644 --- a/packages/types/VideoApiAdapter.d.ts +++ b/packages/types/VideoApiAdapter.d.ts @@ -24,6 +24,8 @@ export type VideoApiAdapter = getRecordings?(roomName: string): Promise; getRecordingDownloadLink?(recordingId: string): Promise; + + createInstantCalVideoRoom?(endTime: string): Promise; } | undefined;