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"; +}