diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
index b4dfcd5bbb..99e86c6692 100644
--- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
+++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
@@ -6,9 +6,11 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
+import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { shouldHideBrandingForTeamEvent, shouldHideBrandingForUserEvent } from "@calcom/lib/hideBranding";
import { EventRepository } from "@calcom/lib/server/repository/event";
import { UserRepository } from "@calcom/lib/server/repository/user";
+import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
@@ -25,43 +27,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const org = isValidOrgDomain ? currentOrgDomain : null;
- const hashedLink = await prisma.hashedLink.findUnique({
- where: {
- link,
- },
- select: {
- eventTypeId: true,
- eventType: {
- select: {
- users: {
- select: {
- username: true,
- profiles: {
- select: {
- id: true,
- organizationId: true,
- username: true,
- },
- },
- },
- },
- team: {
- select: {
- id: true,
- slug: true,
- hideBranding: true,
- parent: {
- select: {
- hideBranding: true,
- },
- },
- },
- },
- },
- },
- },
- });
-
let name: string;
let hideBranding = false;
@@ -69,9 +34,22 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
notFound: true,
} as const;
+ // Use centralized validation logic to avoid duplication
+ const hashedLinkService = new HashedLinkService();
+ try {
+ await hashedLinkService.validate(link);
+ } catch (error) {
+ // Link is expired, invalid, or doesn't exist
+ return notFound;
+ }
+
+ // If validation passes, fetch the complete data needed for rendering
+ const hashedLink = await hashedLinkService.findLinkWithDetails(link);
+
if (!hashedLink) {
return notFound;
}
+
const username = hashedLink.eventType.users[0]?.username;
const profileUsername = hashedLink.eventType.users[0]?.profiles[0]?.username;
@@ -139,8 +117,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return notFound;
}
+ // Check if team has API v2 feature flag enabled (same logic as team pages)
+ let useApiV2 = false;
+ if (isTeamEvent && hashedLink.eventType.team?.id) {
+ const featureRepo = new FeaturesRepository();
+ const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
+ hashedLink.eventType.team.id,
+ "use-api-v2-for-team-slots"
+ );
+ useApiV2 = teamHasApiV2Route;
+ }
+
return {
props: {
+ useApiV2,
eventData,
entity: eventData.entity,
duration: getMultipleDurationValue(
@@ -156,7 +146,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
// Sending the team event from the server, because this template file
// is reused for both team and user events.
isTeamEvent,
- hashedLink: link,
+ hashedLink: hashedLink?.link,
},
};
}
diff --git a/apps/web/modules/d/[link]/d-type-view.tsx b/apps/web/modules/d/[link]/d-type-view.tsx
index 9d1d75e0d0..b733cba23d 100644
--- a/apps/web/modules/d/[link]/d-type-view.tsx
+++ b/apps/web/modules/d/[link]/d-type-view.tsx
@@ -19,6 +19,7 @@ export default function Type({
hashedLink,
durationConfig,
eventData,
+ useApiV2,
}: PageProps) {
return (
@@ -34,6 +35,7 @@ export default function Type({
duration={duration}
hashedLink={hashedLink}
durationConfig={durationConfig}
+ useApiV2={useApiV2}
/>
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 cb9bb9b347..b26652ca87 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
@@ -16,6 +16,8 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
+import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
+import { filterActiveLinks } from "@calcom/lib/hashedLinksUtils";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
@@ -467,6 +469,14 @@ export const InfiniteEventTypeList = ({
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
};
+ const userTimezone = extractHostTimezone({
+ userId: firstItem.userId,
+ teamId: firstItem?.teamId,
+ hosts: firstItem?.hosts,
+ owner: firstItem?.owner,
+ team: firstItem?.team,
+ });
+
return (
@@ -474,11 +484,17 @@ export const InfiniteEventTypeList = ({
return page?.eventTypes?.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${bookerUrl}/${embedLink}`;
+
+ const activeHashedLinks = type.hashedLink ? filterActiveLinks(type.hashedLink, userTimezone) : [];
+
+ // Ensure index is within bounds for active links
+ const currentIndex = privateLinkCopyIndices[type.slug] ?? 0;
+ const safeIndex = activeHashedLinks.length > 0 ? currentIndex % activeHashedLinks.length : 0;
+
const isPrivateURLEnabled =
- type.hashedLink && type.hashedLink.length > 0
- ? type.hashedLink[privateLinkCopyIndices[type.slug] ?? 0]?.link
- : "";
+ activeHashedLinks.length > 0 ? activeHashedLinks[safeIndex]?.link : "";
const placeholderHashedLink = `${bookerUrl}/d/${isPrivateURLEnabled}/${type.slug}`;
+
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
type.metadata?.managedEventConfig !== undefined &&
@@ -580,8 +596,8 @@ export const InfiniteEventTypeList = ({
copyToClipboard(placeholderHashedLink);
setPrivateLinkCopyIndices((prev) => {
const prevIndex = prev[type.slug] ?? 0;
- prev[type.slug] = (prevIndex + 1) % type.hashedLink.length;
- return prev;
+ const nextIndex = (prevIndex + 1) % activeHashedLinks.length;
+ return { ...prev, [type.slug]: nextIndex };
});
}}
/>
diff --git a/apps/web/playwright/hash-my-url.e2e.ts b/apps/web/playwright/hash-my-url.e2e.ts
index bb48cb87a2..ae4e41d78f 100644
--- a/apps/web/playwright/hash-my-url.e2e.ts
+++ b/apps/web/playwright/hash-my-url.e2e.ts
@@ -7,10 +7,9 @@ import {
submitAndWaitForResponse,
} from "./lib/testUtils";
-test.describe.configure({ mode: "parallel" });
+test.describe.configure({ mode: "serial" });
-// TODO: This test is very flaky. Feels like tossing a coin and hope that it won't fail. Needs to be revisited.
-test.describe("hash my url", () => {
+test.describe("private links creation and usage", () => {
test.beforeEach(async ({ users }) => {
const user = await users.create();
await user.apiLogin();
@@ -18,25 +17,29 @@ test.describe("hash my url", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
- test("generate url hash", async ({ page }) => {
+ test("generate private link and make a booking with it", async ({ page }) => {
await page.goto("/event-types");
// We wait until loading is finished
- await page.waitForSelector('[data-testid="event-types"]');
- await page.locator("ul[data-testid=event-types] > li a").first().click();
- await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
- "aria-current",
- "page"
- ); // fix the race condition
- await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
+ await Promise.all([
+ page.waitForURL("**/event-types"),
+ page.getByTestId("event-types").locator("li a").first().click(),
+ ]);
+
+ await expect(page.locator("[data-testid=event-title]")).toBeVisible();
+
// We wait for the page to load
- await page.locator(".primary-navigation >> text=Advanced").click();
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
+
+ const hashedLinkCheck = page.locator('[data-testid="multiplePrivateLinksCheck"]');
+ await expect(hashedLinkCheck).toBeVisible();
+
// ignore if it is already checked, and click if unchecked
- const hashedLinkCheck = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
+ if (!(await hashedLinkCheck.isChecked())) {
+ await hashedLinkCheck.click();
+ }
- await hashedLinkCheck.click();
-
- // we wait for the hashedLink setting to load
- const $url = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
+ // Wait for the private link URL input to be visible and get its value
+ const $url = await page.locator('[data-testid="private-link-url"]').inputValue();
// click update
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
@@ -47,33 +50,148 @@ test.describe("hash my url", () => {
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
- await expect(page.locator("[data-testid=success-page]")).toBeVisible();
+ const successPage = page.getByTestId("success-page");
+ await expect(successPage).toBeVisible();
- // hash regenerates after successful booking
+ // hash regenerates after successful booking (only for usage-based links)
await page.goto("/event-types");
- // We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
+ await page.reload(); // ensure fresh state
+
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();
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").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-0"]').inputValue();
- expect($url !== $newUrl).toBeTruthy();
+ // After booking with a usage-based private link, the link should be expired
+ await expect(page.locator('[data-testid="private-link-description"]')).toContainText(
+ "Usage limit reached"
+ );
// Ensure that private URL is enabled after modifying the event type.
// Additionally, if the slug is changed, ensure that the private URL is updated accordingly.
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
await page.locator("[data-testid=event-title]").first().fill("somethingrandom");
await page.locator("[data-testid=event-slug]").first().fill("somethingrandom");
+ await expect(page.locator('[data-testid="event-slug"]').first()).toHaveValue("somethingrandom");
+
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
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-0"]').inputValue();
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
+
+ // Wait for the private link URL input to be visible and get its value
+ const $url2 = await page.locator('[data-testid="private-link-url"]').inputValue();
expect($url2.includes("somethingrandom")).toBeTruthy();
});
+
+ test("generate private link with future expiration date and make a booking with it", async ({ page }) => {
+ await page.goto("/event-types");
+ // We wait until loading is finished
+ await Promise.all([
+ page.waitForURL("**/event-types"),
+ page.getByTestId("event-types").locator("li a").first().click(),
+ ]);
+
+ await expect(page.locator("[data-testid=event-title]")).toBeVisible();
+
+ // We wait for the page to load
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
+
+ const privateLinkCheck = page.locator('[data-testid="multiplePrivateLinksCheck"]');
+ await expect(privateLinkCheck).toBeVisible();
+
+ // ignore if it is already checked, and click if unchecked
+ if (!(await privateLinkCheck.isChecked())) {
+ await privateLinkCheck.click();
+ }
+
+ // Wait for the private link URL input to be visible and get its value
+ const $url = await page.locator('[data-testid="private-link-url"]').inputValue();
+ await page.locator('[data-testid="private-link-settings"]').click();
+ await expect(page.locator('[data-testid="private-link-radio-group"]')).toBeVisible();
+ await page.locator('[data-testid="private-link-time"]').click();
+ await page.locator('[data-testid="private-link-expiration-settings-save"]').click();
+ await page.waitForLoadState("networkidle");
+ // click update
+ await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
+ action: () => page.locator("[data-testid=update-eventtype]").click(),
+ });
+ // book using generated url hash
+ await page.goto($url);
+ await selectFirstAvailableTimeSlotNextMonth(page);
+ await bookTimeSlot(page);
+ // Make sure we're navigated to the success page
+ await expect(page.getByTestId("success-page")).toBeVisible();
+
+ // hash regenerates after successful booking (only for usage-based links)
+ await page.goto("/event-types");
+ await page.waitForSelector('[data-testid="event-types"]');
+ await page.reload(); // ensure fresh state
+
+ await page.locator("ul[data-testid=event-types] > li a").first().click();
+ // We wait for the page to load
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
+
+ // After booking with a expiration date based private link, the link should still be valid
+ await expect(page.locator('[data-testid="private-link-expired"]')).toBeHidden();
+ });
+ test("generate private link with 2 usages and make 2 bookings with it", async ({ page }) => {
+ await page.goto("/event-types");
+ // We wait until loading is finished
+ await Promise.all([
+ page.waitForURL("**/event-types"),
+ page.getByTestId("event-types").locator("li a").first().click(),
+ ]);
+
+ await expect(page.locator("[data-testid=event-title]")).toBeVisible();
+
+ // We wait for the page to load
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
+
+ const privateLinkCheck = page.locator('[data-testid="multiplePrivateLinksCheck"]');
+ await expect(privateLinkCheck).toBeVisible();
+
+ // ignore if it is already checked, and click if unchecked
+ if (!(await privateLinkCheck.isChecked())) {
+ await privateLinkCheck.click();
+ }
+
+ // Wait for the private link URL input to be visible and get its value
+ const $url = await page.locator('[data-testid="private-link-url"]').inputValue();
+ await page.locator('[data-testid="private-link-settings"]').click();
+ await expect(page.locator('[data-testid="private-link-radio-group"]')).toBeVisible();
+ await page.locator('[data-testid="private-link-usage-count"]').fill("2");
+ await page.locator('[data-testid="private-link-expiration-settings-save"]').click();
+ await page.waitForLoadState("networkidle");
+ // click update
+ await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
+ action: () => page.locator("[data-testid=update-eventtype]").click(),
+ });
+ // book using generated url hash
+ await page.goto($url);
+ await selectFirstAvailableTimeSlotNextMonth(page);
+ await bookTimeSlot(page);
+ // Make sure we're navigated to the success page
+ await expect(page.getByTestId("success-page")).toBeVisible();
+
+ // book again using generated url hash
+ await page.goto($url);
+ await selectFirstAvailableTimeSlotNextMonth(page);
+ await bookTimeSlot(page);
+ // Make sure we're navigated to the success page
+ await expect(page.getByTestId("success-page")).toBeVisible();
+
+ await page.goto("/event-types");
+ await page.waitForSelector('[data-testid="event-types"]');
+ await page.reload(); // ensure fresh state
+
+ await page.locator("ul[data-testid=event-types] > li a").first().click();
+ // We wait for the page to load
+ await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
+
+ // After booking twice with a 2 usages based private link, the link should be expired
+ await expect(page.locator('[data-testid="private-link-description"]')).toContainText(
+ "Usage limit reached"
+ );
+ });
});
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index d49eb2c8d3..4829475ef1 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -961,7 +961,7 @@
"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",
+ "multiple_private_links_description": "Generate private URLs without exposing the username, with configurable expiry and usage limits",
"add_a_multiple_private_link": "Add new link",
"multiple_private_link_copied": "Private link copied",
"invitees_can_schedule": "Invitees can schedule",
@@ -2511,6 +2511,9 @@
"extensive_whitelabeling": "Extensive Whitelabeling",
"extensive_whitelabeling_description": "Customize your scheduling experience with your own logo, colors, and more",
"unlimited_teams": "Unlimited Teams",
+ "verify_email": "Verify email",
+ "proceed_with_cancellation_description": "Please enter the email address used for this booking to proceed with cancellation",
+ "proceed_with_cancellation_error": "Email doesn't match the booking email",
"unlimited_teams_description": "Add as many subteams as you need to your organization",
"unified_billing": "Unified Billing",
"advanced_managed_events": "Advanced Managed Event Types",
@@ -3396,5 +3399,24 @@
"error_deleting_cache": "Error deleting cache",
"confirm_delete_cache": "Are you sure you want to delete the cached data? This action cannot be undone.",
"yes_delete_cache": "Yes, delete cache",
+ "remainder_of_maximum_uses_left_plural": "{{remainder}} of {{maximum}} uses left",
+ "remainder_of_maximum_use_left_singular": "{{remainder}} of {{maximum}} use left",
+ "expires_on_date": "Expires on {{date}}, end of day",
+ "single_use": "Single use",
+ "time_based_expiration": "Time-based expiration",
+ "usage_based_expiration": "Usage-based expiration",
+ "number_of_uses": "Number of uses",
+ "expires_on": "Expires on",
+ "max_usage_count": "Maximum number of uses",
+ "link_settings": "Link Settings",
+ "link_expired_on_date": "This link expired on {{date}}, end of day",
+ "usage_limit_reached": "Usage limit reached",
+ "usage_count_error": "Maximum usage count must be greater than current usage count",
+ "private_link_expired": "This link has expired",
+ "time_based_expiration_description": "This link can be used for bookings until the end of {{date}}",
+ "time_based_generic_expiration_description": "This link can be configured to expire after a set date",
+ "usage_based_expiration_description": "This link can be used for {{count}} booking",
+ "usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
+ "usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
diff --git a/apps/web/test/lib/CheckForEmptyAssignment.test.ts b/apps/web/test/lib/CheckForEmptyAssignment.test.ts
index c956873ef8..3dbfce4c15 100644
--- a/apps/web/test/lib/CheckForEmptyAssignment.test.ts
+++ b/apps/web/test/lib/CheckForEmptyAssignment.test.ts
@@ -8,7 +8,16 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
checkForEmptyAssignment({
assignedUsers: [],
assignAllTeamMembers: false,
- hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, scheduleId: null }],
+ hosts: [
+ {
+ userId: 101,
+ isFixed: false,
+ priority: 2,
+ weight: 100,
+ scheduleId: null,
+ user: { timeZone: "America/New_York" },
+ },
+ ],
isManagedEventType: true,
})
).toBe(true);
@@ -61,7 +70,16 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
checkForEmptyAssignment({
assignedUsers: [],
assignAllTeamMembers: false,
- hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, scheduleId: null }],
+ hosts: [
+ {
+ userId: 101,
+ isFixed: false,
+ priority: 2,
+ weight: 100,
+ scheduleId: null,
+ user: { timeZone: "America/New_York" },
+ },
+ ],
isManagedEventType: false,
})
).toBe(false);
@@ -94,7 +112,16 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
},
],
assignAllTeamMembers: false,
- hosts: [],
+ hosts: [
+ {
+ userId: 101,
+ isFixed: false,
+ priority: 2,
+ weight: 100,
+ scheduleId: null,
+ user: { timeZone: "America/New_York" },
+ },
+ ],
isManagedEventType: true,
})
).toBe(false);
diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts
index 67d432a759..0a03184396 100644
--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -68,6 +68,7 @@ import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
import { getTranslation } from "@calcom/lib/server/i18n";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
+import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { AssignmentReasonEnum } from "@calcom/prisma/enums";
@@ -2117,15 +2118,20 @@ async function handler(
}
try {
+ const hashedLinkService = new HashedLinkService();
if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) {
- await prisma.hashedLink.delete({
- where: {
- link: reqBody.hashedLink as string,
- },
- });
+ await hashedLinkService.validateAndIncrementUsage(reqBody.hashedLink as string);
}
} catch (error) {
loggerWithEventDetails.error("Error while updating hashed link", JSON.stringify({ error }));
+
+ // Handle repository errors and convert to HttpErrors
+ if (error instanceof Error) {
+ throw new HttpError({ statusCode: 410, message: error.message });
+ }
+
+ // For unexpected errors, provide a generic message
+ throw new HttpError({ statusCode: 500, message: "Failed to process booking link" });
}
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
diff --git a/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx b/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx
index 87eb596457..a52bbff792 100644
--- a/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx
+++ b/packages/features/eventtypes/components/MultiplePrivateLinksController.tsx
@@ -1,97 +1,420 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
+import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
+import dayjs from "@calcom/dayjs";
import type { EventTypeSetupProps } from "@calcom/features/eventtypes/lib/types";
-import type { FormValues } from "@calcom/features/eventtypes/lib/types";
+import type { FormValues, PrivateLinkWithOptions } from "@calcom/features/eventtypes/lib/types";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
+import { isLinkExpired as utilsIsLinkExpired } from "@calcom/lib/hashedLinksUtils";
+import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { Icon } from "@calcom/ui/components/icon";
-import { showToast } from "@calcom/ui/components/toast";
+import { trpc } from "@calcom/trpc/react";
+import classNames from "@calcom/ui/classNames";
+import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
+import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { TextField } from "@calcom/ui/components/form";
+import { DatePicker } from "@calcom/ui/components/form";
+import { NumberInput } from "@calcom/ui/components/form";
+import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
+import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
export const MultiplePrivateLinksController = ({
team,
bookerUrl,
-}: Pick) => {
+ setMultiplePrivateLinksVisible,
+ userTimeZone,
+}: Pick & {
+ setMultiplePrivateLinksVisible?: (isVisible: boolean) => void;
+ userTimeZone?: string;
+}): JSX.Element => {
const formMethods = useFormContext();
const { t } = useLocale();
+ const { copyToClipboard } = useCopy();
const [animateRef] = useAutoAnimate();
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [currentLinkIndex, setCurrentLinkIndex] = useState(null);
+ const [selectedType, setSelectedType] = useState<"time" | "usage">("usage");
+ const [expiryDate, setExpiryDate] = useState(new Date());
+ const [maxUsageCount, setMaxUsageCount] = useState(1);
+
+ // Updates the form data directly when settings change
+ const updateLinkSettings = (
+ index: number | null,
+ type: "time" | "usage",
+ date?: Date,
+ usageCount?: number | null
+ ) => {
+ if (index === null) return;
+
+ const currentValue = formMethods.getValues("multiplePrivateLinks") || [];
+ // Convert any string values to PrivateLinkWithOptions
+ const convertedValue = currentValue.map((val: string | PrivateLinkWithOptions) =>
+ typeof val === "string" ? { link: val, expiresAt: null, maxUsageCount: 1, usageCount: 0 } : val
+ );
+
+ if (type === "time") {
+ const selectedDate = date || expiryDate;
+
+ const dateString = dayjs(selectedDate).format("YYYY-MM-DD");
+ const endOfDayInUTC = dayjs.utc(`${dateString}T23:59:59.999Z`).toDate();
+
+ convertedValue[index] = {
+ ...convertedValue[index],
+ expiresAt: endOfDayInUTC,
+ maxUsageCount: null,
+ };
+ } else if (type === "usage") {
+ convertedValue[index] = {
+ ...convertedValue[index],
+ expiresAt: null,
+ maxUsageCount: usageCount !== undefined ? usageCount : maxUsageCount,
+ };
+ }
+
+ // Update the form value and trigger form change
+ // Store the full objects so the UI can display updated settings immediately
+ formMethods.setValue("multiplePrivateLinks", convertedValue, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ });
+ };
+
+ const openSettingsDialog = (index: number, currentLink: PrivateLinkWithOptions) => {
+ setCurrentLinkIndex(index);
+ if (currentLink.expiresAt) {
+ setSelectedType("time");
+ const expiryDate = dayjs.utc(currentLink.expiresAt).format("YYYY-MM-DD");
+ setExpiryDate(dayjs(expiryDate).toDate());
+ } else {
+ setSelectedType("usage");
+ setMaxUsageCount(currentLink.maxUsageCount ?? 1);
+ }
+ setIsDialogOpen(true);
+ };
+
return (
- {
- if (!value) {
- value = [];
- }
- const addPrivateLink = () => {
- const newPrivateLink = generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id);
- if (!value) value = [];
- value.push(newPrivateLink);
- onChange(value);
- };
+ <>
+ {
+ if (!value) {
+ value = [];
+ }
- const removePrivateLink = (index: number) => {
- if (!value) value = [];
- value.splice(index, 1);
- onChange(value);
- };
+ // Convert any string values to PrivateLinkWithOptions
+ const convertedValue = value.map((val: string | PrivateLinkWithOptions) =>
+ typeof val === "string" ? { link: val, expiresAt: null, maxUsageCount: null, usageCount: 0 } : val
+ );
+
+ // Query all links at once instead of individually
+ const { data: allLinksData } = trpc.viewer.eventTypes.getHashedLinks.useQuery(
+ { linkIds: convertedValue.map((val) => val.link) },
+ {
+ enabled: convertedValue.length > 0,
+ refetchOnMount: true,
+ }
+ );
+
+ type HashedLinkData = {
+ id: number | string;
+ linkId: string;
+ expiresAt: Date | null;
+ maxUsageCount: number | null;
+ usageCount: number;
+ };
+
+ const linkDataMap = new Map(allLinksData?.map((data: HashedLinkData) => [data.linkId, data]) || []);
+
+ const addPrivateLink = () => {
+ const userId = formMethods.getValues("users")?.[0]?.id ?? team?.id;
+ if (!userId) return;
+
+ const newPrivateLink = {
+ link: generateHashedLink(userId),
+ expiresAt: null,
+ maxUsageCount: 1,
+ usageCount: 0,
+ };
+
+ const newValue = [...convertedValue, newPrivateLink];
+ onChange(newValue);
+ };
+
+ const removePrivateLink = (index: number) => {
+ const newValue = [...convertedValue];
+ newValue.splice(index, 1);
+ onChange(newValue);
+
+ if (newValue.length === 0 && setMultiplePrivateLinksVisible) {
+ setMultiplePrivateLinksVisible(false);
+ }
+ };
+
+ const isLinkExpired = (val: PrivateLinkWithOptions) => {
+ const latestLinkData = linkDataMap.get(val.link);
+ const latestUsageCount =
+ latestLinkData?.usageCount ?? ((val as PrivateLinkWithOptions).usageCount || 0);
+
+ return utilsIsLinkExpired(
+ {
+ expiresAt: val.expiresAt,
+ maxUsageCount: val.maxUsageCount,
+ usageCount: latestUsageCount,
+ },
+ userTimeZone
+ );
+ };
+
+ const getLinkDescription = (link: PrivateLinkWithOptions, latestUsageCount?: number) => {
+ const isExpired = isLinkExpired(link);
+ const usageCount = latestUsageCount ?? (link.usageCount || 0);
+
+ if (link.expiresAt) {
+ const expiryDate = dayjs.utc(link.expiresAt).format("MMM DD, YYYY");
+ return isExpired
+ ? t("link_expired_on_date", { date: expiryDate })
+ : t("expires_on_date", { date: expiryDate });
+ } else if (
+ link.maxUsageCount !== undefined &&
+ link.maxUsageCount !== null &&
+ !isNaN(Number(link.maxUsageCount))
+ ) {
+ const maxUses = link.maxUsageCount;
+ const remainingUses = maxUses - usageCount;
+
+ if (isExpired) {
+ return t("usage_limit_reached");
+ } else {
+ return remainingUses === 1
+ ? t("remainder_of_maximum_use_left_singular", {
+ remainder: remainingUses,
+ maximum: maxUses,
+ })
+ : t("remainder_of_maximum_uses_left_plural", {
+ remainder: remainingUses,
+ maximum: maxUses,
+ });
+ }
+ }
+
+ // Default case for links without expiry or usage limits
+ return t("remainder_of_maximum_use_left_singular", {
+ remainder: "1",
+ maximum: "1",
+ });
+ };
+
+ const sortedLinksWithIndex = convertedValue
+ .map((val, originalIndex) => {
+ const expired = isLinkExpired(val);
+ const hasExpiry = !!val.expiresAt;
+ const expiryTime = hasExpiry ? new Date(val.expiresAt!).getTime() : null;
+
+ return {
+ val,
+ originalIndex,
+ expired,
+ hasExpiry,
+ expiryTime,
+ };
+ })
+ .sort((a, b) => {
+ // Sort expired links last
+ if (a.expired !== b.expired) {
+ return a.expired ? 1 : -1;
+ }
+
+ // Both not expired
+ if (!a.expired && !b.expired) {
+ // Prefer links with expiry date
+ if (a.hasExpiry !== b.hasExpiry) {
+ return a.hasExpiry ? -1 : 1;
+ }
+
+ // Both have expiry, sort by expiry time
+ if (a.hasExpiry && b.hasExpiry) {
+ return a.expiryTime! - b.expiryTime!;
+ }
+
+ // Neither has expiry: preserve original order (descending)
+ return b.originalIndex - a.originalIndex;
+ }
+
+ // Both expired: preserve original order (ascending)
+ return a.originalIndex - b.originalIndex;
+ });
+
+ return (
+
+ {sortedLinksWithIndex.map(({ val, originalIndex }, key) => {
+ const singleUseURL = `${bookerUrl}/d/${val.link}/${formMethods.getValues("slug")}`;
+
+ const latestLinkData = linkDataMap.get(val.link);
+ const latestUsageCount =
+ latestLinkData?.usageCount ?? ((val as PrivateLinkWithOptions).usageCount || 0);
+
+ const isExpired = isLinkExpired(val);
+ const linkDescription = getLinkDescription(val, latestUsageCount);
- 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/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx
index 253d12e3cd..bfb21d5644 100644
--- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx
+++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx
@@ -40,6 +40,7 @@ import type { EventNameObjectType } from "@calcom/lib/event";
import { getEventName } from "@calcom/lib/event";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
+import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -108,7 +109,10 @@ type BookingField = z.infer;
export type EventAdvancedBaseProps = Pick & {
user?: Partial<
- Pick
+ Pick<
+ RouterOutputs["viewer"]["me"]["get"],
+ "email" | "secondaryEmails" | "theme" | "defaultBookerLayouts" | "timeZone"
+ >
>;
isUserLoading?: boolean;
showToast: (message: string, variant: "success" | "warning" | "error") => void;
@@ -529,6 +533,14 @@ export const EventAdvancedTab = ({
}
);
+ const userTimeZone = extractHostTimezone({
+ userId: eventType.userId,
+ teamId: eventType.teamId,
+ hosts: eventType.hosts,
+ owner: eventType.owner,
+ team: eventType.team,
+ });
+
let verifiedSecondaryEmails = [
{
label: user?.email || "",
@@ -855,7 +867,12 @@ export const EventAdvancedTab = ({
}}>
{!isManagedEventType && (
-
+
)}
diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts
index 1e244ffbfa..be6600c70d 100644
--- a/packages/features/eventtypes/lib/types.ts
+++ b/packages/features/eventtypes/lib/types.ts
@@ -66,6 +66,13 @@ type PhoneCallConfig = {
schedulerName?: string;
};
+export type PrivateLinkWithOptions = {
+ link: string;
+ expiresAt?: Date | null;
+ maxUsageCount?: number | null;
+ usageCount?: number;
+};
+
export type FormValues = {
id: number;
title: string;
@@ -89,7 +96,7 @@ export type FormValues = {
schedulingType: SchedulingType | null;
hidden: boolean;
hideCalendarNotes: boolean;
- multiplePrivateLinks: string[] | undefined;
+ multiplePrivateLinks: (string | PrivateLinkWithOptions)[] | undefined;
eventTypeColor: z.infer;
customReplyToEmail: string | null;
locations: EventLocation[];
diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts
index b96691491e..1ea098ec78 100644
--- a/packages/lib/errorCodes.ts
+++ b/packages/lib/errorCodes.ts
@@ -24,4 +24,5 @@ export enum ErrorCode {
BookingNotAllowedByRestrictionSchedule = "booking_not_allowed_by_restriction_schedule_error",
RestrictionScheduleNotFound = "restriction_schedule_not_found_error",
EventTypeNoHosts = "event_type_no_hosts",
+ PrivateLinkExpired = "private_link_expired",
}
diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts
index 229e2798ff..ab9f901576 100644
--- a/packages/lib/event-types/getEventTypeById.ts
+++ b/packages/lib/event-types/getEventTypeById.ts
@@ -50,6 +50,7 @@ export const getEventTypeById = async ({
locale: true,
defaultScheduleId: true,
isPlatformManaged: true,
+ timeZone: true,
} satisfies Prisma.UserSelect;
const eventTypeRepo = new EventTypeRepository(prisma);
diff --git a/packages/lib/hashedLinksUtils.ts b/packages/lib/hashedLinksUtils.ts
new file mode 100644
index 0000000000..2a93780240
--- /dev/null
+++ b/packages/lib/hashedLinksUtils.ts
@@ -0,0 +1,145 @@
+import dayjs from "@calcom/dayjs";
+import { ErrorCode } from "@calcom/lib/errorCodes";
+
+export type EventTypeForTimezone = {
+ userId?: number | null;
+ teamId?: number | null;
+ hosts?: Array<{
+ user: {
+ timeZone: string | null;
+ } | null;
+ }> | null;
+ owner?: {
+ timeZone: string | null;
+ } | null;
+ team?: {
+ members?: Array<{
+ user?: {
+ timeZone: string | null;
+ } | null;
+ }> | null;
+ } | null;
+};
+
+export type HashedLinkData = {
+ id: number;
+ expiresAt: Date | null;
+ maxUsageCount: number | null;
+ usageCount: number;
+ eventType: EventTypeForTimezone;
+};
+
+/**
+ * Extracts the host timezone from event type data
+ * @param eventType - Event type data with potential timezone information
+ * @returns Host timezone string or null if not found
+ */
+export function extractHostTimezone(eventType: EventTypeForTimezone): string {
+ if (eventType?.userId && eventType?.owner?.timeZone) {
+ // Personal event type - use owner's timezone
+ return eventType.owner.timeZone;
+ } else if (eventType?.teamId) {
+ // Team event type - try hosts first, then team members
+ if (eventType.hosts && eventType.hosts.length > 0 && eventType.hosts[0]?.user?.timeZone) {
+ return eventType.hosts[0].user.timeZone;
+ } else if (
+ eventType.team?.members &&
+ eventType.team.members.length > 0 &&
+ eventType.team.members[0]?.user?.timeZone
+ ) {
+ return eventType.team.members[0]?.user?.timeZone;
+ }
+ }
+ return dayjs.tz.guess();
+}
+
+/**
+ * Core time-based expiration logic
+ * @param expiresAt - The expiration date
+ * @param timezone - Optional timezone for comparison
+ * @returns true if expired, false if valid
+ */
+function hasExpiryTimePassed(expiresAt: Date | string | null, timezone?: string | null): boolean {
+ if (!expiresAt) return false;
+
+ if (timezone) {
+ const now = dayjs().tz(timezone);
+ const expiration = dayjs(expiresAt).tz(timezone);
+ return expiration.isBefore(now);
+ } else {
+ const now = dayjs();
+ const expiration = dayjs(expiresAt);
+ return expiration.isBefore(now);
+ }
+}
+
+/**
+ * Validates if a hashed link has expired based on time
+ * @param expiresAt - The expiration date
+ * @param eventType - Event type data for timezone extraction
+ * @returns true if expired, false if valid
+ */
+export function isTimeBasedExpired(expiresAt: Date | null, eventType: EventTypeForTimezone): boolean {
+ const hostTimezone = extractHostTimezone(eventType);
+ return hasExpiryTimePassed(expiresAt, hostTimezone);
+}
+
+/**
+ * Validates if a hashed link has exceeded its usage limit
+ * @param usageCount - Current usage count
+ * @param maxUsageCount - Maximum allowed usage count
+ * @returns true if usage exceeded, false if valid
+ */
+export function isUsageBasedExpired(usageCount: number, maxUsageCount?: number | null): boolean {
+ if (!maxUsageCount || maxUsageCount <= 0) return false;
+ return usageCount >= maxUsageCount;
+}
+
+/**
+ * Validates hashed link data for expiration
+ * @param linkData - The hashed link data to validate
+ * @throws Error with ErrorCode.PrivateLinkExpired if link is expired
+ */
+export function validateHashedLinkData(linkData: HashedLinkData): void {
+ if (isTimeBasedExpired(linkData.expiresAt, linkData.eventType)) {
+ throw new Error(ErrorCode.PrivateLinkExpired);
+ }
+
+ if (isUsageBasedExpired(linkData.usageCount, linkData.maxUsageCount)) {
+ throw new Error(ErrorCode.PrivateLinkExpired);
+ }
+}
+
+/**
+ * Checks if a link has expired
+ * @param link - Link object with expiration and usage data
+ * @param timezone - Optional timezone for time-based expiration
+ * @returns true if expired, false if valid
+ */
+export function isLinkExpired(
+ link: {
+ expiresAt?: Date | string | null;
+ maxUsageCount?: number | null;
+ usageCount?: number | null;
+ },
+ timezone?: string | null
+): boolean {
+ if (link.expiresAt) return hasExpiryTimePassed(link.expiresAt, timezone);
+ return isUsageBasedExpired(link.usageCount || 0, link.maxUsageCount);
+}
+
+/**
+ * filters out expired links
+ * @param links - Array of links to filter
+ * @param timezone - Optional timezone for time-based expiration
+ * @returns Array of active (non-expired) links
+ */
+export function filterActiveLinks<
+ T extends {
+ expiresAt?: Date | null;
+ maxUsageCount?: number | null;
+ usageCount?: number | null;
+ }
+>(links: T[], timezone?: string | null): T[] {
+ return links.filter((link) => !isLinkExpired(link, timezone));
+}
diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts
index e4870aeae0..06c20e1f57 100644
--- a/packages/lib/server/eventTypeSelect.ts
+++ b/packages/lib/server/eventTypeSelect.ts
@@ -19,6 +19,11 @@ export const eventTypeSelect = {
autoTranslateDescriptionEnabled: true,
position: true,
offsetStart: true,
+ owner: {
+ select: {
+ timeZone: true,
+ },
+ },
profileId: true,
eventName: true,
parentId: true,
diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts
index a88a230d02..fa3f8ff80b 100644
--- a/packages/lib/server/repository/eventTypeRepository.ts
+++ b/packages/lib/server/repository/eventTypeRepository.ts
@@ -19,6 +19,17 @@ import type { UserWithLegacySelectedCalendars } from "./user";
import { withSelectedCalendars } from "./user";
const log = logger.getSubLogger({ prefix: ["repository/eventType"] });
+
+const hashedLinkSelect = {
+ select: {
+ id: true,
+ link: true,
+ expiresAt: true,
+ maxUsageCount: true,
+ usageCount: true,
+ },
+};
+
type NotSupportedProps = "locations";
type IEventType = Ensure<
Partial<
@@ -50,6 +61,7 @@ const userSelect = {
avatarUrl: true,
username: true,
id: true,
+ timeZone: true,
} satisfies Prisma.UserSelect;
function hostsWithSelectedCalendars(
@@ -159,7 +171,7 @@ export class EventTypeRepository {
const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id;
const select = {
...eventTypeSelect,
- hashedLink: true,
+ hashedLink: hashedLinkSelect,
users: { select: userSelect },
children: {
include: {
@@ -171,6 +183,21 @@ export class EventTypeRepository {
user: { select: userSelect },
},
},
+ team: {
+ select: {
+ id: true,
+ members: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ take: 1,
+ },
+ },
+ },
};
log.debug(
@@ -274,7 +301,7 @@ export class EventTypeRepository {
const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id;
const select = {
...eventTypeSelect,
- hashedLink: true,
+ hashedLink: hashedLinkSelect,
};
log.debug(
@@ -381,11 +408,12 @@ export class EventTypeRepository {
avatarUrl: true,
username: true,
id: true,
+ timeZone: true,
} satisfies Prisma.UserSelect;
const select = {
...eventTypeSelect,
- hashedLink: true,
+ hashedLink: hashedLinkSelect,
users: { select: userSelect, take: 5 },
children: {
include: {
@@ -398,6 +426,21 @@ export class EventTypeRepository {
},
take: 5,
},
+ team: {
+ select: {
+ id: true,
+ members: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ take: 1,
+ },
+ },
+ },
};
const teamMembership = await this.prismaClient.membership.findFirst({
@@ -478,6 +521,7 @@ export class EventTypeRepository {
locale: true,
defaultScheduleId: true,
isPlatformManaged: true,
+ timeZone: true,
} satisfies Prisma.UserSelect;
const CompleteEventTypeSelect = {
@@ -528,7 +572,7 @@ export class EventTypeRepository {
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
- hashedLink: true,
+ hashedLink: hashedLinkSelect,
eventTypeColor: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
@@ -551,6 +595,7 @@ export class EventTypeRepository {
owner: {
select: {
id: true,
+ timeZone: true,
},
},
parent: {
@@ -626,6 +671,11 @@ export class EventTypeRepository {
priority: true,
weight: true,
scheduleId: true,
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
},
},
userId: true,
diff --git a/packages/lib/server/repository/hashedLinkRepository.ts b/packages/lib/server/repository/hashedLinkRepository.ts
new file mode 100644
index 0000000000..6220891bb0
--- /dev/null
+++ b/packages/lib/server/repository/hashedLinkRepository.ts
@@ -0,0 +1,262 @@
+import type { PrismaClient } from "@calcom/prisma";
+import { prisma } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+
+export type HashedLinkInputType = {
+ link: string;
+ expiresAt?: Date | null;
+ maxUsageCount?: number | null;
+};
+
+export const hashedLinkSelect = {
+ id: true,
+ link: true,
+ eventTypeId: true,
+ expiresAt: true,
+ maxUsageCount: true,
+ usageCount: true,
+ eventType: {
+ select: {
+ userId: true,
+ teamId: true,
+ users: {
+ select: {
+ username: true,
+ profiles: {
+ select: {
+ id: true,
+ organizationId: true,
+ username: true,
+ },
+ },
+ },
+ },
+ team: {
+ select: {
+ id: true,
+ slug: true,
+ hideBranding: true,
+ parent: {
+ select: {
+ hideBranding: true,
+ },
+ },
+ members: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ take: 1,
+ },
+ },
+ },
+ hosts: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ },
+ profile: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ },
+ owner: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ },
+} satisfies Prisma.HashedLinkSelect;
+
+export class HashedLinkRepository {
+ constructor(private readonly prismaClient: PrismaClient) {}
+
+ static create() {
+ return new HashedLinkRepository(prisma);
+ }
+
+ async deleteLinks(eventTypeId: number, linksToDelete: string[]) {
+ if (linksToDelete.length === 0) return;
+
+ return await this.prismaClient.hashedLink.deleteMany({
+ where: {
+ eventTypeId,
+ link: { in: linksToDelete },
+ },
+ });
+ }
+
+ async createLink(
+ eventTypeId: number,
+ linkData: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
+ ) {
+ const data: Prisma.HashedLinkCreateManyInput = {
+ eventTypeId,
+ link: linkData.link,
+ expiresAt: linkData.expiresAt,
+ };
+
+ if (linkData.maxUsageCount && Number.isFinite(linkData.maxUsageCount)) {
+ data.maxUsageCount = linkData.maxUsageCount;
+ }
+
+ return await this.prismaClient.hashedLink.create({ data });
+ }
+
+ async updateLink(
+ eventTypeId: number,
+ linkData: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
+ ) {
+ const updateData: Prisma.HashedLinkUpdateManyMutationInput = {
+ expiresAt: linkData.expiresAt,
+ };
+
+ if (typeof linkData.maxUsageCount === "number" && linkData.maxUsageCount !== null) {
+ updateData.maxUsageCount = linkData.maxUsageCount;
+ }
+
+ return await this.prismaClient.hashedLink.updateMany({
+ where: {
+ eventTypeId,
+ link: linkData.link,
+ },
+ data: updateData,
+ });
+ }
+
+ async findLinksByEventTypeId(eventTypeId: number) {
+ return await this.prismaClient.hashedLink.findMany({
+ where: {
+ eventTypeId,
+ },
+ select: {
+ link: true,
+ expiresAt: true,
+ maxUsageCount: true,
+ usageCount: true,
+ },
+ });
+ }
+
+ async findLinkWithEventTypeDetails(linkId: string) {
+ return await this.prismaClient.hashedLink.findUnique({
+ where: {
+ link: linkId,
+ },
+ select: {
+ id: true,
+ link: true,
+ expiresAt: true,
+ maxUsageCount: true,
+ usageCount: true,
+ eventTypeId: true,
+ eventType: {
+ select: {
+ teamId: true,
+ userId: true,
+ },
+ },
+ },
+ });
+ }
+
+ async findLinkWithDetails(linkId: string) {
+ return await this.prismaClient.hashedLink.findUnique({
+ where: {
+ link: linkId,
+ },
+ select: hashedLinkSelect,
+ });
+ }
+
+ async findLinksWithEventTypeDetails(linkIds: string[]) {
+ return await this.prismaClient.hashedLink.findMany({
+ where: {
+ link: {
+ in: linkIds,
+ },
+ },
+ select: {
+ id: true,
+ link: true,
+ expiresAt: true,
+ maxUsageCount: true,
+ usageCount: true,
+ eventTypeId: true,
+ eventType: {
+ select: {
+ teamId: true,
+ userId: true,
+ },
+ },
+ },
+ });
+ }
+
+ async findLinkWithValidationData(linkId: string) {
+ return await this.prismaClient.hashedLink.findUnique({
+ where: {
+ link: linkId,
+ },
+ select: {
+ id: true,
+ expiresAt: true,
+ maxUsageCount: true,
+ usageCount: true,
+ eventType: {
+ select: {
+ userId: true,
+ teamId: true,
+ hosts: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ },
+ profile: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ },
+ owner: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ },
+ },
+ });
+ }
+
+ async incrementUsage(linkId: number, maxUsageCount: number) {
+ return await this.prismaClient.hashedLink.update({
+ where: {
+ id: linkId,
+ usageCount: { lt: maxUsageCount },
+ },
+ data: {
+ usageCount: { increment: 1 },
+ },
+ });
+ }
+}
diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts
index 01b4021d82..895475f51e 100644
--- a/packages/lib/server/repository/membership.ts
+++ b/packages/lib/server/repository/membership.ts
@@ -41,6 +41,7 @@ const userSelect = {
avatarUrl: true,
username: true,
id: true,
+ timeZone: true,
} satisfies Prisma.UserSelect;
const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipWhereInput) => {
@@ -164,6 +165,21 @@ export class MembershipRepository {
user: { select: userSelect },
},
},
+ team: {
+ select: {
+ id: true,
+ members: {
+ select: {
+ user: {
+ select: {
+ timeZone: true,
+ },
+ },
+ },
+ take: 1,
+ },
+ },
+ },
},
// As required by getByViewHandler - Make it configurable
orderBy: [
diff --git a/packages/lib/server/service/hashedLinkService.ts b/packages/lib/server/service/hashedLinkService.ts
new file mode 100644
index 0000000000..4d55e196b8
--- /dev/null
+++ b/packages/lib/server/service/hashedLinkService.ts
@@ -0,0 +1,159 @@
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import { validateHashedLinkData } from "@calcom/lib/hashedLinksUtils";
+
+import { HashedLinkRepository, type HashedLinkInputType } from "../repository/hashedLinkRepository";
+import { MembershipService } from "./membershipService";
+
+type NormalizedLink = {
+ link: string;
+ expiresAt: Date | null;
+ maxUsageCount?: number | null;
+};
+
+export class HashedLinkService {
+ constructor(
+ private readonly hashedLinkRepository: HashedLinkRepository = HashedLinkRepository.create(),
+ private readonly membershipService: MembershipService = new MembershipService()
+ ) {}
+
+ /**
+ * Normalizes link input to a consistent format
+ * @param input - String link or object with link and options
+ * @returns Normalized link object
+ */
+ private normalizeLinkInput(input: string | HashedLinkInputType): NormalizedLink {
+ return typeof input === "string"
+ ? { link: input, expiresAt: null }
+ : {
+ link: input.link,
+ expiresAt: input.expiresAt ?? null,
+ maxUsageCount: input.maxUsageCount,
+ };
+ }
+
+ /**
+ * Handles multiple private links operations - orchestrates create, update, and delete
+ * @param eventTypeId - The ID of the event type
+ * @param multiplePrivateLinks - Array of links to create/update (can be strings or objects with expiration)
+ * @param connectedMultiplePrivateLinks - Array of existing link IDs for this event type
+ */
+ async handleMultiplePrivateLinks({
+ eventTypeId,
+ multiplePrivateLinks,
+ connectedMultiplePrivateLinks,
+ }: {
+ eventTypeId: number;
+ multiplePrivateLinks?: (string | HashedLinkInputType)[];
+ connectedMultiplePrivateLinks: string[];
+ }) {
+ if (!eventTypeId || eventTypeId <= 0) {
+ throw new Error("Invalid event type ID");
+ }
+
+ if (!multiplePrivateLinks || multiplePrivateLinks.length === 0) {
+ await this.hashedLinkRepository.deleteLinks(eventTypeId, connectedMultiplePrivateLinks);
+ return;
+ }
+
+ const normalizedLinks = multiplePrivateLinks.map((input) => this.normalizeLinkInput(input));
+ const currentLinks = normalizedLinks.map((l) => l.link);
+
+ const currentLinksSet = new Set(currentLinks);
+
+ const linksToDelete = connectedMultiplePrivateLinks.filter((link) => !currentLinksSet.has(link));
+ await this.hashedLinkRepository.deleteLinks(eventTypeId, linksToDelete);
+
+ const existingLinksSet = new Set(connectedMultiplePrivateLinks);
+
+ for (const linkData of normalizedLinks) {
+ const exists = existingLinksSet.has(linkData.link);
+ if (!exists) {
+ await this.hashedLinkRepository.createLink(eventTypeId, linkData);
+ } else {
+ await this.hashedLinkRepository.updateLink(eventTypeId, linkData);
+ }
+ }
+ }
+
+ /**
+ * Validates a link without incrementing usage count
+ * Handles both time-based and usage-based expiration with timezone awareness
+ * @param linkId - The hashed link ID to validate
+ * @returns The validated link data
+ * @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid
+ */
+ async validate(linkId: string) {
+ if (!linkId || typeof linkId !== "string") {
+ throw new Error("Invalid link ID");
+ }
+
+ const hashedLink = await this.hashedLinkRepository.findLinkWithValidationData(linkId);
+
+ if (!hashedLink) {
+ throw new Error(ErrorCode.PrivateLinkExpired);
+ }
+
+ validateHashedLinkData(hashedLink);
+
+ return hashedLink;
+ }
+
+ /**
+ * Validates a link and increments usage count if valid
+ * Handles both time-based and usage-based expiration with timezone awareness
+ * @param linkId - The hashed link ID to validate
+ * @returns The validated link data
+ * @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid
+ */
+ async validateAndIncrementUsage(linkId: string) {
+ const hashedLink = await this.validate(linkId);
+ if (hashedLink.expiresAt) return hashedLink; // Time-based links don't need usage count increment
+
+ if (hashedLink.maxUsageCount && hashedLink.maxUsageCount > 0) {
+ try {
+ await this.hashedLinkRepository.incrementUsage(hashedLink.id, hashedLink.maxUsageCount);
+ } catch (updateError) {
+ throw new Error(ErrorCode.PrivateLinkExpired);
+ }
+ }
+
+ return hashedLink;
+ }
+
+ async findLinkWithDetails(linkId: string) {
+ const hashedLink = await this.hashedLinkRepository.findLinkWithDetails(linkId);
+ if (!hashedLink) {
+ return null;
+ }
+
+ return hashedLink;
+ }
+
+ /**
+ * Checks if a user has permission to access a link
+ * @param link - Link object with event type information
+ * @param userId - The user ID to check permissions for
+ * @returns true if user has permission, false otherwise
+ */
+ async checkUserPermissionForLink(
+ link: { eventType: { teamId?: number | null; userId?: number | null } },
+ userId: number
+ ): Promise {
+ if (!link?.eventType || !userId || userId <= 0) {
+ return false;
+ }
+
+ // If it's a user event type, check if user owns it
+ if (link.eventType.userId) {
+ return link.eventType.userId === userId;
+ }
+
+ //No user ownership and no team - deny access
+ if (!link.eventType.teamId) {
+ return false;
+ }
+
+ const membership = await this.membershipService.checkMembership(link.eventType.teamId, userId);
+ return membership.isMember;
+ }
+}
diff --git a/packages/lib/server/service/membershipService.ts b/packages/lib/server/service/membershipService.ts
new file mode 100644
index 0000000000..99a24ca64d
--- /dev/null
+++ b/packages/lib/server/service/membershipService.ts
@@ -0,0 +1,41 @@
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { MembershipRepository } from "../repository/membership";
+
+export type MembershipCheckResult = {
+ isMember: boolean;
+ isAdmin: boolean;
+ isOwner: boolean;
+ role?: MembershipRole;
+};
+
+export class MembershipService {
+ constructor(private readonly membershipRepository: MembershipRepository = new MembershipRepository()) {}
+
+ /**
+ * Checks the membership status of a user within a specific team.
+ */
+ async checkMembership(teamId: number, userId: number): Promise {
+ const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ teamId, userId });
+
+ if (!membership || !membership.accepted) {
+ return {
+ isMember: false,
+ isAdmin: false,
+ isOwner: false,
+ role: undefined,
+ };
+ }
+
+ const { role } = membership;
+ const isOwner = role === MembershipRole.OWNER;
+ const isAdmin = isOwner || role === MembershipRole.ADMIN;
+
+ return {
+ isMember: true,
+ isAdmin,
+ isOwner,
+ role,
+ };
+ }
+}
diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
index 6abcfc9244..94fcb98228 100644
--- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
+++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
@@ -73,7 +73,11 @@ export const useEventTypeForm = ({
durationLimits: eventType.durationLimits || undefined,
length: eventType.length,
hidden: eventType.hidden,
- multiplePrivateLinks: eventType.hashedLink.map((link) => link.link),
+ multiplePrivateLinks: eventType.hashedLink.map((link) => ({
+ link: link.link,
+ expiresAt: link.expiresAt,
+ maxUsageCount: link.maxUsageCount,
+ })),
eventTypeColor: eventType.eventTypeColor || null,
periodDates: {
startDate: periodDates.startDate,
diff --git a/packages/prisma/migrations/20250707145503_add_private_links_expiration_capability/migration.sql b/packages/prisma/migrations/20250707145503_add_private_links_expiration_capability/migration.sql
new file mode 100644
index 0000000000..85d675067d
--- /dev/null
+++ b/packages/prisma/migrations/20250707145503_add_private_links_expiration_capability/migration.sql
@@ -0,0 +1,7 @@
+-- AlterTable
+ALTER TABLE "HashedLink" ADD COLUMN "expiresAt" TIMESTAMP(3),
+ADD COLUMN "maxUsageCount" INTEGER NOT NULL DEFAULT 1,
+ADD COLUMN "usageCount" INTEGER NOT NULL DEFAULT 0;
+
+-- CreateIndex
+CREATE INDEX "HashedLink_eventTypeId_idx" ON "HashedLink"("eventTypeId");
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 9da776099e..8298a97a79 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -1061,10 +1061,15 @@ model RateLimit {
}
model HashedLink {
- id Int @id @default(autoincrement())
- link String @unique()
- eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
- eventTypeId Int
+ id Int @id @default(autoincrement())
+ link String @unique
+ eventTypeId Int
+ eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
+ expiresAt DateTime?
+ maxUsageCount Int @default(1)
+ usageCount Int @default(0)
+
+ @@index([eventTypeId])
}
model Account {
diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts
index bb233e11b5..e563453285 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts
@@ -8,6 +8,8 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDuplicateInputSchema } from "./duplicate.schema";
import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema";
+import { ZGetHashedLinkInputSchema } from "./getHashedLink.schema";
+import { ZGetHashedLinksInputSchema } from "./getHashedLinks.schema";
import { ZGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema";
import { get } from "./procedures/get";
import { ZUpdateInputSchema } from "./update.schema";
@@ -29,6 +31,9 @@ type BookingsRouterHandlerCache = {
bulkUpdateToDefaultLocation?: typeof import("./bulkUpdateToDefaultLocation.handler").bulkUpdateToDefaultLocationHandler;
};
+// Init the handler cache
+const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
+
export const eventTypesRouter = router({
// REVIEW: What should we name this procedure?
getByViewer: authedProcedure.input(ZEventTypeInputSchema).query(async ({ ctx, input }) => {
@@ -170,4 +175,22 @@ export const eventTypesRouter = router({
input,
});
}),
+
+ getHashedLink: authedProcedure.input(ZGetHashedLinkInputSchema).query(async ({ ctx, input }) => {
+ const { getHashedLinkHandler } = await import("./getHashedLink.handler");
+
+ return getHashedLinkHandler({
+ ctx,
+ input,
+ });
+ }),
+
+ getHashedLinks: authedProcedure.input(ZGetHashedLinksInputSchema).query(async ({ ctx, input }) => {
+ const { getHashedLinksHandler } = await import("./getHashedLinks.handler");
+
+ return getHashedLinksHandler({
+ ctx,
+ input,
+ });
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts
index 81dc2db169..f9f63577b2 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts
@@ -181,14 +181,18 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
data: customInputsData,
});
}
+
if (hashedLink.length > 0) {
- await prisma.hashedLink.create({
- data: {
- link: generateHashedLink(users[0]?.id ?? newEventType.teamId),
- eventType: {
- connect: { id: newEventType.id },
- },
- },
+ const newHashedLinksData = hashedLink.map((originalLink, index) => ({
+ link: generateHashedLink(
+ `${users[0]?.id ?? newEventType.teamId ?? originalLink.eventTypeId}-${index}`
+ ),
+ eventTypeId: newEventType.id,
+ expiresAt: originalLink.expiresAt,
+ maxUsageCount: originalLink.maxUsageCount,
+ }));
+ await prisma.hashedLink.createMany({
+ data: newHashedLinksData,
});
}
diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts
new file mode 100644
index 0000000000..c81374fd11
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts
@@ -0,0 +1,57 @@
+import { HashedLinkRepository } from "@calcom/lib/server/repository/hashedLinkRepository";
+import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
+import type { PrismaClient } from "@calcom/prisma";
+import type { TrpcSessionUser } from "@calcom/trpc/server/types";
+
+import { TRPCError } from "@trpc/server";
+
+type GetHashedLinkOptions = {
+ ctx: {
+ user: NonNullable;
+ prisma: PrismaClient;
+ };
+ input: {
+ linkId: string;
+ };
+};
+
+export const getHashedLinkHandler = async ({ ctx, input }: GetHashedLinkOptions) => {
+ const { linkId } = input;
+
+ if (!linkId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Missing link ID",
+ });
+ }
+
+ const hashedLinkRepository = HashedLinkRepository.create();
+ // Get the hashed link with usage data
+ const hashedLink = await hashedLinkRepository.findLinkWithEventTypeDetails(linkId);
+
+ if (!hashedLink) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Hashed link not found",
+ });
+ }
+
+ // Check if the user has permission to access this hashed link
+ const hashedLinkService = new HashedLinkService();
+ const hasPermission = await hashedLinkService.checkUserPermissionForLink(hashedLink, ctx.user.id);
+
+ if (!hasPermission) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You don't have permission to access this link",
+ });
+ }
+
+ return {
+ id: hashedLink.id,
+ link: hashedLink.link,
+ expiresAt: hashedLink.expiresAt,
+ maxUsageCount: hashedLink.maxUsageCount,
+ usageCount: hashedLink.usageCount,
+ };
+};
diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.schema.ts
new file mode 100644
index 0000000000..5366046553
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZGetHashedLinkInputSchema = z.object({
+ linkId: z.string(),
+});
+
+export type TGetHashedLinkInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts
new file mode 100644
index 0000000000..53996fa30a
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts
@@ -0,0 +1,49 @@
+import { HashedLinkRepository } from "@calcom/lib/server/repository/hashedLinkRepository";
+import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
+import type { PrismaClient } from "@calcom/prisma";
+import type { TrpcSessionUser } from "@calcom/trpc/server/types";
+
+import type { TGetHashedLinksInputSchema } from "./getHashedLinks.schema";
+
+type GetHashedLinksOptions = {
+ ctx: {
+ user: NonNullable;
+ prisma: PrismaClient;
+ };
+ input: TGetHashedLinksInputSchema;
+};
+
+export const getHashedLinksHandler = async ({ ctx, input }: GetHashedLinksOptions) => {
+ const { linkIds } = input;
+
+ if (!linkIds.length) {
+ return [];
+ }
+
+ // Get all hashed links with usage data
+ const privateLinksRepo = HashedLinkRepository.create();
+ const hashedLinks = await privateLinksRepo.findLinksWithEventTypeDetails(linkIds);
+
+ // Check if the user has permission to access these hashed links
+ const privateLinkService = new HashedLinkService();
+ const validLinks = await Promise.all(
+ hashedLinks.map(async (link) => {
+ const hasPermission = await privateLinkService.checkUserPermissionForLink(link, ctx.user.id);
+
+ if (!hasPermission) {
+ return null;
+ }
+
+ return {
+ id: link.id,
+ linkId: link.link,
+ expiresAt: link.expiresAt,
+ maxUsageCount: link.maxUsageCount,
+ usageCount: link.usageCount,
+ };
+ })
+ );
+
+ // Filter out null values (unauthorized links)
+ return validLinks.filter((link): link is NonNullable => link !== null);
+};
diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.schema.ts
new file mode 100644
index 0000000000..3e9e24ee71
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZGetHashedLinksInputSchema = z.object({
+ linkIds: z.array(z.string()),
+});
+
+export type TGetHashedLinksInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts
index 2731d60b66..c0cdedec03 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/types.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts
@@ -11,6 +11,15 @@ import {
} from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
+const hashedLinkInputSchema = z
+ .object({
+ link: z.string(),
+ expiresAt: z.date().nullish(),
+ maxUsageCount: z.number().nullish(),
+ usageCount: z.number().nullish(),
+ })
+ .strict();
+
const aiPhoneCallConfig = z
.object({
generalPrompt: z.string(),
@@ -78,7 +87,7 @@ const BaseEventTypeUpdateInput = _EventTypeModel
hosts: z.array(hostSchema),
schedule: z.number().nullable(),
instantMeetingSchedule: z.number().nullable(),
- multiplePrivateLinks: z.array(z.string()),
+ multiplePrivateLinks: z.array(z.union([z.string(), hashedLinkInputSchema])),
assignAllTeamMembers: z.boolean(),
isRRWeightsEnabled: z.boolean(),
metadata: EventTypeMetaDataSchema,
diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts
index ecc5644f41..260a830cd2 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts
@@ -13,8 +13,10 @@ import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateI
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CalVideoSettingsRepository } from "@calcom/lib/server/repository/calVideoSettings";
+import { HashedLinkRepository } from "@calcom/lib/server/repository/hashedLinkRepository";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ScheduleRepository } from "@calcom/lib/server/repository/schedule";
+import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { PrismaClient } from "@calcom/prisma";
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
@@ -35,6 +37,7 @@ import {
} from "./util";
type SessionUser = NonNullable;
+
type User = {
id: SessionUser["id"];
username: SessionUser["username"];
@@ -491,58 +494,19 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
break;
}
}
- const connectedLinks = await ctx.prisma.hashedLink.findMany({
- where: {
- eventTypeId: input.id,
- },
- select: {
- id: true,
- link: true,
- },
- });
-
+ console.log("multiplePrivateLinks", multiplePrivateLinks);
+ // Handle multiple private links using the service
+ const privateLinksRepo = HashedLinkRepository.create();
+ const connectedLinks = await privateLinksRepo.findLinksByEventTypeId(input.id);
+ console.log("connectedLinks", connectedLinks);
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,
- link: {
- in: singleLinksToBeDeleted,
- },
- },
- });
- }
- if (multiplePrivateLinksToBeInserted.length > 0) {
- await ctx.prisma.hashedLink.createMany({
- data: multiplePrivateLinksToBeInserted.map((link) => {
- return {
- link: link,
- eventTypeId: input.id,
- };
- }),
- });
- }
- } else {
- // 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,
- },
- },
- });
- }
- }
+ const privateLinksService = new HashedLinkService();
+ await privateLinksService.handleMultiplePrivateLinks({
+ eventTypeId: input.id,
+ multiplePrivateLinks,
+ connectedMultiplePrivateLinks,
+ });
if (assignAllTeamMembers !== undefined) {
data.assignAllTeamMembers = assignAllTeamMembers;