Merge branch 'main' into feat/zapier-app

pr/2623
CarinaWolli 2022-04-29 18:58:58 +02:00
commit 7d38b18e28
41 changed files with 864 additions and 104 deletions

View File

@ -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. -->

View File

@ -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>
</>
);
}

View File

@ -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 && (

View File

@ -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]}

View File

@ -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

View File

@ -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;
}

View File

@ -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);

View File

@ -27,6 +27,8 @@ export type BookingCreateBody = {
metadata: {
[key: string]: string;
};
hasHashedBookingLink: boolean;
hashedLink?: string | null;
};
export type BookingResponse = Booking & {

View File

@ -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() {

View File

@ -213,6 +213,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
hasHashedBookingLink: false,
hashedLink: null,
},
};
}

View File

@ -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);

View File

@ -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,

View File

@ -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, {

View File

@ -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")}>

View File

@ -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")}{" "}

View File

@ -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"

View File

@ -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,
},
};
};

View File

@ -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,
},
};
}

View File

@ -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: {

View File

@ -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();

View File

@ -98,6 +98,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
booking,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
hashedLink: null,
},
};
}

View File

@ -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");
},
});
});
});
});

View File

@ -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();
});
});

View File

@ -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]"}}

View File

@ -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"
}

View File

@ -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: {

View File

@ -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

View File

@ -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");
}

View File

@ -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,

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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

View File

@ -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");

View File

@ -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}
`;

View File

@ -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;
};

View File

@ -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;

View File

@ -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