Files
calendar/packages/features/ee/organizations/lib/onboardingStore.ts
T
Joe Au-YeungGitHubjoe@cal.com <joe@cal.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>joe@cal.com <joe@cal.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>joe@cal.com <joe@cal.com>Benny Joocubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>Hariom
6ed197d35c fix: Org creation for self-hosters (#21994)
* Restart form if admin is not org admin

* Show pricing is admin and hosted

* Only show billing specific fields when enabled

* Create user onboarding only if valid valid license when self hosting

* Reset store when org creation is completed

* Create schema for trpc input when creating an org

* Add `organization.createSelfHosted` trpc endpoint

* `createOrganizationFromOnboarding` do not require stripe data

* Create organization without billing if self-hosted admin

* Update organizationOnboarding record as completed

* Handle updating subscription for self hosters

* Refactor `isBillingEnabled` to store

* Type fix

* Fix admin org creation for hosted

* Hide UI from admin

* Add tests

* fix: Fix LicenseKeySingleton mocking in organization tests

- Add proper vi.mock() module-level mocking for LicenseKeySingleton
- Replace incorrect casting syntax with vi.mocked() approach
- Add LicenseKeySingleton mock to hosted tests in intentToCreateOrg.handler.test.ts
- Fix IS_SELF_HOSTED mocking for hosted vs self-hosted test scenarios
- All organization creation tests now pass locally

Co-Authored-By: joe@cal.com <joe@cal.com>

* Fix tests

* Fix tests

* Test fixes

* Type fix

* Type fix

* Type fix

* Update packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts

Fix typo

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: Add IS_SELF_HOSTED mocking for E2E organization creation tests

Co-Authored-By: joe@cal.com <joe@cal.com>

* Fix E2E test

* Address feedback

* Type fix

* Fix e2e tests

* Remove unused code

* Small fixes

* fix unit tests

* review fixes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-25 16:58:18 +03:00

187 lines
6.0 KiB
TypeScript

import { useSession } from "next-auth/react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { WEBAPP_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
interface OnboardingAdminStoreState {
billingPeriod?: BillingPeriod;
pricePerSeat?: number | null;
seats?: number | null;
orgOwnerEmail?: string;
}
interface OnboardingUserStoreState {
name: string;
slug: string;
logo?: string | null;
bio?: string | null;
onboardingId: string | null;
invitedMembers: { email: string; name?: string }[];
teams: { id: number; name: string; slug: string | null; isBeingMigrated: boolean }[];
}
interface OnboardingStoreState extends OnboardingAdminStoreState, OnboardingUserStoreState {
// Actions for admin state
setBillingPeriod: (billingPeriod: BillingPeriod) => void;
setPricePerSeat: (price: number | null) => void;
setSeats: (seats: number | null) => void;
setOrgOwnerEmail: (email: string) => void;
// Actions for user state
setName: (name: string) => void;
setSlug: (slug: string) => void;
setLogo: (logo: string) => void;
setBio: (bio: string) => void;
addInvitedMember: (member: { email: string; name?: string }) => void;
removeInvitedMember: (email: string) => void;
setOnboardingId: (onboardingId: string) => void;
// Actions for team state
setTeams: (teams: { id: number; name: string; slug: string | null; isBeingMigrated: boolean }[]) => void;
// Reset state
reset: (state?: Partial<OnboardingStoreState>) => void;
}
const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = {
// User state
name: "",
slug: "",
logo: "",
bio: "",
orgOwnerEmail: "",
seats: null,
pricePerSeat: null,
billingPeriod: undefined,
onboardingId: null,
invitedMembers: [],
teams: [],
};
export const useOnboardingStore = create<OnboardingStoreState>()(
persist(
(set) => ({
...initialState,
// Admin actions
setBillingPeriod: (billingPeriod) => set({ billingPeriod }),
setPricePerSeat: (pricePerSeat) => set({ pricePerSeat }),
setSeats: (seats) => set({ seats }),
setOrgOwnerEmail: (orgOwnerEmail) => set({ orgOwnerEmail }),
// User actions
setName: (name) => set({ name }),
setSlug: (slug) => set({ slug }),
setLogo: (logo) => set({ logo }),
setBio: (bio) => set({ bio }),
setOnboardingId: (onboardingId) => set({ onboardingId }),
addInvitedMember: (member) =>
set((state) => ({
invitedMembers: [...state.invitedMembers, member],
})),
removeInvitedMember: (email) =>
set((state) => ({
invitedMembers: state.invitedMembers.filter((member) => member.email !== email),
})),
// Team actions
setTeams: (teams) => set({ teams }),
// Reset action
reset: (state) => {
if (state) {
set(state);
} else {
// Clear the localStorage entry for this store
if (typeof window !== "undefined") {
localStorage.removeItem("org-creation-onboarding");
}
set(initialState);
}
},
}),
{
name: "org-creation-onboarding",
}
)
);
export const useOnboarding = (params?: { step?: "start" | "status" | null }) => {
const session = useSession();
const router = useRouter();
const path = usePathname();
const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN;
const isBillingEnabled = !(IS_SELF_HOSTED && isAdmin) || process.env.NEXT_PUBLIC_IS_E2E;
const searchParams = useSearchParams();
const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } =
trpc.viewer.organizations.getOrganizationOnboarding.useQuery();
const { reset, onboardingId } = useOnboardingStore();
const step = params?.step ?? null;
useEffect(() => {
if (isLoadingOrgOnboarding) {
return;
}
if (organizationOnboarding?.isComplete) {
reset();
}
if (organizationOnboarding) {
// Must not keep resetting state on each step change as every step doesn't save at the moment and this would reset user's changes
if (!window.isOrgOnboardingSynced) {
window.isOrgOnboardingSynced = true;
// Must reset with current state of onboarding in DB for the user
reset({
onboardingId: organizationOnboarding.id,
billingPeriod: organizationOnboarding.billingPeriod as BillingPeriod,
pricePerSeat: organizationOnboarding.pricePerSeat,
seats: organizationOnboarding.seats,
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
name: organizationOnboarding.name,
slug: organizationOnboarding.slug,
bio: organizationOnboarding.bio,
logo: organizationOnboarding.logo,
});
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
reset();
}
}
} else {
// First step doesn't require onboardingId
const requireOnboardingId = step !== "start";
// Reset to first step if onboardingId isn't available
if (!onboardingId && requireOnboardingId) {
console.warn("No onboardingId found in store, redirecting to /settings/organizations/new");
router.push("/settings/organizations/new");
}
}
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]);
useEffect(() => {
if (session.status === "loading") {
return;
}
if (!session.data) {
const searchString = !searchParams ? "" : `${searchParams.toString()}`;
router.push(`/auth/login?callbackUrl=${WEBAPP_URL}${path}${searchString ? `?${searchString}` : ""}`);
}
}, [session, router, path, searchParams]);
return {
useOnboardingStore,
isLoadingOrgOnboarding,
dbOnboarding: organizationOnboarding,
isBillingEnabled,
};
};