diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx index 939b3718168daa..210836b8cc334b 100644 --- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx @@ -127,6 +127,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { queryDuration, eventData.length ), + durationConfig: eventData.metadata?.multipleDuration ?? [], booking, user: name, slug, diff --git a/apps/web/modules/d/[link]/d-type-view.tsx b/apps/web/modules/d/[link]/d-type-view.tsx index 60355221f56c8f..46f7315074293d 100644 --- a/apps/web/modules/d/[link]/d-type-view.tsx +++ b/apps/web/modules/d/[link]/d-type-view.tsx @@ -16,6 +16,7 @@ export default function Type({ entity, duration, hashedLink, + durationConfig, }: PageProps) { return (
@@ -35,6 +36,7 @@ export default function Type({ entity={entity} duration={duration} hashedLink={hashedLink} + durationConfig={durationConfig} />
); diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index 4ff1eff4f9672e..647965d38c1631 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -19,7 +19,6 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF import Shell from "@calcom/features/shell/Shell"; import { parseEventTypeColor } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; -import { WEBSITE_URL } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; @@ -250,6 +249,7 @@ export const InfiniteEventTypeList = ({ const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState( null ); + const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState>({}); const utils = trpc.useUtils(); const mutation = trpc.viewer.eventTypeOrder.useMutation({ @@ -443,8 +443,11 @@ export const InfiniteEventTypeList = ({ return page?.eventTypes?.map((type, index) => { const embedLink = `${group.profile.slug}/${type.slug}`; const calLink = `${bookerUrl}/${embedLink}`; - const isPrivateURLEnabled = type.hashedLink?.link; - const placeholderHashedLink = `${WEBSITE_URL}/d/${type.hashedLink?.link}/${type.slug}`; + const isPrivateURLEnabled = + type.hashedLink && type.hashedLink.length > 0 + ? type.hashedLink[privateLinkCopyIndices[type.slug] ?? 0]?.link + : ""; + const placeholderHashedLink = `${bookerUrl}/d/${isPrivateURLEnabled}/${type.slug}`; const isManagedEventType = type.schedulingType === SchedulingType.MANAGED; const isChildrenManagedEventType = type.metadata?.managedEventConfig !== undefined && @@ -544,6 +547,11 @@ export const InfiniteEventTypeList = ({ onClick={() => { showToast(t("private_link_copied"), "success"); navigator.clipboard.writeText(placeholderHashedLink); + setPrivateLinkCopyIndices((prev) => { + const prevIndex = prev[type.slug] ?? 0; + prev[type.slug] = (prevIndex + 1) % type.hashedLink.length; + return prev; + }); }} /> diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 68a0a33f5dc8f4..d7150cf16e9a44 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -579,8 +579,6 @@ export const createUsersFixture = ( await updateChildrenEventTypes({ eventTypeId: teamEvent.id, currentUserId: user.id, - hashedLink: "", - connectedLink: null, oldEventType: { team: null, }, diff --git a/apps/web/playwright/hash-my-url.e2e.ts b/apps/web/playwright/hash-my-url.e2e.ts index c83effd1c03e2e..b47fd3f29dae52 100644 --- a/apps/web/playwright/hash-my-url.e2e.ts +++ b/apps/web/playwright/hash-my-url.e2e.ts @@ -26,12 +26,12 @@ test.describe("hash my url", () => { // We wait for the page to load await page.locator(".primary-navigation >> text=Advanced").click(); // ignore if it is already checked, and click if unchecked - const hashedLinkCheck = await page.locator('[data-testid="hashedLinkCheck"]'); + const hashedLinkCheck = await page.locator('[data-testid="multiplePrivateLinksCheck"]'); await hashedLinkCheck.click(); // we wait for the hashedLink setting to load - const $url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue(); + const $url = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue(); // click update await page.locator('[data-testid="update-eventtype"]').press("Enter"); @@ -50,8 +50,12 @@ test.describe("hash my url", () => { await page.locator("ul[data-testid=event-types] > li a").first().click(); // We wait for the page to load await page.locator(".primary-navigation >> text=Advanced").click(); + + const hashedLinkCheck2 = await page.locator('[data-testid="multiplePrivateLinksCheck"]'); + await hashedLinkCheck2.click(); + // we wait for the hashedLink setting to load - const $newUrl = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue(); + const $newUrl = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue(); expect($url !== $newUrl).toBeTruthy(); // Ensure that private URL is enabled after modifying the event type. @@ -63,7 +67,7 @@ test.describe("hash my url", () => { action: () => page.locator("[data-testid=update-eventtype]").click(), }); await page.locator(".primary-navigation >> text=Advanced").click(); - const $url2 = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue(); + const $url2 = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue(); expect($url2.includes("somethingrandom")).toBeTruthy(); }); }); diff --git a/apps/web/playwright/organization/organization-redirection.e2e.ts b/apps/web/playwright/organization/organization-redirection.e2e.ts index 753f5203a72646..2d25b7c202b1a7 100644 --- a/apps/web/playwright/organization/organization-redirection.e2e.ts +++ b/apps/web/playwright/organization/organization-redirection.e2e.ts @@ -147,9 +147,11 @@ test.describe("Unpublished Organization Redirection", () => { }, data: { hashedLink: { - create: { - link: generateHashedLink(eventType.id), - }, + create: [ + { + link: generateHashedLink(eventType.id), + }, + ], }, }, include: { @@ -158,7 +160,7 @@ test.describe("Unpublished Organization Redirection", () => { }); await doOnOrgDomain({ page, orgSlug }, async () => { - await page.goto(`/d/${privateEvent.hashedLink?.link}/${privateEvent.slug}`); + await page.goto(`/d/${privateEvent.hashedLink[0]?.link}/${privateEvent.slug}`); // Expect the empty screen, indicating the event is inaccessible. await expect(page.getByTestId("empty-screen")).toBeVisible(); @@ -180,9 +182,11 @@ test.describe("Unpublished Organization Redirection", () => { }, data: { hashedLink: { - create: { - link: generateHashedLink(eventType.id), - }, + create: [ + { + link: generateHashedLink(eventType.id), + }, + ], }, }, include: { @@ -191,7 +195,7 @@ test.describe("Unpublished Organization Redirection", () => { }); await doOnOrgDomain({ page, orgSlug }, async () => { - await page.goto(`/d/${privateEvent.hashedLink?.link}/${privateEvent.slug}?orgRedirection=true`); + await page.goto(`/d/${privateEvent.hashedLink[0]?.link}/${privateEvent.slug}?orgRedirection=true`); // Verify that the event page is visible. await expect(page.getByTestId("event-title")).toBeVisible(); diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 8684453d591aed..0475b06b376172 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -1,4 +1,5 @@ -import { expect, Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; import { randomBytes } from "crypto"; import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 398e85ee7dae85..e08d457350063f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -876,12 +876,17 @@ "copy_private_link": "Copy private link", "copy_private_link_to_event": "Copy private link to event", "private_link_description": "Generate a private URL to share without exposing your {{appName}} username", + "multiple_private_links_title": "Private Links", + "multiple_private_links_description": "Generate private URLs without exposing the username, which will be destroyed once used", + "add_a_multiple_private_link": "Add new link", + "multiple_private_link_copied": "Private link copied", "invitees_can_schedule": "Invitees can schedule", "date_range": "Date Range", "calendar_days": "calendar days", "business_days": "business days", "set_address_place": "Set an address or place", "set_link_meeting": "Set a link to the meeting", + "managed_event_field_parent_control_disabled": "Can't be toggled. It can only be unlocked for child event types", "cal_invitee_phone_number_scheduling": "{{appName}} will ask your invitee to enter a phone number before scheduling.", "cal_provide_google_meet_location": "{{appName}} will provide a Google Meet location.", "cal_provide_zoom_meeting_url": "{{appName}} will provide a Zoom meeting URL.", diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 18b8c96001d022..22c8652181ec33 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -40,8 +40,6 @@ describe("handleChildrenEventTypes", () => { children: [], updatedEventType: { schedulingType: null, slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -64,8 +62,6 @@ describe("handleChildrenEventTypes", () => { children: [], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -91,8 +87,6 @@ describe("handleChildrenEventTypes", () => { children: [], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -130,8 +124,6 @@ describe("handleChildrenEventTypes", () => { children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -184,8 +176,6 @@ describe("handleChildrenEventTypes", () => { children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: "somestring", - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: { @@ -200,8 +190,10 @@ describe("handleChildrenEventTypes", () => { scheduleId: null, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, + hashedLink: { + deleteMany: {}, + }, instantMeetingScheduleId: undefined, - hashedLink: { create: { link: expect.any(String) } }, }, where: { userId_parentId: { @@ -224,8 +216,6 @@ describe("handleChildrenEventTypes", () => { children: [], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -250,8 +240,6 @@ describe("handleChildrenEventTypes", () => { ], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -290,8 +278,6 @@ describe("handleChildrenEventTypes", () => { children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: ["something"] } }], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -305,12 +291,12 @@ describe("handleChildrenEventTypes", () => { durationLimits: undefined, recurringEvent: undefined, eventTypeColor: undefined, - hashedLink: undefined, instantMeetingScheduleId: undefined, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, userId: 4, workflows: undefined, + hashedLink: undefined, }, }); expect(result.newUserIds).toEqual([4]); @@ -345,8 +331,6 @@ describe("handleChildrenEventTypes", () => { children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: ["something"] } }], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: { @@ -358,6 +342,9 @@ describe("handleChildrenEventTypes", () => { data: { ...rest, locations: [], + hashedLink: { + deleteMany: {}, + }, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, instantMeetingScheduleId: undefined, @@ -411,8 +398,6 @@ describe("handleChildrenEventTypes", () => { ], updatedEventType: { schedulingType: "MANAGED", slug: "something" }, currentUserId: 1, - hashedLink: undefined, - connectedLink: null, prisma: prismaMock, profileId: null, updatedValues: {}, @@ -424,7 +409,6 @@ describe("handleChildrenEventTypes", () => { durationLimits: undefined, recurringEvent: undefined, eventTypeColor: undefined, - hashedLink: undefined, instantMeetingScheduleId: undefined, locations: [], lockTimeZoneToggleOnBookingPage: false, @@ -441,6 +425,7 @@ describe("handleChildrenEventTypes", () => { workflows: { create: [{ workflowId: 11 }], }, + hashedLink: undefined, }, }); const { profileId, ...rest } = evType; @@ -451,7 +436,9 @@ describe("handleChildrenEventTypes", () => { locations: [], lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, - hashedLink: undefined, + hashedLink: { + deleteMany: {}, + }, instantMeetingScheduleId: undefined, }, where: { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 67012fa09c27e8..38cc410d0e19a8 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1737,20 +1737,12 @@ async function handler( await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); } - // Avoid passing referencesToCreate with id unique constrain values - // refresh hashed link if used - const urlSeed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}`; - const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL)); - try { - if (hasHashedBookingLink) { - await prisma.hashedLink.update({ + if (hasHashedBookingLink && reqBody.hashedLink) { + await prisma.hashedLink.delete({ where: { link: reqBody.hashedLink as string, }, - data: { - link: hashedUid, - }, }); } } catch (error) { diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index 29ca66d784caca..cd8abecf10a62b 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -3,7 +3,6 @@ import type { Prisma } from "@prisma/client"; import type { DeepMockProxy } from "vitest-mock-extended"; import { sendSlugReplacementEmail } from "@calcom/emails/email-manager"; -import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { PrismaClient } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -23,8 +22,6 @@ interface handleChildrenEventTypesProps { team: { name: string } | null; workflows?: { workflowId: number }[]; } | null; - hashedLink: string | undefined; - connectedLink: { id: number } | null; children: | { hidden: boolean; @@ -92,8 +89,6 @@ export default async function handleChildrenEventTypes({ eventTypeId: parentId, oldEventType, updatedEventType, - hashedLink, - connectedLink, children, prisma, profileId, @@ -146,17 +141,6 @@ export default async function handleChildrenEventTypes({ // Calculate if there are new workflows for which assigned members will get too const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId); - // Define hashedLink query input - const hashedLinkQuery = (userId: number) => { - return hashedLink - ? !connectedLink - ? { create: { link: generateHashedLink(userId) } } - : undefined - : connectedLink - ? { delete: true } - : undefined; - }; - // Store result for existent event types deletion process let deletedExistentEventTypes = undefined; @@ -203,7 +187,6 @@ export default async function handleChildrenEventTypes({ data: eventType.webhooks?.map((wh) => ({ ...wh, eventTypeId: undefined })), }, },*/ - hashedLink: hashedLinkQuery(userId), }, }); }) @@ -255,7 +238,12 @@ export default async function handleChildrenEventTypes({ }, data: { ...updatePayloadFiltered, - hashedLink: "hashedLink" in unlockedFieldProps ? undefined : hashedLinkQuery(userId), + hashedLink: + "multiplePrivateLinks" in unlockedFieldProps + ? undefined + : { + deleteMany: {}, + }, }, }); }) diff --git a/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx b/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx new file mode 100644 index 00000000000000..402fb91a28750b --- /dev/null +++ b/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx @@ -0,0 +1,94 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Controller, useFormContext } from "react-hook-form"; + +import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types"; +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import { generateHashedLink } from "@calcom/lib/generateHashedLink"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, Icon, TextField, Tooltip, showToast } from "@calcom/ui"; + +export const MultiplePrivateLinksController = ({ + team, + bookerUrl, +}: Pick) => { + const formMethods = useFormContext(); + const { t } = useLocale(); + const [animateRef] = useAutoAnimate(); + return ( + { + if (!value) { + value = []; + } + const addPrivateLink = () => { + const newPrivateLink = generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id); + if (!value) value = []; + value.push(newPrivateLink); + onChange(value); + }; + + const removePrivateLink = (index: number) => { + if (!value) value = []; + value.splice(index, 1); + onChange(value); + }; + + return ( +
    + {value && + value.map((val, key) => { + const singleUseURL = `${bookerUrl}/d/${val}/${formMethods.getValues("slug")}`; + return ( +
  • + + + + } + /> + {value && value.length > 1 && ( +
  • + ); + })} + +
+ ); + }} + /> + ); +}; diff --git a/packages/features/eventtypes/components/index.ts b/packages/features/eventtypes/components/index.ts index 2b4cf68a682e85..61cf915013a41f 100644 --- a/packages/features/eventtypes/components/index.ts +++ b/packages/features/eventtypes/components/index.ts @@ -3,4 +3,5 @@ import dynamic from "next/dynamic"; export { default as CheckedTeamSelect } from "./CheckedTeamSelect"; export { default as CreateEventTypeDialog } from "./CreateEventTypeDialog"; export { default as EventTypeDescription } from "./EventTypeDescription"; +export { MultiplePrivateLinksController } from "./MultiplePrivateLinksController"; export const EventTypeDescriptionLazy = dynamic(() => import("./EventTypeDescription")); diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 743e03943c9197..386b34009fb88c 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -1,5 +1,5 @@ import dynamic from "next/dynamic"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { z } from "zod"; @@ -13,6 +13,7 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { MultiplePrivateLinksController } from "@calcom/features/eventtypes/components"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import { FormBuilder } from "@calcom/features/form-builder/FormBuilder"; import type { fieldSchema } from "@calcom/features/form-builder/schema"; @@ -20,13 +21,7 @@ import type { EditableSchema } from "@calcom/features/form-builder/schema"; import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; import { classNames } from "@calcom/lib"; import cx from "@calcom/lib/classNames"; -import { - DEFAULT_LIGHT_BRAND_COLOR, - DEFAULT_DARK_BRAND_COLOR, - APP_NAME, - IS_VISUAL_REGRESSION_TESTING, - WEBSITE_URL, -} from "@calcom/lib/constants"; +import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR, APP_NAME } from "@calcom/lib/constants"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -44,7 +39,6 @@ import { SettingsToggle, Switch, TextField, - Tooltip, showToast, ColorPicker, } from "@calcom/ui"; @@ -74,12 +68,15 @@ export const EventAdvancedTab = ({ const [showEventNameTip, setShowEventNameTip] = useState(false); const [darkModeError, setDarkModeError] = useState(false); const [lightModeError, setLightModeError] = useState(false); - const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink")); + const [multiplePrivateLinksVisible, setMultiplePrivateLinksVisible] = useState( + !!formMethods.getValues("multiplePrivateLinks") && + formMethods.getValues("multiplePrivateLinks")?.length !== 0 + ); const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl")); const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState( formMethods.getValues("useEventTypeDestinationCalendarEmail") ); - const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link); + const bookingFields: Prisma.JsonObject = {}; const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow); const selectedThemeIsDark = @@ -105,7 +102,6 @@ export const EventAdvancedTab = ({ const [requiresConfirmation, setRequiresConfirmation] = useState( formMethods.getValues("requiresConfirmation") ); - const placeholderHashedLink = `${WEBSITE_URL}/d/${hashedUrl}/${formMethods.getValues("slug")}`; const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); const multiLocation = (formMethods.getValues("locations") || []).length > 1; const noShowFeeEnabled = @@ -115,11 +111,6 @@ export const EventAdvancedTab = ({ const isRoundRobinEventType = eventType.schedulingType && eventType.schedulingType === SchedulingType.ROUND_ROBIN; - useEffect(() => { - !hashedUrl && setHashedUrl(generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [formMethods.getValues("users"), hashedUrl, team?.id]); - const toggleGuests = (enabled: boolean) => { const bookingFields = formMethods.getValues("bookingFields"); formMethods.setValue( @@ -157,6 +148,11 @@ export const EventAdvancedTab = ({ const hideCalendarEventDetailsLocked = shouldLockDisableProps("hideCalendarEventDetails"); const eventTypeColorLocked = shouldLockDisableProps("eventTypeColor"); const lockTimeZoneToggleOnBookingPageLocked = shouldLockDisableProps("lockTimeZoneToggleOnBookingPage"); + const multiplePrivateLinksLocked = shouldLockDisableProps("multiplePrivateLinks"); + + if (isManagedEventType) { + multiplePrivateLinksLocked.disabled = true; + } const closeEventNameTip = () => setShowEventNameTip(false); @@ -422,72 +418,45 @@ export const EventAdvancedTab = ({ )} /> - - - - } - {...shouldLockDisableProps("hashedLink")} - description={t("private_link_description", { appName: APP_NAME })} - checked={hashedLinkVisible} - onCheckedChange={(e) => { - formMethods.setValue("hashedLink", e ? hashedUrl : undefined, { shouldDirty: true }); - setHashedLinkVisible(e); - }}> - {!isManagedEventType && ( -
- {!IS_VISUAL_REGRESSION_TESTING && ( - - - + { + return ( + { + if (!e) { + formMethods.setValue("multiplePrivateLinks", [], { shouldDirty: true }); + } else { + formMethods.setValue( + "multiplePrivateLinks", + [generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id)], + { shouldDirty: true } + ); } - /> - )} -
- )} -
+ setMultiplePrivateLinksVisible(e); + }}> + {!isManagedEventType && ( +
+ +
+ )} + + ); + }} + /> ( diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 4dbe945348483b..88de135be379ec 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -55,7 +55,7 @@ export type FormValues = { schedulingType: SchedulingType | null; hidden: boolean; hideCalendarNotes: boolean; - hashedLink: string | undefined; + multiplePrivateLinks: string[] | undefined; eventTypeColor: z.infer; locations: { type: EventLocationType["type"]; diff --git a/packages/lib/generateHashedLink.ts b/packages/lib/generateHashedLink.ts index 3aadf46f792686..efa1f3a21d2541 100644 --- a/packages/lib/generateHashedLink.ts +++ b/packages/lib/generateHashedLink.ts @@ -1,7 +1,7 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; -export const generateHashedLink = (id: number) => { +export const generateHashedLink = (id: number | string) => { const translator = short(); const seed = `${id}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index f4e036ce7ac00b..8db29cd78bda84 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -72,7 +72,7 @@ export const useEventTypeForm = ({ durationLimits: eventType.durationLimits || undefined, length: eventType.length, hidden: eventType.hidden, - hashedLink: eventType.hashedLink?.link || undefined, + multiplePrivateLinks: eventType.hashedLink.map((link) => link.link), eventTypeColor: eventType.eventTypeColor || null, periodDates: { startDate: periodDates.startDate, @@ -348,6 +348,7 @@ export const useEventTypeForm = ({ customInputs, children, assignAllTeamMembers, + multiplePrivateLinks: values.multiplePrivateLinks, aiPhoneCallConfig: rest.aiPhoneCallConfig ? { ...rest.aiPhoneCallConfig, templateType: rest.aiPhoneCallConfig.templateType as TemplateType } : undefined, diff --git a/packages/prisma/migrations/20240816160101_support_multiple_hashed_links_for_an_event_type/migration.sql b/packages/prisma/migrations/20240816160101_support_multiple_hashed_links_for_an_event_type/migration.sql new file mode 100644 index 00000000000000..1be94af17fb4a5 --- /dev/null +++ b/packages/prisma/migrations/20240816160101_support_multiple_hashed_links_for_an_event_type/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "HashedLink_eventTypeId_key"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0fa9000395a797..b7fd81a4d27d8b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -82,7 +82,7 @@ model EventType { team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) teamId Int? - hashedLink HashedLink? + hashedLink HashedLink[] bookings Booking[] availability Availability[] webhooks Webhook[] @@ -823,7 +823,7 @@ model HashedLink { id Int @id @default(autoincrement()) link String @unique() eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) - eventTypeId Int @unique + eventTypeId Int } model Account { diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index fbea40b40cdcaf..e9a52016e4fa85 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -62,7 +62,7 @@ export const EventTypeUpdateInput = _EventTypeModel .optional(), schedule: z.number().nullable().optional(), instantMeetingSchedule: z.number().nullable().optional(), - hashedLink: z.string(), + multiplePrivateLinks: z.array(z.string()), assignAllTeamMembers: z.boolean().optional(), isRRWeightsEnabled: z.boolean().optional(), }) diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 91ed0377cb84ea..810a7b07391535 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -66,7 +66,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { assignAllTeamMembers, hosts, id, - hashedLink, + multiplePrivateLinks, // Extract this from the input so it doesn't get saved in the db // eslint-disable-next-line userId, @@ -343,41 +343,54 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { break; } } - - const connectedLink = await ctx.prisma.hashedLink.findFirst({ + const connectedLinks = await ctx.prisma.hashedLink.findMany({ where: { eventTypeId: input.id, }, select: { id: true, + link: true, }, }); - if (hashedLink) { - // check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection - if (!connectedLink) { - // create a hashed link - await ctx.prisma.hashedLink.upsert({ + const connectedMultiplePrivateLinks = connectedLinks.map((link) => link.link); + + if (multiplePrivateLinks && multiplePrivateLinks.length > 0) { + const multiplePrivateLinksToBeInserted = multiplePrivateLinks.filter( + (link) => !connectedMultiplePrivateLinks.includes(link) + ); + const singleLinksToBeDeleted = connectedMultiplePrivateLinks.filter( + (link) => !multiplePrivateLinks.includes(link) + ); + if (singleLinksToBeDeleted.length > 0) { + await ctx.prisma.hashedLink.deleteMany({ where: { eventTypeId: input.id, - }, - update: { - link: hashedLink, - }, - create: { - link: hashedLink, - eventType: { - connect: { id: input.id }, + link: { + in: singleLinksToBeDeleted, }, }, }); } + if (multiplePrivateLinksToBeInserted.length > 0) { + await ctx.prisma.hashedLink.createMany({ + data: multiplePrivateLinksToBeInserted.map((link) => { + return { + link: link, + eventTypeId: input.id, + }; + }), + }); + } } else { - // check if hashed connection exists. If it does, disconnect - if (connectedLink) { - await ctx.prisma.hashedLink.delete({ + // Delete all the single-use links for this event. + if (connectedMultiplePrivateLinks.length > 0) { + await ctx.prisma.hashedLink.deleteMany({ where: { eventTypeId: input.id, + link: { + in: connectedMultiplePrivateLinks, + }, }, }); } @@ -470,8 +483,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { eventTypeId: id, currentUserId: ctx.user.id, oldEventType: eventType, - hashedLink, - connectedLink, updatedEventType, children, profileId: ctx.user.profile.id,