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:
parent
6d5983fabc
commit
200ce6932d
|
@ -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} />
|
||||
);
|
||||
};
|
|
@ -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`,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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: (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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"];
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
>;
|
||||
|
|
|
@ -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: "",
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -80,6 +80,7 @@ export default async function getEventTypeById({
|
|||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
isInstantEvent: true,
|
||||
offsetStart: true,
|
||||
hidden: true,
|
||||
locations: true,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZConnectAndJoinInputSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TConnectAndJoinInputSchema = z.infer<typeof ZConnectAndJoinInputSchema>;
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const ZInstantBookingInputSchema = z.object({
|
||||
bookingId: z.number(),
|
||||
});
|
||||
|
||||
export type TInstantBookingInputSchema = z.infer<typeof ZInstantBookingInputSchema>;
|
||||
|
||||
export { ZInstantBookingInputSchema };
|
|
@ -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,
|
||||
|
|
|
@ -24,6 +24,8 @@ export type VideoApiAdapter =
|
|||
getRecordings?(roomName: string): Promise<GetRecordingsResponseSchema>;
|
||||
|
||||
getRecordingDownloadLink?(recordingId: string): Promise<GetAccessLinkResponseSchema>;
|
||||
|
||||
createInstantCalVideoRoom?(endTime: string): Promise<VideoCallData>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user