Hash my url (#2484)
* disposable link model added * disposable model updated * added disposable slug availability page * added disposable book page * added disposable slug hook * added disposable link booking flow * updated schema * checktype fix * added checkfix and schema generated * create link API added * added one time link view on event type list * adjusted schema * fixed disposable visual indicator * expired check and visual indicator added * updated slug for disposable event type * revised schema * WIP * revert desc * revert --WIP * rework based on change of plans * further adjustments * added eventtype option for hashed link * added refresh and delete on update * fixed update call conditions * cleanup * code improvement * clean up * Potential fix for 404 * backward compat for booking page * fixes regular booking for user and team * typefix * updated path for Booking import * checkfix * e2e wip * link err fix * workaround for banner issue in event type update-test * added regenerate hash check * fixed test according to new testID Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>pull/2674/head
parent
d856ef53a7
commit
89b4acdfaf
|
@ -49,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 */
|
||||
|
@ -56,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;
|
||||
|
@ -76,6 +77,8 @@ const BookingPage = ({
|
|||
profile,
|
||||
isDynamicGroupBooking,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
|
@ -290,6 +293,8 @@ const BookingPage = ({
|
|||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -27,6 +27,8 @@ export type BookingCreateBody = {
|
|||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
hasHashedBookingLink: boolean;
|
||||
hashedLink?: string | null;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
|
|
|
@ -213,6 +213,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
booking,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -233,6 +233,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");
|
||||
|
@ -780,6 +781,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);
|
||||
|
|
|
@ -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[];
|
||||
|
@ -1333,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"
|
||||
|
@ -1944,6 +2011,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
slotInterval: true,
|
||||
hashedLink: true,
|
||||
successRedirectUrl: true,
|
||||
team: {
|
||||
select: {
|
||||
|
|
|
@ -98,6 +98,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
eventType: eventTypeObject,
|
||||
booking,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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!",
|
||||
|
|
|
@ -133,6 +133,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
currency: true,
|
||||
position: true,
|
||||
successRedirectUrl: true,
|
||||
hashedLink: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
|
@ -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(
|
||||
|
@ -214,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;
|
||||
|
@ -250,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,
|
||||
|
|
|
@ -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[]
|
||||
|
@ -406,6 +407,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