Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
ecb7d42
init
alishaz-polymath Jul 7, 2025
5ff3840
fix type
alishaz-polymath Jul 7, 2025
65e321f
fix a re-render infinite loop because of missing readOnly (╯°□°)╯︵ ┻━┻)
alishaz-polymath Jul 7, 2025
7af9363
further fixes
alishaz-polymath Jul 8, 2025
96809e0
improvement
alishaz-polymath Jul 8, 2025
f84f3c9
fix expiry datetime check
alishaz-polymath Jul 9, 2025
7396884
remove unnecessary prismaMock def
alishaz-polymath Jul 9, 2025
c6f1dca
revert
alishaz-polymath Jul 9, 2025
0b52085
fix test
alishaz-polymath Jul 9, 2025
8851531
add test ids
alishaz-polymath Jul 10, 2025
3cc3996
remove unit tests in favor of e2e
alishaz-polymath Jul 10, 2025
5570d11
e2e test update
alishaz-polymath Jul 10, 2025
59b7dda
fix e2e
alishaz-polymath Jul 10, 2025
dc8bd32
fix e2e
alishaz-polymath Jul 10, 2025
c55a76a
remove unnecessary change
alishaz-polymath Jul 10, 2025
581e879
abstract into injectable object
alishaz-polymath Jul 10, 2025
24b7aff
further improvements
alishaz-polymath Jul 10, 2025
58f754b
fix label not selecting radio
alishaz-polymath Jul 10, 2025
6afff64
fix type
alishaz-polymath Jul 11, 2025
c893bee
code improvement
alishaz-polymath Jul 11, 2025
564bf97
DI implementation
alishaz-polymath Jul 11, 2025
6d8f276
fix type
alishaz-polymath Jul 11, 2025
af03067
fix quick copy
alishaz-polymath Jul 11, 2025
b850e08
code improvement and a few fixes
alishaz-polymath Jul 12, 2025
2843e18
further improvements and NITS
alishaz-polymath Jul 12, 2025
d8ff8da
further into DI
alishaz-polymath Jul 12, 2025
7085725
select
alishaz-polymath Jul 12, 2025
aab4c38
improve link list sorting
alishaz-polymath Jul 12, 2025
1532ea6
prep for easier conflict resolution
alishaz-polymath Jul 12, 2025
9389d82
add back translations
alishaz-polymath Jul 12, 2025
60eea0e
using useCopy instead
alishaz-polymath Jul 12, 2025
a9b4551
improvement
alishaz-polymath Jul 12, 2025
be6992a
add index to update salt and have different hash generation
alishaz-polymath Jul 12, 2025
7ec9287
fix private link description
alishaz-polymath Jul 14, 2025
344a8a4
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 14, 2025
6a1e0dc
fix increment regression in expiry logic
alishaz-polymath Jul 14, 2025
706fa35
fixes
alishaz-polymath Jul 14, 2025
bf90145
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 14, 2025
cadc07f
address feedback
alishaz-polymath Jul 16, 2025
9605052
use extractHostTimezone in event type listing
alishaz-polymath Jul 16, 2025
d34d75e
remove unused function
alishaz-polymath Jul 16, 2025
2aeb33a
remove translationBundler
alishaz-polymath Jul 16, 2025
e7bc83c
-_-
alishaz-polymath Jul 16, 2025
3c88b66
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 16, 2025
17eef0f
address feedback
alishaz-polymath Jul 16, 2025
262ac90
further changes
alishaz-polymath Jul 16, 2025
f352929
address more feedback
alishaz-polymath Jul 16, 2025
ef0e575
NIT
alishaz-polymath Jul 16, 2025
7d05b7d
address improvement suggestions
alishaz-polymath Jul 16, 2025
6375f0d
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 16, 2025
f8264db
use extractHostTimezone
alishaz-polymath Jul 17, 2025
48d8a58
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 17, 2025
812dab5
remove console log
alishaz-polymath Jul 17, 2025
8a2a792
pre update
alishaz-polymath Jul 23, 2025
f8584e9
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 23, 2025
e29dc1b
code improvement
alishaz-polymath Jul 24, 2025
29160dc
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 24, 2025
5bca163
further fixes
alishaz-polymath Jul 24, 2025
e8d5923
cleanup
alishaz-polymath Jul 24, 2025
f7febd3
-_-
alishaz-polymath Jul 24, 2025
65159ca
Merge branch 'main' into feat/private-links-2
alishaz-polymath Jul 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 28 additions & 38 deletions apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,53 +27,29 @@ 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;

const notFound = {
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;

Expand Down Expand Up @@ -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(
Expand All @@ -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,
},
};
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/modules/d/[link]/d-type-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function Type({
hashedLink,
durationConfig,
eventData,
useApiV2,
}: PageProps) {
return (
<BookingPageErrorBoundary>
Expand All @@ -34,6 +35,7 @@ export default function Type({
duration={duration}
hashedLink={hashedLink}
durationConfig={durationConfig}
useApiV2={useApiV2}
/>
</main>
</BookingPageErrorBoundary>
Expand Down
26 changes: 21 additions & 5 deletions apps/web/modules/event-types/views/event-types-listing-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -467,18 +469,32 @@ 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">
{pages.map((page, pageIdx) => {
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 &&
Expand Down Expand Up @@ -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 };
});
}}
/>
Expand Down
176 changes: 147 additions & 29 deletions apps/web/playwright/hash-my-url.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,39 @@ 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();
});
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();
// ignore if it is already checked, and click if unchecked
const hashedLinkCheck = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
await page.locator("[data-testid=vertical-tab-event_advanced_tab_title]").click();

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

// we wait for the hashedLink setting to load
const $url = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
// ignore if it is already checked, and click if unchecked
if (!(await hashedLinkCheck.isChecked())) {
await hashedLinkCheck.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();

// click update
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
Expand All @@ -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"
);
});
});
Loading
Loading