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:
@@ -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 };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+7
@@ -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");
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user