From 6cbe1c1cf15135c8bcbd9d9493d18eff6c0ebdd6 Mon Sep 17 00:00:00 2001
From: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Date: Thu, 30 Oct 2025 13:44:11 +0000
Subject: [PATCH] chore: Implement onboarding redirect logic in event-types and
main page components (#24785)
## What does this PR do?
Currently we load /event-types for a few seconds while the onboarding hook catches up. This adds that logic to the serverside and not just client side to ensure onboarding is triggered
Adds server-side onboarding redirect checks to prevent users from accessing event types pages before completing onboarding. This implementation:
- Creates a new `checkOnboardingRedirect` utility function in a dedicated file
- Applies the redirect check on both the root page and event-types page
- Optimizes performance by using organizationId from session when available
- Handles email verification requirements before redirecting to onboarding
- Supports both legacy and v3 onboarding paths based on feature flags
## How should this be tested?
- Create a new user account that hasn't completed onboarding
- Attempt to access the root page or event-types page directly
- Verify you're redirected to the appropriate onboarding flow
- Test with email verification feature flag enabled/disabled
- Test with onboarding-v3 feature flag enabled/disabled
- Verify users who have completed onboarding can access event-types normally
- Verify organization users aren't redirected to onboarding
## Video Demo
Before:
[CleanShot 2025-10-30 at 10.54.41.mp4 (uploaded via Graphite)
](https://app.graphite.dev/user-attachments/video/8282decc-a00d-4bc8-9215-fe1c4809fd8f.mp4)
After
## [CleanShot 2025-10-30 at 10.54.06.mp4 (uploaded via Graphite)
](https://app.graphite.dev/user-attachments/video/2acc7601-6c8c-496a-af4d-08dadef9aa2d.mp4)
## Checklist
- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works
---
.../(main-nav)/event-types/page.tsx | 12 ++++
apps/web/app/page.tsx | 12 ++++
packages/features/auth/lib/onboardingUtils.ts | 68 +++++++++++++++++++
3 files changed, 92 insertions(+)
create mode 100644 packages/features/auth/lib/onboardingUtils.ts
diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx
index 2a8187a624..e74d324a19 100644
--- a/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx
@@ -6,6 +6,7 @@ import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
+import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
@@ -53,6 +54,17 @@ const Page = async ({ searchParams }: PageProps) => {
return redirect("/auth/login");
}
+ // Check if user needs onboarding and redirect before fetching event types data
+ // Use organizationId from session profile if available to avoid extra query
+ const organizationId = session.user.profile?.organizationId ?? null;
+ const onboardingPath = await checkOnboardingRedirect(session.user.id, {
+ checkEmailVerification: true,
+ organizationId,
+ });
+ if (onboardingPath) {
+ return redirect(onboardingPath);
+ }
+
const t = await getTranslate();
const filters = getTeamsFiltersFromQuery(_searchParams);
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 95d99df6d9..f9731ef029 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,6 +1,7 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
+import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -11,6 +12,17 @@ const RedirectPage = async () => {
if (!session?.user?.id) {
redirect("/auth/login");
}
+
+ // Check if user needs onboarding and redirect before going to event-types
+ const organizationId = session.user.profile?.organizationId ?? null;
+ const onboardingPath = await checkOnboardingRedirect(session.user.id, {
+ checkEmailVerification: true,
+ organizationId,
+ });
+ if (onboardingPath) {
+ redirect(onboardingPath);
+ }
+
redirect("/event-types");
};
diff --git a/packages/features/auth/lib/onboardingUtils.ts b/packages/features/auth/lib/onboardingUtils.ts
new file mode 100644
index 0000000000..80d092e68f
--- /dev/null
+++ b/packages/features/auth/lib/onboardingUtils.ts
@@ -0,0 +1,68 @@
+import dayjs from "@calcom/dayjs";
+import { FeaturesRepository } from "@calcom/features/flags/features.repository";
+import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import { prisma } from "@calcom/prisma";
+
+const ONBOARDING_INTRODUCED_AT = dayjs("September 1 2021").toISOString();
+
+/**
+ * Checks if a user needs onboarding on the server side.
+ * Returns the onboarding path if redirect is needed, null otherwise.
+ *
+ * @param userId - The user ID to check
+ * @param options - Optional configuration
+ * @param options.checkEmailVerification - Whether to check if email verification is required
+ * @param options.organizationId - Optional organizationId from session (to avoid extra query)
+ */
+export async function checkOnboardingRedirect(
+ userId: number,
+ options?: {
+ checkEmailVerification?: boolean;
+ organizationId?: number | null;
+ }
+): Promise {
+ // Query user data needed for onboarding check using UserRepository
+ const userRepository = new UserRepository(prisma);
+ const user = await userRepository.findById({ id: userId });
+
+ if (!user) {
+ return null;
+ }
+
+ // Use provided organizationId or query it via ProfileRepository
+ let organizationId: number | null;
+ if (options?.organizationId !== undefined) {
+ organizationId = options.organizationId;
+ } else {
+ const profile = await ProfileRepository.findFirstForUserId({ userId });
+ organizationId = profile?.organizationId ?? null;
+ }
+
+ // Check if user should be shown onboarding
+ const shouldShowOnboarding =
+ !user.completedOnboarding && !organizationId && dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT);
+
+ if (!shouldShowOnboarding) {
+ return null;
+ }
+
+ // Check email verification if needed
+ const featuresRepository = new FeaturesRepository(prisma);
+
+ if (options?.checkEmailVerification) {
+ const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
+ "email-verification"
+ );
+
+ if (!user.emailVerified && user.identityProvider === "CAL" && emailVerificationEnabled) {
+ // User needs email verification, redirect to verification page
+ return "/auth/verify-email";
+ }
+ }
+
+ // Determine which onboarding path to use
+ const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
+
+ return onboardingV3Enabled ? "/onboarding/getting-started" : "/getting-started";
+}