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:
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>
|
||||
|
||||
@@ -579,8 +579,6 @@ export const createUsersFixture = (
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: teamEvent.id,
|
||||
currentUserId: user.id,
|
||||
hashedLink: "",
|
||||
connectedLink: null,
|
||||
oldEventType: {
|
||||
team: null,
|
||||
},
|
||||
|
||||
@@ -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 } }) => (
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "HashedLink_eventTypeId_key";
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user