feat: Enhance private link expiration with usage and date limits (#22304)

* init

* fix type

* fix a re-render infinite loop because of missing readOnly (╯°□°)╯︵ ┻━┻)

* further fixes

* improvement

* fix expiry datetime check

* remove unnecessary prismaMock def

* revert

* fix test

* add test ids

* remove unit tests in favor of e2e

* e2e test update

* fix e2e

* fix e2e

* remove unnecessary change

* abstract into injectable object

* further improvements

* fix label not selecting radio

* fix type

* code improvement

* DI implementation

* fix type

* fix quick copy

* code improvement and a few fixes

* further improvements and NITS

* further into DI

* select

* improve link list sorting

* prep for easier conflict resolution

* add back translations

* using useCopy instead

* improvement

* add index to update salt and have different hash generation

* fix private link description

* fix increment regression in expiry logic

* fixes

* address feedback

* use extractHostTimezone in event type listing

* remove unused function

* remove translationBundler

* -_-

* address feedback

* further changes

* address more feedback

* NIT

* address improvement suggestions

* use extractHostTimezone

* remove console log

* pre update

* code improvement

* further fixes

* cleanup

* -_-
This commit is contained in:
Syed Ali Shahbaz
2025-07-24 19:54:44 +01:00
committed by GitHub
parent b9c49b4567
commit 66b52bb19f
30 changed files with 1564 additions and 220 deletions
@@ -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,
},
};
}
@@ -19,6 +19,7 @@ export default function Type({
hashedLink,
durationConfig,
eventData,
useApiV2,
}: PageProps) {
return (
<BookingPageErrorBoundary>
@@ -34,6 +35,7 @@ export default function Type({
duration={duration}
hashedLink={hashedLink}
durationConfig={durationConfig}
useApiV2={useApiV2}
/>
</main>
</BookingPageErrorBoundary>
@@ -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 (
<div className="bg-default border-subtle flex flex-col overflow-hidden rounded-md border">
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
@@ -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 };
});
}}
/>
+147 -29
View File
@@ -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"
);
});
});
+23 -1
View File
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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);
@@ -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" });
@@ -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<EventTypeSetupProps["eventType"], "team" | "bookerUrl">) => {
setMultiplePrivateLinksVisible,
userTimeZone,
}: Pick<EventTypeSetupProps["eventType"], "team" | "bookerUrl"> & {
setMultiplePrivateLinksVisible?: (isVisible: boolean) => void;
userTimeZone?: string;
}): JSX.Element => {
const formMethods = useFormContext<FormValues>();
const { t } = useLocale();
const { copyToClipboard } = useCopy();
const [animateRef] = useAutoAnimate<HTMLUListElement>();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [currentLinkIndex, setCurrentLinkIndex] = useState<number | null>(null);
const [selectedType, setSelectedType] = useState<"time" | "usage">("usage");
const [expiryDate, setExpiryDate] = useState<Date>(new Date());
const [maxUsageCount, setMaxUsageCount] = useState<number | null>(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 (
<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);
};
<>
<Controller
name="multiplePrivateLinks"
control={formMethods.control}
render={({ field: { value, onChange } }) => {
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 (
<ul ref={animateRef}>
{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 (
<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"
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 data-testid="add-single-use-link" className="mb-4 flex flex-col" key={val.link}>
<div className="flex items-center">
<TextField
containerClassName={classNames("w-full")}
disabled={isExpired}
value={singleUseURL}
readOnly
className={classNames(isExpired ? "bg-red-50 text-gray-400" : "bg-gray-50")}
data-testid="private-link-url"
addOnSuffix={
!isExpired ? (
<Tooltip content={t("copy_link")}>
<Button
type="button"
color="minimal"
size="sm"
StartIcon="copy"
onClick={() => {
copyToClipboard(singleUseURL);
showToast(t("link_copied"), "success");
}}
/>
</Tooltip>
) : (
<Badge data-testid="private-link-expired" variant="red">
{t("expired")}
</Badge>
)
}
/>
)}
<div className="ml-2 flex items-center">
{!isExpired && (
<Button
type="button"
color="minimal"
variant="icon"
StartIcon="settings"
data-testid="private-link-settings"
onClick={() => openSettingsDialog(originalIndex, val)}
/>
)}
<Button
data-testid={`remove-private-link-${originalIndex}`}
variant="icon"
type="button"
StartIcon="trash-2"
color="destructive"
className="ml-1 border-none"
onClick={() => removePrivateLink(originalIndex)}
/>
</div>
</div>
<div data-testid="private-link-description" className="mt-1 text-sm text-gray-500">
{linkDescription}
</div>
</li>
);
})}
<Button
color="minimal"
StartIcon="plus"
onClick={addPrivateLink}
data-testid="add-private-link-button">
{t("add_a_multiple_private_link")}
</Button>
</ul>
);
}}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent title={t("link_settings")} type="creation">
<div className="mb-4 space-y-4">
<RadioArea.Group
className="space-y-2"
value={selectedType}
data-testid="private-link-radio-group"
onValueChange={(value: "time" | "usage") => {
setSelectedType(value);
}}>
<RadioArea.Item value="usage" data-testid="private-link-usage" className="w-full text-sm">
<strong className="mb-1 block">{t("usage_based_expiration")}</strong>
<p>
{selectedType !== "usage"
? t("usage_based_generic_expiration_description")
: t(
maxUsageCount === 1
? "usage_based_expiration_description"
: "usage_based_expiration_description_plural",
{ count: maxUsageCount || 0 }
)}
</p>
{selectedType === "usage" && (
<div className="mt-2 w-[180px]">
<NumberInput
required
min={1}
data-testid="private-link-usage-count"
placeholder={t("number_of_uses")}
value={maxUsageCount === null ? "" : maxUsageCount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === "" ? null : parseInt(e.target.value);
if (e.target.value === "") {
setMaxUsageCount(null);
} else if (!isNaN(Number(value)) && Number(value) > 0) {
setMaxUsageCount(value);
}
}}
/>
</div>
)}
</RadioArea.Item>
<RadioArea.Item data-testid="private-link-time" value="time" className="w-full text-sm">
<strong className="mb-1 block">{t("time_based_expiration")}</strong>
{selectedType !== "time"
? t("time_based_generic_expiration_description")
: t("time_based_expiration_description", {
date: dayjs(expiryDate).format("MMM DD, YYYY"),
})}
{selectedType === "time" && (
<div className="mt-2 w-[180px]">
<DatePicker
data-testid="private-link-expiration-date"
date={expiryDate}
onDatesChange={(newDate: Date) => {
setExpiryDate(newDate);
}}
/>
</div>
)}
</RadioArea.Item>
</RadioArea.Group>
</div>
<div className="mb-4 mt-4 flex justify-end">
<Button
color="minimal"
StartIcon="plus"
onClick={addPrivateLink}
data-testid="add-single-use-link-button">
{t("add_a_multiple_private_link")}
type="button"
color="primary"
data-testid="private-link-expiration-settings-save"
onClick={() => {
// Save the changes when Save button is clicked
if (selectedType === "time") {
updateLinkSettings(currentLinkIndex, "time", expiryDate);
} else {
updateLinkSettings(currentLinkIndex, "usage", undefined, maxUsageCount);
}
setIsDialogOpen(false);
}}>
{t("save")}
</Button>
</ul>
);
}}
/>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -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<typeof fieldSchema>;
export type EventAdvancedBaseProps = Pick<EventTypeSetupProps, "eventType" | "team"> & {
user?: Partial<
Pick<RouterOutputs["viewer"]["me"]["get"], "email" | "secondaryEmails" | "theme" | "defaultBookerLayouts">
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 && (
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<MultiplePrivateLinksController team={team} bookerUrl={eventType.bookerUrl} />
<MultiplePrivateLinksController
team={team}
bookerUrl={eventType.bookerUrl}
setMultiplePrivateLinksVisible={setMultiplePrivateLinksVisible}
userTimeZone={userTimeZone}
/>
</div>
)}
</SettingsToggle>
+8 -1
View File
@@ -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<typeof eventTypeColor>;
customReplyToEmail: string | null;
locations: EventLocation[];
+1
View File
@@ -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",
}
@@ -50,6 +50,7 @@ export const getEventTypeById = async ({
locale: true,
defaultScheduleId: true,
isPlatformManaged: true,
timeZone: true,
} satisfies Prisma.UserSelect;
const eventTypeRepo = new EventTypeRepository(prisma);
+145
View File
@@ -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));
}
+5
View File
@@ -19,6 +19,11 @@ export const eventTypeSelect = {
autoTranslateDescriptionEnabled: true,
position: true,
offsetStart: true,
owner: {
select: {
timeZone: true,
},
},
profileId: true,
eventName: true,
parentId: true,
@@ -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<TSelectedCalendar extends { eventTypeId: number | null }, THost, TUser>(
@@ -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,
@@ -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 },
},
});
}
}
@@ -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: [
@@ -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<boolean> {
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;
}
}
@@ -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<MembershipCheckResult> {
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,
};
}
}
@@ -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,
@@ -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");
+9 -4
View File
@@ -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 {
@@ -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,
});
}),
});
@@ -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,
});
}
@@ -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<TrpcSessionUser>;
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,
};
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetHashedLinkInputSchema = z.object({
linkId: z.string(),
});
export type TGetHashedLinkInputSchema = z.infer<typeof ZGetHashedLinkInputSchema>;
@@ -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<TrpcSessionUser>;
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<typeof link> => link !== null);
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetHashedLinksInputSchema = z.object({
linkIds: z.array(z.string()),
});
export type TGetHashedLinksInputSchema = z.infer<typeof ZGetHashedLinksInputSchema>;
@@ -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,
@@ -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<TrpcSessionUser>;
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;