Skip to content

Commit 66b52bb

Browse files
feat: Enhance private link expiration with usage and date limits (calcom#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 * -_-
1 parent b9c49b4 commit 66b52bb

30 files changed

Lines changed: 1565 additions & 221 deletions

File tree

apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
66
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
77
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
88
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
9+
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
910
import { shouldHideBrandingForTeamEvent, shouldHideBrandingForUserEvent } from "@calcom/lib/hideBranding";
1011
import { EventRepository } from "@calcom/lib/server/repository/event";
1112
import { UserRepository } from "@calcom/lib/server/repository/user";
13+
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
1214
import slugify from "@calcom/lib/slugify";
1315
import prisma from "@calcom/prisma";
1416
import { RedirectType } from "@calcom/prisma/enums";
@@ -25,53 +27,29 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
2527
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
2628
const org = isValidOrgDomain ? currentOrgDomain : null;
2729

28-
const hashedLink = await prisma.hashedLink.findUnique({
29-
where: {
30-
link,
31-
},
32-
select: {
33-
eventTypeId: true,
34-
eventType: {
35-
select: {
36-
users: {
37-
select: {
38-
username: true,
39-
profiles: {
40-
select: {
41-
id: true,
42-
organizationId: true,
43-
username: true,
44-
},
45-
},
46-
},
47-
},
48-
team: {
49-
select: {
50-
id: true,
51-
slug: true,
52-
hideBranding: true,
53-
parent: {
54-
select: {
55-
hideBranding: true,
56-
},
57-
},
58-
},
59-
},
60-
},
61-
},
62-
},
63-
});
64-
6530
let name: string;
6631
let hideBranding = false;
6732

6833
const notFound = {
6934
notFound: true,
7035
} as const;
7136

37+
// Use centralized validation logic to avoid duplication
38+
const hashedLinkService = new HashedLinkService();
39+
try {
40+
await hashedLinkService.validate(link);
41+
} catch (error) {
42+
// Link is expired, invalid, or doesn't exist
43+
return notFound;
44+
}
45+
46+
// If validation passes, fetch the complete data needed for rendering
47+
const hashedLink = await hashedLinkService.findLinkWithDetails(link);
48+
7249
if (!hashedLink) {
7350
return notFound;
7451
}
52+
7553
const username = hashedLink.eventType.users[0]?.username;
7654
const profileUsername = hashedLink.eventType.users[0]?.profiles[0]?.username;
7755

@@ -139,8 +117,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
139117
return notFound;
140118
}
141119

120+
// Check if team has API v2 feature flag enabled (same logic as team pages)
121+
let useApiV2 = false;
122+
if (isTeamEvent && hashedLink.eventType.team?.id) {
123+
const featureRepo = new FeaturesRepository();
124+
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
125+
hashedLink.eventType.team.id,
126+
"use-api-v2-for-team-slots"
127+
);
128+
useApiV2 = teamHasApiV2Route;
129+
}
130+
142131
return {
143132
props: {
133+
useApiV2,
144134
eventData,
145135
entity: eventData.entity,
146136
duration: getMultipleDurationValue(
@@ -156,7 +146,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
156146
// Sending the team event from the server, because this template file
157147
// is reused for both team and user events.
158148
isTeamEvent,
159-
hashedLink: link,
149+
hashedLink: hashedLink?.link,
160150
},
161151
};
162152
}

apps/web/modules/d/[link]/d-type-view.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default function Type({
1919
hashedLink,
2020
durationConfig,
2121
eventData,
22+
useApiV2,
2223
}: PageProps) {
2324
return (
2425
<BookingPageErrorBoundary>
@@ -34,6 +35,7 @@ export default function Type({
3435
duration={duration}
3536
hashedLink={hashedLink}
3637
durationConfig={durationConfig}
38+
useApiV2={useApiV2}
3739
/>
3840
</main>
3941
</BookingPageErrorBoundary>

apps/web/modules/event-types/views/event-types-listing-view.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
1616
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
1717
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
1818
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
19+
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
20+
import { filterActiveLinks } from "@calcom/lib/hashedLinksUtils";
1921
import { useCopy } from "@calcom/lib/hooks/useCopy";
2022
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
2123
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
@@ -467,18 +469,32 @@ export const InfiniteEventTypeList = ({
467469
return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : "";
468470
};
469471

472+
const userTimezone = extractHostTimezone({
473+
userId: firstItem.userId,
474+
teamId: firstItem?.teamId,
475+
hosts: firstItem?.hosts,
476+
owner: firstItem?.owner,
477+
team: firstItem?.team,
478+
});
479+
470480
return (
471481
<div className="bg-default border-subtle flex flex-col overflow-hidden rounded-md border">
472482
<ul ref={parent} className="divide-subtle !static w-full divide-y" data-testid="event-types">
473483
{pages.map((page, pageIdx) => {
474484
return page?.eventTypes?.map((type, index) => {
475485
const embedLink = `${group.profile.slug}/${type.slug}`;
476486
const calLink = `${bookerUrl}/${embedLink}`;
487+
488+
const activeHashedLinks = type.hashedLink ? filterActiveLinks(type.hashedLink, userTimezone) : [];
489+
490+
// Ensure index is within bounds for active links
491+
const currentIndex = privateLinkCopyIndices[type.slug] ?? 0;
492+
const safeIndex = activeHashedLinks.length > 0 ? currentIndex % activeHashedLinks.length : 0;
493+
477494
const isPrivateURLEnabled =
478-
type.hashedLink && type.hashedLink.length > 0
479-
? type.hashedLink[privateLinkCopyIndices[type.slug] ?? 0]?.link
480-
: "";
495+
activeHashedLinks.length > 0 ? activeHashedLinks[safeIndex]?.link : "";
481496
const placeholderHashedLink = `${bookerUrl}/d/${isPrivateURLEnabled}/${type.slug}`;
497+
482498
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
483499
const isChildrenManagedEventType =
484500
type.metadata?.managedEventConfig !== undefined &&
@@ -580,8 +596,8 @@ export const InfiniteEventTypeList = ({
580596
copyToClipboard(placeholderHashedLink);
581597
setPrivateLinkCopyIndices((prev) => {
582598
const prevIndex = prev[type.slug] ?? 0;
583-
prev[type.slug] = (prevIndex + 1) % type.hashedLink.length;
584-
return prev;
599+
const nextIndex = (prevIndex + 1) % activeHashedLinks.length;
600+
return { ...prev, [type.slug]: nextIndex };
585601
});
586602
}}
587603
/>

apps/web/playwright/hash-my-url.e2e.ts

Lines changed: 147 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,39 @@ import {
77
submitAndWaitForResponse,
88
} from "./lib/testUtils";
99

10-
test.describe.configure({ mode: "parallel" });
10+
test.describe.configure({ mode: "serial" });
1111

12-
// TODO: This test is very flaky. Feels like tossing a coin and hope that it won't fail. Needs to be revisited.
13-
test.describe("hash my url", () => {
12+
test.describe("private links creation and usage", () => {
1413
test.beforeEach(async ({ users }) => {
1514
const user = await users.create();
1615
await user.apiLogin();
1716
});
1817
test.afterEach(async ({ users }) => {
1918
await users.deleteAll();
2019
});
21-
test("generate url hash", async ({ page }) => {
20+
test("generate private link and make a booking with it", async ({ page }) => {
2221
await page.goto("/event-types");
2322
// We wait until loading is finished
24-
await page.waitForSelector('[data-testid="event-types"]');
25-
await page.locator("ul[data-testid=event-types] > li a").first().click();
26-
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
27-
"aria-current",
28-
"page"
29-
); // fix the race condition
30-
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
23+
await Promise.all([
24+
page.waitForURL("**/event-types"),
25+
page.getByTestId("event-types").locator("li a").first().click(),
26+
]);
27+
28+
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
29+
3130
// We wait for the page to load
32-
await page.locator(".primary-navigation >> text=Advanced").click();
33-
// ignore if it is already checked, and click if unchecked
34-
const hashedLinkCheck = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
31+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
3532

36-
await hashedLinkCheck.click();
33+
const hashedLinkCheck = page.locator('[data-testid="multiplePrivateLinksCheck"]');
34+
await expect(hashedLinkCheck).toBeVisible();
3735

38-
// we wait for the hashedLink setting to load
39-
const $url = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
36+
// ignore if it is already checked, and click if unchecked
37+
if (!(await hashedLinkCheck.isChecked())) {
38+
await hashedLinkCheck.click();
39+
}
40+
41+
// Wait for the private link URL input to be visible and get its value
42+
const $url = await page.locator('[data-testid="private-link-url"]').inputValue();
4043

4144
// click update
4245
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
@@ -47,33 +50,148 @@ test.describe("hash my url", () => {
4750
await selectFirstAvailableTimeSlotNextMonth(page);
4851
await bookTimeSlot(page);
4952
// Make sure we're navigated to the success page
50-
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
53+
const successPage = page.getByTestId("success-page");
54+
await expect(successPage).toBeVisible();
5155

52-
// hash regenerates after successful booking
56+
// hash regenerates after successful booking (only for usage-based links)
5357
await page.goto("/event-types");
54-
// We wait until loading is finished
5558
await page.waitForSelector('[data-testid="event-types"]');
59+
await page.reload(); // ensure fresh state
60+
5661
await page.locator("ul[data-testid=event-types] > li a").first().click();
5762
// We wait for the page to load
58-
await page.locator(".primary-navigation >> text=Advanced").click();
63+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
5964

60-
const hashedLinkCheck2 = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
61-
await hashedLinkCheck2.click();
62-
63-
// we wait for the hashedLink setting to load
64-
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
65-
expect($url !== $newUrl).toBeTruthy();
65+
// After booking with a usage-based private link, the link should be expired
66+
await expect(page.locator('[data-testid="private-link-description"]')).toContainText(
67+
"Usage limit reached"
68+
);
6669

6770
// Ensure that private URL is enabled after modifying the event type.
6871
// Additionally, if the slug is changed, ensure that the private URL is updated accordingly.
6972
await page.getByTestId("vertical-tab-event_setup_tab_title").click();
7073
await page.locator("[data-testid=event-title]").first().fill("somethingrandom");
7174
await page.locator("[data-testid=event-slug]").first().fill("somethingrandom");
75+
await expect(page.locator('[data-testid="event-slug"]').first()).toHaveValue("somethingrandom");
76+
7277
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
7378
action: () => page.locator("[data-testid=update-eventtype]").click(),
7479
});
75-
await page.locator(".primary-navigation >> text=Advanced").click();
76-
const $url2 = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
80+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
81+
82+
// Wait for the private link URL input to be visible and get its value
83+
const $url2 = await page.locator('[data-testid="private-link-url"]').inputValue();
7784
expect($url2.includes("somethingrandom")).toBeTruthy();
7885
});
86+
87+
test("generate private link with future expiration date and make a booking with it", async ({ page }) => {
88+
await page.goto("/event-types");
89+
// We wait until loading is finished
90+
await Promise.all([
91+
page.waitForURL("**/event-types"),
92+
page.getByTestId("event-types").locator("li a").first().click(),
93+
]);
94+
95+
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
96+
97+
// We wait for the page to load
98+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
99+
100+
const privateLinkCheck = page.locator('[data-testid="multiplePrivateLinksCheck"]');
101+
await expect(privateLinkCheck).toBeVisible();
102+
103+
// ignore if it is already checked, and click if unchecked
104+
if (!(await privateLinkCheck.isChecked())) {
105+
await privateLinkCheck.click();
106+
}
107+
108+
// Wait for the private link URL input to be visible and get its value
109+
const $url = await page.locator('[data-testid="private-link-url"]').inputValue();
110+
await page.locator('[data-testid="private-link-settings"]').click();
111+
await expect(page.locator('[data-testid="private-link-radio-group"]')).toBeVisible();
112+
await page.locator('[data-testid="private-link-time"]').click();
113+
await page.locator('[data-testid="private-link-expiration-settings-save"]').click();
114+
await page.waitForLoadState("networkidle");
115+
// click update
116+
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
117+
action: () => page.locator("[data-testid=update-eventtype]").click(),
118+
});
119+
// book using generated url hash
120+
await page.goto($url);
121+
await selectFirstAvailableTimeSlotNextMonth(page);
122+
await bookTimeSlot(page);
123+
// Make sure we're navigated to the success page
124+
await expect(page.getByTestId("success-page")).toBeVisible();
125+
126+
// hash regenerates after successful booking (only for usage-based links)
127+
await page.goto("/event-types");
128+
await page.waitForSelector('[data-testid="event-types"]');
129+
await page.reload(); // ensure fresh state
130+
131+
await page.locator("ul[data-testid=event-types] > li a").first().click();
132+
// We wait for the page to load
133+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
134+
135+
// After booking with a expiration date based private link, the link should still be valid
136+
await expect(page.locator('[data-testid="private-link-expired"]')).toBeHidden();
137+
});
138+
test("generate private link with 2 usages and make 2 bookings with it", async ({ page }) => {
139+
await page.goto("/event-types");
140+
// We wait until loading is finished
141+
await Promise.all([
142+
page.waitForURL("**/event-types"),
143+
page.getByTestId("event-types").locator("li a").first().click(),
144+
]);
145+
146+
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
147+
148+
// We wait for the page to load
149+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
150+
151+
const privateLinkCheck = page.locator('[data-testid="multiplePrivateLinksCheck"]');
152+
await expect(privateLinkCheck).toBeVisible();
153+
154+
// ignore if it is already checked, and click if unchecked
155+
if (!(await privateLinkCheck.isChecked())) {
156+
await privateLinkCheck.click();
157+
}
158+
159+
// Wait for the private link URL input to be visible and get its value
160+
const $url = await page.locator('[data-testid="private-link-url"]').inputValue();
161+
await page.locator('[data-testid="private-link-settings"]').click();
162+
await expect(page.locator('[data-testid="private-link-radio-group"]')).toBeVisible();
163+
await page.locator('[data-testid="private-link-usage-count"]').fill("2");
164+
await page.locator('[data-testid="private-link-expiration-settings-save"]').click();
165+
await page.waitForLoadState("networkidle");
166+
// click update
167+
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
168+
action: () => page.locator("[data-testid=update-eventtype]").click(),
169+
});
170+
// book using generated url hash
171+
await page.goto($url);
172+
await selectFirstAvailableTimeSlotNextMonth(page);
173+
await bookTimeSlot(page);
174+
// Make sure we're navigated to the success page
175+
await expect(page.getByTestId("success-page")).toBeVisible();
176+
177+
// book again using generated url hash
178+
await page.goto($url);
179+
await selectFirstAvailableTimeSlotNextMonth(page);
180+
await bookTimeSlot(page);
181+
// Make sure we're navigated to the success page
182+
await expect(page.getByTestId("success-page")).toBeVisible();
183+
184+
await page.goto("/event-types");
185+
await page.waitForSelector('[data-testid="event-types"]');
186+
await page.reload(); // ensure fresh state
187+
188+
await page.locator("ul[data-testid=event-types] > li a").first().click();
189+
// We wait for the page to load
190+
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();
191+
192+
// After booking twice with a 2 usages based private link, the link should be expired
193+
await expect(page.locator('[data-testid="private-link-description"]')).toContainText(
194+
"Usage limit reached"
195+
);
196+
});
79197
});

0 commit comments

Comments
 (0)