feat: Multiple Private links per Event Type (#15896)

* feat: Change hashedLink field to array

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Implement core single-use links logic

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Get private link from list of hashedLinks on event list page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix defalut single-use links form field value

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix some type errors while compiling

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Use onChange callback instead of setting form values directly

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add e2e tests for single-use links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor single-use-links admin interface

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix settings toggle bugs

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Remove redundant inline comments

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Remove debug statements

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Remove single-use links e2e tests

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Remove private-link feature and override with single-use-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove redundant migrations

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Add managedEvents restriction

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix single-use-links for org

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix recommended UI changes

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix duration not respected for private links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix team event page title

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add logic to copy each single-use-links from event listing page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add single-use links support for managed event types

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix some type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Disable adding new single-use links when field is locked

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Address feedbacks

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Hide add new link button for locked single-use link field

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Use empty string for single-use link fallback value condition

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix org single-use links in listing page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Auto toggle unlock button for managed event's single-use links option

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* Revert "feat: Auto toggle unlock button for managed event's single-use links option"

This reverts commit 5a2ed6f0bf428d52de153335499f02f42ccdf18f.

* chore: Disable parent toggle for single-use-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Remove tooltip and give info message below field

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* add tooltip

* tooltip message

* fix: Fix typo

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix typescript type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix few unit tests

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove redundant query

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* merge conflict fix

* More merge conflict fix and refactor

* fix tests, code refactor

* fix: type check

* fix: duration config and keep links when updating event type

* fix: e2e

* refactor: Replace single-use-links with multiple-private-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit91848 <samit91848@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-10-03 00:58:54 +05:30
1 parent 48a2fc2f67
commit 2a1dc619ee
20 files changed
+245 -178

No files matched your search

@@ -127,6 +127,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
queryDuration,
eventData.length
),
durationConfig: eventData.metadata?.multipleDuration ?? [],
booking,
user: name,
slug,
@@ -16,6 +16,7 @@ export default function Type({
entity,
duration,
hashedLink,
durationConfig,
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
@@ -35,6 +36,7 @@ export default function Type({
entity={entity}
duration={duration}
hashedLink={hashedLink}
durationConfig={durationConfig}
/>
</main>
);
@@ -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<SchedulingType | null>(
null
);
const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState<Record<string, number>>({});
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;
});
}}
/>
</Tooltip>
-2
View File
@@ -579,8 +579,6 @@ export const createUsersFixture = (
await updateChildrenEventTypes({
eventTypeId: teamEvent.id,
currentUserId: user.id,
hashedLink: "",
connectedLink: null,
oldEventType: {
team: null,
},
+8 -4
View File
@@ -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();
});
});
@@ -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();
@@ -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.",
@@ -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: {
@@ -1556,20 +1556,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) {
@@ -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: {},
},
},
});
})
@@ -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<EventTypeSetupProps["eventType"], "team" | "bookerUrl">) => {
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
const [animateRef] = useAutoAnimate<HTMLUListElement>();
return (
<Controller
name="multiplePrivateLinks"
control={formMethods.control}
render={({ field: { value, onChange } }) => {
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 (
<ul ref={animateRef}>
{value &&
value.map((val, key) => {
const singleUseURL = `${bookerUrl}/d/${val}/${formMethods.getValues("slug")}`;
return (
<li data-testid="add-single-use-link" className="mb-4 flex items-center" key={val}>
<TextField
containerClassName="w-full"
disabled
data-testid={`generated-hash-url-${key}`}
labelSrOnly
type="text"
defaultValue={singleUseURL}
addOnSuffix={
<Tooltip content={t("copy_to_clipboard")}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(singleUseURL);
showToast(t("multiple_private_link_copied"), "success");
}}>
<Icon name="copy" className="ml-1 h-4 w-4" />
</Button>
</Tooltip>
}
/>
{value && value.length > 1 && (
<Button
data-testid={`remove-single-use-link-${key}`}
variant="icon"
StartIcon="trash-2"
color="destructive"
className="ml-2 border-none"
onClick={() => removePrivateLink(key)}
/>
)}
</li>
);
})}
<Button
color="minimal"
StartIcon="plus"
onClick={addPrivateLink}
data-testid="add-single-use-link-button">
{t("add_a_multiple_private_link")}
</Button>
</ul>
);
}}
/>
);
};
@@ -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"));
@@ -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 = ({
</>
)}
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
hashedLinkVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="hashedLinkCheck"
title={t("enable_private_url")}
Badge={
<a
data-testid="hashedLinkCheck-info"
target="_blank"
rel="noreferrer"
href="https://cal.com/docs/core-features/event-types/single-use-private-links">
<Icon name="info" className="ml-1.5 h-4 w-4 cursor-pointer" />
</a>
}
{...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 && (
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{!IS_VISUAL_REGRESSION_TESTING && (
<TextField
disabled
name="hashedLink"
label={t("private_link_label")}
data-testid="generated-hash-url"
labelSrOnly
type="text"
hint={t("private_link_hint")}
defaultValue={placeholderHashedLink}
addOnSuffix={
<Tooltip
content={
formMethods.getValues("hashedLink") ? t("copy_to_clipboard") : t("enabled_after_update")
}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (formMethods.getValues("hashedLink")) {
showToast(t("private_link_copied"), "success");
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}>
<Icon name="copy" className="h-4 w-4" />
</Button>
</Tooltip>
<Controller
name="multiplePrivateLinks"
render={() => {
return (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
multiplePrivateLinksVisible && "rounded-b-none"
)}
childrenClassName="lg:ml-0"
data-testid="multiplePrivateLinksCheck"
title={t("multiple_private_links_title")}
{...multiplePrivateLinksLocked}
description={t("multiple_private_links_description", { appName: APP_NAME })}
tooltip={isManagedEventType ? t("managed_event_field_parent_control_disabled") : ""}
checked={multiplePrivateLinksVisible}
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("multiplePrivateLinks", [], { shouldDirty: true });
} else {
formMethods.setValue(
"multiplePrivateLinks",
[generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id)],
{ shouldDirty: true }
);
}
/>
)}
</div>
)}
</SettingsToggle>
setMultiplePrivateLinksVisible(e);
}}>
{!isManagedEventType && (
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<MultiplePrivateLinksController team={team} bookerUrl={eventType.bookerUrl} />
</div>
)}
</SettingsToggle>
);
}}
/>
<Controller
name="seatsPerTimeSlotEnabled"
render={({ field: { value, onChange } }) => (
+1 -1
View File
@@ -55,7 +55,7 @@ export type FormValues = {
schedulingType: SchedulingType | null;
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: string | undefined;
multiplePrivateLinks: string[] | undefined;
eventTypeColor: z.infer<typeof eventTypeColor>;
locations: {
type: EventLocationType["type"];
+1 -1
View File
@@ -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));
@@ -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,
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "HashedLink_eventTypeId_key";
+2 -2
View File
@@ -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[]
@@ -839,7 +839,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 {
@@ -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(),
})
@@ -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,