Merge branch 'main' into feat/zapier-app
commit
7d38b18e28
|
@ -4,6 +4,9 @@
|
|||
|
||||
Fixes # (issue)
|
||||
|
||||
<!-- Please provide a loom video for visual changes to speed up reviews -->
|
||||
Loom Video: https://www.loom.com/
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete options that are not relevant. -->
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function AppsShell({ children }: { children: React.ReactNode }) {
|
|||
<div className="mb-12 block lg:hidden">
|
||||
{status === "authenticated" && <NavTabs tabs={tabs} linkProps={{ shallow: true }} />}
|
||||
</div>
|
||||
<main>{children}</main>
|
||||
<main className="pb-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -172,7 +172,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div className="block items-center sm:flex sm:space-x-4">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
|
@ -190,9 +190,9 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4 sm:-mt-2">
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
|
@ -203,7 +203,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
|||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
|
@ -17,6 +18,7 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
|||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
useIsEmbed,
|
||||
|
@ -47,6 +49,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
|||
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||
|
||||
import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
|
@ -54,7 +57,7 @@ const PhoneInput = dynamic(
|
|||
() => import("@components/ui/form/PhoneInput")
|
||||
) as unknown as typeof PhoneInputType;
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
|
||||
type BookingFormValues = {
|
||||
name: string;
|
||||
|
@ -74,6 +77,8 @@ const BookingPage = ({
|
|||
profile,
|
||||
isDynamicGroupBooking,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
|
@ -195,8 +200,16 @@ const BookingPage = ({
|
|||
};
|
||||
};
|
||||
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: defaultValues(),
|
||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
|
@ -280,6 +293,8 @@ const BookingPage = ({
|
|||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -389,7 +404,7 @@ const BookingPage = ({
|
|||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
{...bookingForm.register("name")}
|
||||
{...bookingForm.register("name", { required: true })}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
|
@ -436,7 +451,6 @@ const BookingPage = ({
|
|||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
defaultChecked={selectedLocation === location.type}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
|
||||
{locationLabels[location.type]}
|
||||
|
|
|
@ -139,7 +139,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
title={t("calendar_error")}
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
|
|
|
@ -20,7 +20,7 @@ export default function LicenseBanner() {
|
|||
- Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_LICENSE_CONSENT === "agree") {
|
||||
if (process.env.NEXT_PUBLIC_LICENSE_CONSENT === "agree" || process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -174,6 +174,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
|
||||
if (event.account) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Incoming connected account" });
|
||||
}
|
||||
|
||||
const handler = webhookHandlers[event.type];
|
||||
if (handler) {
|
||||
await handler(event);
|
||||
|
|
|
@ -27,6 +27,8 @@ export type BookingCreateBody = {
|
|||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
hasHashedBookingLink: boolean;
|
||||
hashedLink?: string | null;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
|
|
|
@ -80,6 +80,10 @@ const nextConfig = {
|
|||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
|
|
|
@ -213,6 +213,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
booking,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -234,6 +234,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
? getGroupName(req.body.user)
|
||||
: getUsernameList(reqBody.user as string);
|
||||
const hasHashedBookingLink = reqBody.hasHashedBookingLink;
|
||||
const eventTypeSlug = reqBody.eventTypeSlug;
|
||||
const eventTypeId = reqBody.eventTypeId;
|
||||
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
|
||||
|
@ -813,6 +814,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
},
|
||||
});
|
||||
// refresh hashed link if used
|
||||
const urlSeed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
|
||||
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
|
||||
|
||||
if (hasHashedBookingLink) {
|
||||
await prisma.hashedLink.update({
|
||||
where: {
|
||||
link: reqBody.hashedLink as string,
|
||||
},
|
||||
data: {
|
||||
link: hashedUid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// booking successful
|
||||
return res.status(201).json(booking);
|
||||
|
|
|
@ -174,14 +174,6 @@ const handler = async (
|
|||
}
|
||||
});
|
||||
|
||||
// Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it
|
||||
const eventManager = new EventManager({
|
||||
credentials: userOwner.credentials,
|
||||
destinationCalendar: userOwner.destinationCalendar,
|
||||
});
|
||||
builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`;
|
||||
await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule);
|
||||
|
||||
// Send emails
|
||||
await sendRequestRescheduleEmail(builder.calendarEvent, {
|
||||
rescheduleLink: builder.rescheduleLink,
|
||||
|
|
|
@ -1,31 +1,60 @@
|
|||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import prisma from "@lib/prisma";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// const username = req.url?.substring(1, req.url.lastIndexOf("/"));
|
||||
const username = req.query.username as string;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
const teamname = req.query.teamname as string;
|
||||
let identity;
|
||||
if (username) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
identity = {
|
||||
name: username,
|
||||
email: user?.email,
|
||||
avatar: user?.avatar,
|
||||
};
|
||||
} else if (teamname) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug: teamname,
|
||||
},
|
||||
select: {
|
||||
logo: true,
|
||||
},
|
||||
});
|
||||
identity = {
|
||||
name: teamname,
|
||||
shouldDefaultBeNameBased: true,
|
||||
avatar: team?.logo,
|
||||
};
|
||||
}
|
||||
|
||||
const emailMd5 = crypto
|
||||
.createHash("md5")
|
||||
.update((user?.email as string) || "guest@example.com")
|
||||
.update((identity?.email as string) || "guest@example.com")
|
||||
.digest("hex");
|
||||
const img = user?.avatar;
|
||||
const img = identity?.avatar;
|
||||
if (!img) {
|
||||
let defaultSrc = defaultAvatarSrc({ md5: emailMd5 });
|
||||
if (identity?.shouldDefaultBeNameBased) {
|
||||
defaultSrc = getPlaceholderAvatar(null, identity.name);
|
||||
}
|
||||
res.writeHead(302, {
|
||||
Location: defaultAvatarSrc({ md5: emailMd5 }),
|
||||
Location: defaultSrc,
|
||||
});
|
||||
|
||||
res.end();
|
||||
} else if (!img.includes("data:image")) {
|
||||
res.writeHead(302, {
|
||||
|
|
|
@ -256,7 +256,7 @@ function Web3Container() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title="Web3" subtitle={t("meet_people_with_the_same_tokens")} />
|
||||
<ShellSubHeading title="Web3" subtitle={t("meet_people_with_the_same_tokens")} className="mt-10" />
|
||||
<div className="lg:col-span-9 lg:pb-8">
|
||||
<List>
|
||||
<ListItem className={classNames("flex-col")}>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useRouter } from "next/router";
|
|||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields";
|
||||
|
@ -61,12 +62,15 @@ export default function Login({
|
|||
|
||||
let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "";
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (!/^https?:\/\//.test(callbackUrl)) {
|
||||
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
|
||||
}
|
||||
|
||||
callbackUrl = getSafeRedirectUrl(callbackUrl);
|
||||
|
||||
const LoginFooter = (
|
||||
<span>
|
||||
{t("dont_have_an_account")}{" "}
|
||||
|
|
|
@ -101,8 +101,8 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
|
|||
className="mb-5 sm:mb-6"
|
||||
/>
|
||||
<div className="space-x-2 text-center rtl:space-x-reverse">
|
||||
<Button color="secondary" onClick={() => router.back()}>
|
||||
{t("back_to_bookings")}
|
||||
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}>
|
||||
{t("reschedule_this")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
return <AvailabilityPage {...props} />;
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrNull(context.query.link) || "";
|
||||
const slug = asStringOrNull(context.query.slug) || "";
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
id: true,
|
||||
title: true,
|
||||
availability: true,
|
||||
description: true,
|
||||
length: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
schedulingType: true,
|
||||
userId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
hidden: true,
|
||||
slug: true,
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
timeZone: true,
|
||||
metadata: true,
|
||||
slotInterval: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
plan: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
link,
|
||||
},
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
|
||||
if (!userId)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
if (hashedLink?.eventType.slug !== slug)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
availability: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users || !users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const [user] = users;
|
||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||
metadata: {} as JSONObject,
|
||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||
slug,
|
||||
});
|
||||
|
||||
const schedule = {
|
||||
...user.schedules.filter(
|
||||
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
|
||||
)[0],
|
||||
};
|
||||
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone,
|
||||
},
|
||||
schedule.availability || user.availability
|
||||
);
|
||||
eventTypeObject.schedule = null;
|
||||
eventTypeObject.availability = [];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
weekStart: user.weekStart,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
isDynamicGroup: false,
|
||||
profile,
|
||||
plan: user.plan,
|
||||
date: dateParam,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
trpcState: ssr.dehydrate(),
|
||||
previousPage: context.req.headers.referer ?? null,
|
||||
booking,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,183 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export type HashLinkPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Book(props: HashLinkPageProps) {
|
||||
return <BookingPage {...props} />;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrThrow(context.query.link as string);
|
||||
const slug = context.query.slug as string;
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
customInputs: true,
|
||||
periodType: true,
|
||||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
metadata: true,
|
||||
periodCountCalendarDays: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
disableGuests: true,
|
||||
userId: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
link,
|
||||
},
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
|
||||
|
||||
if (!userId)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) return { notFound: true };
|
||||
const [user] = users;
|
||||
const eventTypeRaw = hashedLink?.eventType;
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: {
|
||||
in: users.map((user) => user.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
isWeb3Active:
|
||||
web3Credentials && web3Credentials.key
|
||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
||||
: false,
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
};
|
||||
})[0];
|
||||
|
||||
async function getBooking() {
|
||||
return prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: asStringOrThrow(context.query.rescheduleUid),
|
||||
},
|
||||
select: {
|
||||
description: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
|
||||
let booking: Booking | null = null;
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
eventName: null,
|
||||
};
|
||||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
return {
|
||||
props: {
|
||||
locationLabels: getLocationLabels(t),
|
||||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: true,
|
||||
hashedLink: link,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -2,6 +2,7 @@ import { GlobeAltIcon, PhoneIcon, XIcon } from "@heroicons/react/outline";
|
|||
import {
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DocumentDuplicateIcon,
|
||||
DocumentIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
|
@ -52,6 +53,7 @@ import { ClientSuspense } from "@components/ClientSuspense";
|
|||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||
|
@ -262,6 +264,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
|
||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTokens = async () => {
|
||||
|
@ -442,6 +445,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
team ? `team/${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
|
||||
eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const mapUserToValue = ({
|
||||
id,
|
||||
name,
|
||||
|
@ -471,6 +478,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
currency: string;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: boolean;
|
||||
locations: { type: LocationType; address?: string; link?: string }[];
|
||||
customInputs: EventTypeCustomInput[];
|
||||
users: string[];
|
||||
|
@ -1117,7 +1125,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
open={advancedSettingsVisible}
|
||||
onOpenChange={() => setAdvancedSettingsVisible(!advancedSettingsVisible)}>
|
||||
<>
|
||||
<CollapsibleTrigger type="button" className="flex w-full">
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
data-testid="show-advanced-settings"
|
||||
className="flex w-full">
|
||||
<ChevronRightIcon
|
||||
className={`${
|
||||
advancedSettingsVisible ? "rotate-90 transform" : ""
|
||||
|
@ -1127,7 +1138,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
{t("show_advanced_settings")}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-6">
|
||||
<CollapsibleContent data-testid="advanced-settings-content" className="mt-4 space-y-6">
|
||||
{/**
|
||||
* Only display calendar selector if user has connected calendars AND if it's not
|
||||
* a team event. Since we don't have logic to handle each attende calendar (for now).
|
||||
|
@ -1330,6 +1341,65 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="hashedLink"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hashedLink ? true : false}
|
||||
render={() => (
|
||||
<>
|
||||
<CheckboxField
|
||||
id="hashedLink"
|
||||
name="hashedLink"
|
||||
label={t("hashed_link")}
|
||||
description={t("hashed_link_description")}
|
||||
defaultChecked={eventType.hashedLink ? true : false}
|
||||
onChange={(e) => {
|
||||
setHashedLinkVisible(e?.target.checked);
|
||||
formMethods.setValue("hashedLink", e?.target.checked);
|
||||
}}
|
||||
/>
|
||||
{hashedLinkVisible && (
|
||||
<div className="block items-center sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0"></div>
|
||||
<div className="w-full">
|
||||
<div className="relative mt-1 flex w-full">
|
||||
<input
|
||||
disabled
|
||||
data-testid="generated-hash-url"
|
||||
type="text"
|
||||
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
|
||||
defaultValue={placeholderHashedLink}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
eventType.hashedLink
|
||||
? t("copy_to_clipboard")
|
||||
: t("enabled_after_update")
|
||||
}>
|
||||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
if (eventType.hashedLink) {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
showToast("Link copied!", "success");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
className="text-md flex items-center border border-gray-300 px-2 py-1 text-sm font-medium text-gray-700 ltr:rounded-r-sm ltr:border-l-0 rtl:rounded-l-sm rtl:border-r-0">
|
||||
<DocumentDuplicateIcon className="w-6 p-1 text-neutral-500" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
The URL will regenerate after each use
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<hr className="my-2 border-neutral-200" />
|
||||
<Controller
|
||||
name="minimumBookingNotice"
|
||||
|
@ -1663,7 +1733,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
<Button href="/event-types" color="secondary" tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isLoading}>
|
||||
<Button type="submit" data-testid="update-eventtype" disabled={updateMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -1941,6 +2011,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
slotInterval: true,
|
||||
hashedLink: true,
|
||||
successRedirectUrl: true,
|
||||
team: {
|
||||
select: {
|
||||
|
|
|
@ -18,6 +18,7 @@ import Link from "next/link";
|
|||
import { useRouter } from "next/router";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
@ -452,45 +453,48 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
);
|
||||
};
|
||||
|
||||
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => (
|
||||
<div className="mb-4 flex">
|
||||
<Link href="/settings/teams">
|
||||
<a>
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
imageSrc={profile?.image || undefined}
|
||||
size={8}
|
||||
className="mt-1 inline ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div>
|
||||
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => {
|
||||
console.log(profile.slug);
|
||||
return (
|
||||
<div className="mb-4 flex">
|
||||
<Link href="/settings/teams">
|
||||
<a className="font-bold">{profile?.name || ""}</a>
|
||||
<a>
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
imageSrc={`${WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
size={8}
|
||||
className="mt-1 inline ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
{membershipCount && (
|
||||
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
|
||||
<Link href="/settings/teams">
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
<UsersIcon className="mr-1 -mt-px inline h-3 w-3" />
|
||||
{membershipCount}
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{profile?.slug && (
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${profile.slug}`}>
|
||||
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
|
||||
"https://",
|
||||
""
|
||||
)}/${profile.slug}`}</a>
|
||||
<div>
|
||||
<Link href="/settings/teams">
|
||||
<a className="font-bold">{profile?.name || ""}</a>
|
||||
</Link>
|
||||
)}
|
||||
{membershipCount && (
|
||||
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
|
||||
<Link href="/settings/teams">
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
<UsersIcon className="mr-1 -mt-px inline h-3 w-3" />
|
||||
{membershipCount}
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{profile?.slug && (
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${profile.slug}`}>
|
||||
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
|
||||
"https://",
|
||||
""
|
||||
)}/${profile.slug}`}</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypeProps) => {
|
||||
const { t } = useLocale();
|
||||
|
|
|
@ -98,6 +98,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
eventType: eventTypeObject,
|
||||
booking,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -69,6 +69,25 @@ test.describe("Event Types tests", () => {
|
|||
await expect(formTitle).toBe(firstTitle);
|
||||
await expect(formSlug).toBe(firstSlug);
|
||||
});
|
||||
test("edit first event", async ({ page }) => {
|
||||
const $eventTypes = await page.locator("[data-testid=event-types] > *");
|
||||
const firstEventTypeElement = await $eventTypes.first();
|
||||
await firstEventTypeElement.click();
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return !!url.pathname.match(/\/event-types\/.+/);
|
||||
},
|
||||
});
|
||||
await expect(page.locator("[data-testid=advanced-settings-content]")).not.toBeVisible();
|
||||
await page.locator("[data-testid=show-advanced-settings]").click();
|
||||
await expect(page.locator("[data-testid=advanced-settings-content]")).toBeVisible();
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return url.pathname.endsWith("/event-types");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("free user", () => {
|
||||
|
@ -88,5 +107,25 @@ test.describe("Event Types tests", () => {
|
|||
test("can not add new event type", async ({ page }) => {
|
||||
await expect(page.locator("[data-testid=new-event-type]")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("edit first event", async ({ page }) => {
|
||||
const $eventTypes = await page.locator("[data-testid=event-types] > *");
|
||||
const firstEventTypeElement = await $eventTypes.first();
|
||||
await firstEventTypeElement.click();
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return !!url.pathname.match(/\/event-types\/.+/);
|
||||
},
|
||||
});
|
||||
await expect(page.locator("[data-testid=advanced-settings-content]")).not.toBeVisible();
|
||||
await page.locator("[data-testid=show-advanced-settings]").click();
|
||||
await expect(page.locator("[data-testid=advanced-settings-content]")).toBeVisible();
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return url.pathname.endsWith("/event-types");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { deleteAllBookingsByEmail } from "./lib/teardown";
|
||||
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe("hash my url", () => {
|
||||
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
||||
let $url = "";
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await deleteAllBookingsByEmail("pro@example.com");
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// delete test bookings
|
||||
await deleteAllBookingsByEmail("pro@example.com");
|
||||
});
|
||||
|
||||
test("generate url hash", async ({ page }) => {
|
||||
// await page.pause();
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
||||
// We wait for the page to load
|
||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@id="hashedLink"]');
|
||||
await page.click('//*[@id="hashedLink"]');
|
||||
// click update
|
||||
await page.focus('//button[@type="submit"]');
|
||||
await page.keyboard.press("Enter");
|
||||
});
|
||||
|
||||
test("book using generated url hash", async ({ page }) => {
|
||||
// await page.pause();
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
||||
// We wait for the page to load
|
||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
await page.goto($url);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
|
||||
// Make sure we're navigated to the success page
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname.endsWith("/success");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("hash regenerates after successful booking", async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
||||
// We wait for the page to load
|
||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
expect($url !== $newUrl).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -1 +1 @@
|
|||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":null,"additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30min","title":"30min between Pro Example and Test Testson","description":"","additionalNotes":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"Pro Example","email":"pro@example.com","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"uid":"[redacted/dynamic]","metadata":{},"additionInformation":"[redacted/dynamic]"}}
|
|
@ -500,6 +500,7 @@
|
|||
"url": "URL",
|
||||
"hidden": "Hidden",
|
||||
"readonly": "Readonly",
|
||||
"one_time_link": "One-time link",
|
||||
"plan_description": "You're currently on the {{plan}} plan.",
|
||||
"plan_upgrade_invitation": "Upgrade your account to the pro plan to unlock all of the features we have to offer.",
|
||||
"plan_upgrade": "You need to upgrade your plan to have more than one active event type.",
|
||||
|
@ -583,6 +584,8 @@
|
|||
"opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
|
||||
"disable_guests": "Disable Guests",
|
||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||
"hashed_link": "Generate hashed URL",
|
||||
"hashed_link_description": "Generate a hashed URL to share without exposing your Cal username",
|
||||
"invitees_can_schedule": "Invitees can schedule",
|
||||
"date_range": "Date Range",
|
||||
"calendar_days": "calendar days",
|
||||
|
@ -745,6 +748,7 @@
|
|||
"success_api_key_created_bold_tagline": "Save this API key somewhere safe.",
|
||||
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
|
||||
"copy_to_clipboard": "Copy to clipboard",
|
||||
"enabled_after_update": "Enabled after update",
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"revoke_api_key": "Revoke API key",
|
||||
"api_key_copied": "API key copied!",
|
||||
|
@ -778,5 +782,6 @@
|
|||
"test_your_trigger": "4. Test your Trigger.",
|
||||
"you_are_set": "5. You're set!",
|
||||
"install_zapier_app": "Please first install the Zapier App in the app store.",
|
||||
"go_to_app_store": "Go to App Store"
|
||||
"go_to_app_store": "Go to App Store",
|
||||
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions"
|
||||
}
|
||||
|
|
|
@ -133,11 +133,11 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
currency: true,
|
||||
position: true,
|
||||
successRedirectUrl: true,
|
||||
hashedLink: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
|
@ -154,7 +154,6 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
avatar: true,
|
||||
plan: true,
|
||||
teams: {
|
||||
where: {
|
||||
|
@ -230,7 +229,6 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
profile: {
|
||||
slug: typeof user["username"];
|
||||
name: typeof user["name"];
|
||||
image: typeof user["avatar"];
|
||||
};
|
||||
metadata: {
|
||||
membershipCount: number;
|
||||
|
@ -255,7 +253,6 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
profile: {
|
||||
slug: user.username,
|
||||
name: user.name,
|
||||
image: user.avatar,
|
||||
},
|
||||
eventTypes: _.orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]),
|
||||
metadata: {
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
|
@ -89,6 +91,7 @@ const EventTypeUpdateInput = _EventTypeModel
|
|||
}),
|
||||
users: z.array(stringOrNumber).optional(),
|
||||
schedule: z.number().optional(),
|
||||
hashedLink: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
.merge(
|
||||
|
@ -117,6 +120,7 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
|
||||
const data: Prisma.EventTypeCreateInput = {
|
||||
...rest,
|
||||
userId: teamId ? undefined : userId,
|
||||
users: {
|
||||
connect: {
|
||||
id: userId,
|
||||
|
@ -213,8 +217,17 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
.mutation("update", {
|
||||
input: EventTypeUpdateInput.strict(),
|
||||
async resolve({ ctx, input }) {
|
||||
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
|
||||
input;
|
||||
const {
|
||||
schedule,
|
||||
periodType,
|
||||
locations,
|
||||
destinationCalendar,
|
||||
customInputs,
|
||||
users,
|
||||
id,
|
||||
hashedLink,
|
||||
...rest
|
||||
} = input;
|
||||
assertValidUrl(input.successRedirectUrl);
|
||||
const data: Prisma.EventTypeUpdateInput = rest;
|
||||
data.locations = locations ?? undefined;
|
||||
|
@ -249,6 +262,48 @@ export const eventTypesRouter = createProtectedRouter()
|
|||
};
|
||||
}
|
||||
|
||||
const connectedLink = await ctx.prisma.hashedLink.findFirst({
|
||||
where: {
|
||||
eventTypeId: input.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (hashedLink) {
|
||||
// check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection
|
||||
if (!connectedLink) {
|
||||
const translator = short();
|
||||
const seed = `${input.eventName}:${input.id}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
// create a hashed link
|
||||
await ctx.prisma.hashedLink.upsert({
|
||||
where: {
|
||||
eventTypeId: input.id,
|
||||
},
|
||||
update: {
|
||||
link: uid,
|
||||
},
|
||||
create: {
|
||||
link: uid,
|
||||
eventType: {
|
||||
connect: { id: input.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// check if hashed connection exists. If it does, disconnect
|
||||
if (connectedLink) {
|
||||
await ctx.prisma.hashedLink.delete({
|
||||
where: {
|
||||
eventTypeId: input.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const eventType = await ctx.prisma.eventType.update({
|
||||
where: { id },
|
||||
data,
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit ac4ce5571f91b49c05cb19e71f8c58b5d3f6d131
|
||||
Subproject commit 300d090ebe5772b2b22432931ba1a837b4e5e759
|
|
@ -2,6 +2,7 @@ import { google } from "googleapis";
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
|
||||
|
@ -10,7 +11,6 @@ const credentials = process.env.GOOGLE_API_CREDENTIALS;
|
|||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { code } = req.query;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
res.status(400).json({ message: "`code` must be a string" });
|
||||
return;
|
||||
|
@ -19,7 +19,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
res.status(400).json({ message: "There are no Google Credentials installed." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { client_secret, client_id } = JSON.parse(credentials).web;
|
||||
const redirect_uri = WEBAPP_URL + "/api/integrations/googlecalendar/callback";
|
||||
|
||||
|
@ -41,5 +40,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
},
|
||||
});
|
||||
const state = decodeOAuthState(req);
|
||||
res.redirect(state?.returnTo ?? "/apps/installed");
|
||||
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
|
||||
}
|
||||
|
|
|
@ -72,21 +72,21 @@ export default class GoogleCalendarService implements Calendar {
|
|||
};
|
||||
};
|
||||
|
||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
return new Promise((resolve, reject) =>
|
||||
this.auth.getToken().then((myGoogleAuth) => {
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
summary: event.title,
|
||||
description: getRichDescription(event),
|
||||
summary: calEventRaw.title,
|
||||
description: getRichDescription(calEventRaw),
|
||||
start: {
|
||||
dateTime: event.startTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
dateTime: calEventRaw.startTime,
|
||||
timeZone: calEventRaw.organizer.timeZone,
|
||||
},
|
||||
end: {
|
||||
dateTime: event.endTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
dateTime: calEventRaw.endTime,
|
||||
timeZone: calEventRaw.organizer.timeZone,
|
||||
},
|
||||
attendees: event.attendees.map((attendee) => ({
|
||||
attendees: calEventRaw.attendees.map((attendee) => ({
|
||||
...attendee,
|
||||
responseStatus: "accepted",
|
||||
})),
|
||||
|
@ -95,23 +95,21 @@ export default class GoogleCalendarService implements Calendar {
|
|||
},
|
||||
};
|
||||
|
||||
if (event.location) {
|
||||
payload["location"] = getLocation(event);
|
||||
if (calEventRaw.location) {
|
||||
payload["location"] = getLocation(calEventRaw);
|
||||
}
|
||||
|
||||
if (event.conferenceData && event.location === "integrations:google:meet") {
|
||||
payload["conferenceData"] = event.conferenceData;
|
||||
if (calEventRaw.conferenceData && calEventRaw.location === "integrations:google:meet") {
|
||||
payload["conferenceData"] = calEventRaw.conferenceData;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.insert(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
calendarId: event.destinationCalendar?.externalId
|
||||
? event.destinationCalendar.externalId
|
||||
calendarId: calEventRaw.destinationCalendar?.externalId
|
||||
? calEventRaw.destinationCalendar.externalId
|
||||
: "primary",
|
||||
requestBody: payload,
|
||||
conferenceDataVersion: 1,
|
||||
|
@ -121,6 +119,22 @@ export default class GoogleCalendarService implements Calendar {
|
|||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
calendar.events.patch({
|
||||
// Update the same event but this time we know the hangout link
|
||||
calendarId: calEventRaw.destinationCalendar?.externalId
|
||||
? calEventRaw.destinationCalendar.externalId
|
||||
: "primary",
|
||||
auth: myGoogleAuth,
|
||||
eventId: event.data.id || "",
|
||||
requestBody: {
|
||||
description: getRichDescription({
|
||||
...calEventRaw,
|
||||
additionInformation: { hangoutLink: event.data.hangoutLink || "" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return resolve({
|
||||
uid: "",
|
||||
...event.data,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { TokenResponseIF } from "@hubspot/api-client/lib/codegen/oauth/models/To
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
|
||||
|
@ -52,5 +53,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
});
|
||||
|
||||
const state = decodeOAuthState(req);
|
||||
res.redirect(state?.returnTo ?? "/apps/installed");
|
||||
res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { BASE_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
|
||||
|
@ -62,5 +63,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
});
|
||||
|
||||
const state = decodeOAuthState(req);
|
||||
return res.redirect(state?.returnTo ?? "/apps/installed");
|
||||
return res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { BASE_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
|
||||
|
@ -63,5 +64,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
});
|
||||
|
||||
const state = decodeOAuthState(req);
|
||||
return res.redirect(state?.returnTo ?? "/apps/installed");
|
||||
return res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed");
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { stringify } from "querystring";
|
|||
import prisma from "@calcom/prisma";
|
||||
|
||||
const client_id = process.env.SLACK_CLIENT_ID;
|
||||
const scopes = ["commands", "users:read", "users:read.email", "chat:write.public"];
|
||||
const scopes = ["commands", "users:read", "users:read.email", "chat:write", "chat:write.public"];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { v5 as uuidv5 } from "uuid";
|
|||
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { CalendarEventClass } from "./class";
|
||||
|
||||
|
@ -124,6 +125,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
|||
slug: true,
|
||||
},
|
||||
},
|
||||
description: true,
|
||||
slug: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
|
@ -263,6 +265,10 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
|||
this.calendarEvent.description = description;
|
||||
}
|
||||
|
||||
public setNotes(notes: CalendarEvent["additionalNotes"]) {
|
||||
this.calendarEvent.additionalNotes = notes;
|
||||
}
|
||||
|
||||
public setCancellationReason(cancellationReason: CalendarEventClass["cancellationReason"]) {
|
||||
this.calendarEvent.cancellationReason = cancellationReason;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ class CalendarEventClass implements CalendarEvent {
|
|||
cancellationReason?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
hideCalendarNotes?: boolean;
|
||||
additionalNotes?: string | null | undefined;
|
||||
|
||||
constructor(initProps?: CalendarEvent) {
|
||||
// If more parameters are given we update this
|
||||
|
|
|
@ -27,6 +27,8 @@ export class CalendarEventDirector {
|
|||
this.builder.setLocation(this.existingBooking.location);
|
||||
this.builder.setUId(this.existingBooking.uid);
|
||||
this.builder.setCancellationReason(this.cancellationReason);
|
||||
this.builder.setDescription(this.builder.eventType.description);
|
||||
this.builder.setNotes(this.existingBooking.description);
|
||||
this.builder.buildRescheduleLink(this.existingBooking.uid);
|
||||
} else {
|
||||
throw new Error("buildForRescheduleEmail.missing.params.required");
|
||||
|
|
|
@ -46,6 +46,9 @@ ${organizer + attendees}
|
|||
};
|
||||
|
||||
export const getAdditionalNotes = (calEvent: CalendarEvent) => {
|
||||
if (!calEvent.additionalNotes) {
|
||||
return "";
|
||||
}
|
||||
return `
|
||||
${calEvent.organizer.language.translate("additional_notes")}:
|
||||
${calEvent.additionalNotes}
|
||||
|
@ -53,6 +56,9 @@ ${calEvent.additionalNotes}
|
|||
};
|
||||
|
||||
export const getDescription = (calEvent: CalendarEvent) => {
|
||||
if (!calEvent.description) {
|
||||
return "";
|
||||
}
|
||||
return `\n${calEvent.attendees[0].language.translate("description")}
|
||||
${calEvent.description}
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
// It ensures that redirection URL safe where it is accepted through a query params or other means where user can change it.
|
||||
export const getSafeRedirectUrl = (url: string | undefined) => {
|
||||
url = url || "";
|
||||
if (url.search(/^https?:\/\//) === -1) {
|
||||
throw new Error("Pass an absolute URL");
|
||||
}
|
||||
|
||||
// Avoid open redirection security vulnerability
|
||||
if (!url.startsWith(WEBAPP_URL) && !url.startsWith(WEBSITE_URL)) {
|
||||
url = `${WEBAPP_URL}/`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "BookingReference" DROP CONSTRAINT "BookingReference_bookingId_fkey";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "HashedLink" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"link" TEXT NOT NULL,
|
||||
"eventTypeId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "HashedLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HashedLink_link_key" ON "HashedLink"("link");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HashedLink_eventTypeId_key" ON "HashedLink"("eventTypeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingReference" ADD CONSTRAINT "BookingReference_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HashedLink" ADD CONSTRAINT "HashedLink_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -45,6 +45,7 @@ model EventType {
|
|||
userId Int?
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
teamId Int?
|
||||
hashedLink HashedLink?
|
||||
bookings Booking[]
|
||||
availability Availability[]
|
||||
webhooks Webhook[]
|
||||
|
@ -418,6 +419,13 @@ model ApiKey {
|
|||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model HashedLink {
|
||||
id Int @id @default(autoincrement())
|
||||
link String @unique()
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventTypeId Int @unique
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId Int
|
||||
|
|
Loading…
Reference in New Issue