diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index ed59e2c7..27882cc8 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -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, }); }; diff --git a/apps/web/lib/types/booking.ts b/apps/web/lib/types/booking.ts index 061226ea..d2ffeaa9 100644 --- a/apps/web/lib/types/booking.ts +++ b/apps/web/lib/types/booking.ts @@ -27,6 +27,8 @@ export type BookingCreateBody = { metadata: { [key: string]: string; }; + hasHashedBookingLink: boolean; + hashedLink?: string | null; }; export type BookingResponse = Booking & { diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index b68d0ba0..9019cb25 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -213,6 +213,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { booking, trpcState: ssr.dehydrate(), isDynamicGroupBooking, + hasHashedBookingLink: false, + hashedLink: null, }, }; } diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 591b9b74..385a5021 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -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); diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx new file mode 100644 index 00000000..58508b4a --- /dev/null +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -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; + +export default function Type(props: AvailabilityPageProps) { + return ; +} + +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()({ + 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, + }, + }; +}; diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx new file mode 100644 index 00000000..9b04d355 --- /dev/null +++ b/apps/web/pages/d/[link]/book.tsx @@ -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; + +export default function Book(props: HashLinkPageProps) { + return ; +} + +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()({ + 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; + 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, + }, + }; +} diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index a0f9609b..c844b25f 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -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) => { 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) => { 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) => { 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) => { )} /> + ( + <> + { + setHashedLinkVisible(e?.target.checked); + formMethods.setValue("hashedLink", e?.target.checked); + }} + /> + {hashedLinkVisible && ( +
+
+
+
+ + + + +
+ + The URL will regenerate after each use + +
+
+ )} + + )} + /> +
beforeEventBuffer: true, afterEventBuffer: true, slotInterval: true, + hashedLink: true, successRedirectUrl: true, team: { select: { diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index faa08311..24c46d54 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -98,6 +98,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { eventType: eventTypeObject, booking, isDynamicGroupBooking: false, + hasHashedBookingLink: false, + hashedLink: null, }, }; } diff --git a/apps/web/playwright/hash-my-url.test.ts b/apps/web/playwright/hash-my-url.test.ts new file mode 100644 index 00000000..74bb136d --- /dev/null +++ b/apps/web/playwright/hash-my-url.test.ts @@ -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(); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c679e79b..e900330a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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!", diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index 084711c2..2894d3ce 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -133,6 +133,7 @@ const loggedInViewerRouter = createProtectedRouter() currency: true, position: true, successRedirectUrl: true, + hashedLink: true, users: { select: { id: true, diff --git a/apps/web/server/routers/viewer/eventTypes.tsx b/apps/web/server/routers/viewer/eventTypes.tsx index 7fc2d80d..dd165ced 100644 --- a/apps/web/server/routers/viewer/eventTypes.tsx +++ b/apps/web/server/routers/viewer/eventTypes.tsx @@ -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, diff --git a/packages/prisma/migrations/20220420152505_add_hashed_event_url/migration.sql b/packages/prisma/migrations/20220420152505_add_hashed_event_url/migration.sql new file mode 100644 index 00000000..6890eb7b --- /dev/null +++ b/packages/prisma/migrations/20220420152505_add_hashed_event_url/migration.sql @@ -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; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 41dc5d1a..2c7e14e9 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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