From fa35cc521057d773b85fd2c8fbc560df963cd644 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Sat, 18 Oct 2025 15:13:30 +0100 Subject: [PATCH] chore: organization onboarding refactor (#24381) * feat: redirect to new onboarding flow * Getting started * Brand details * Preview organization brands * Orgs team pages * Invite team steps * Move to global zustand store * Few darkmdoe fixes * Wip onboarding + stripe flow * Default plan state Server Action for gettting slug satus of org * Remove onboardingId * Confirmation prompt * Update old onboarding flow handlers to handle new fields * update onboarding hook * Filter out organization section for none -company emails * Match placeholders to users domain * Drop migration * Wip new onboarding intent * WIP flow for self-hosted. Same service call just split logic * WIP * Add TODO * Use onboarding user type instead of trpc session * WIP * WIP * pass role and team name from onboarding to save in schema * Add test to ensure role + name + team are persisted into onboarding table * migrate roles to enum values * Update ENUM * Fix type error * Redirect if flag is disabled * Remove web * WIP * WIP * Fix migration * Fix calls * User onboarding User types instead of trpc session * Fix factory tests * Fix flow for self hoste * Type error * More type fixes * Fix handler tests * Fix enum return type being different * Use consistant types across the oganization stuff * Fix * Use TEAM_BILLING for e2e test * Refactor is not company email and add tests * Fix * Fix * Refactor flow to submit after form complete * Fix flow with billing disabled * Fix tests * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Rename and move test files * WIP * Fix types * Update repo paths + tests * Move to service folder * Fix tests * Fix types * Remove old test files * Restore lock * Fix path * Fix tests with new paths and factory logic * Fix updaetdAt * WIP onboardingID isolation * Fix e2e test * verify test * Code rabbit * Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService * Fix stores * Fix type error * Fix types * remove tsignore * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * NITS * Add the logic to auto complete admin org when billing enabled * Fix store being weird * We need to return the parsed value * fixes * sync from db always * Add onboardingSgtore tests * fix test * remove step and status --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara --- .../organizations/new/resume/page.tsx | 22 + .../new/_components/AddNewTeamsForm.tsx | 1 + .../new/_components/OnboardMembersView.tsx | 73 +- .../new/_components/PaymentStatusView.tsx | 6 +- .../organizations/new/resume-view.tsx | 114 +++ .../organization-creation-flows.e2e.ts | 451 ++++++++++ .../organization/organization-creation.e2e.ts | 475 ---------- apps/web/public/static/locales/en/common.json | 6 + .../billing/api/webhook/_invoice.paid.org.ts | 33 +- .../components/AdminOnboardingHandover.tsx | 81 +- .../components/CreateANewOrganizationForm.tsx | 50 +- .../lib/OrganizationPaymentService.test.ts | 10 + .../lib/OrganizationPaymentService.ts | 43 +- .../lib/OrganizationPermissionService.ts | 5 +- .../organizations/lib/onboardingStore.test.ts | 389 +++++++++ .../ee/organizations/lib/onboardingStore.ts | 87 +- .../createOrganizationFromOnboarding.test.ts | 540 ------------ .../createOrganizationFromOnboarding.ts | 567 ------------ .../lib/server/orgCreationUtils.test.ts | 116 +++ .../lib/server/orgCreationUtils.ts | 11 +- .../onboarding/BaseOnboardingService.ts | 605 +++++++++++++ .../BillingEnabledOrgOnboardingService.ts | 270 ++++++ .../IOrganizationOnboardingService.ts | 32 + .../OrganizationOnboardingFactory.ts | 53 ++ .../onboarding/OrganizationOnboardingSetup.md | 170 ++++ .../onboarding/SelfHostedOnboardingService.ts | 230 +++++ .../__tests__/BaseOnboardingService.test.ts | 249 ++++++ ...BillingEnabledOrgOnboardingService.test.ts | 814 ++++++++++++++++++ .../OrganizationOnboardingFactory.test.ts | 204 +++++ .../SelfHostedOnboardingService.test.ts | 523 +++++++++++ .../lib/service/onboarding/index.ts | 5 + .../lib/service/onboarding/types.ts | 109 +++ .../features/ee/organizations/lib/utils.ts | 42 + .../ee/organizations/types/schemas.ts | 2 + packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 1 + .../repository/organizationOnboarding.ts | 4 + .../migration.sql | 9 + .../migration.sql | 3 + packages/prisma/schema.prisma | 2 + packages/prisma/zod-utils.ts | 8 +- .../viewer/organizations/create.handler.ts | 42 +- .../organizations/createSelfHosted.handler.ts | 46 +- .../createWithPaymentIntent.handler.ts | 25 +- .../createWithPaymentIntent.schema.ts | 8 + .../intentToCreateOrg.handler.test.ts | 171 +++- .../intentToCreateOrg.handler.ts | 46 +- .../organizations/intentToCreateOrg.schema.ts | 18 +- 48 files changed, 4959 insertions(+), 1813 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx create mode 100644 apps/web/modules/settings/organizations/new/resume-view.tsx create mode 100644 apps/web/playwright/organization/organization-creation-flows.e2e.ts delete mode 100644 apps/web/playwright/organization/organization-creation.e2e.ts create mode 100644 packages/features/ee/organizations/lib/onboardingStore.test.ts delete mode 100644 packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts delete mode 100644 packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts create mode 100644 packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md create mode 100644 packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/index.ts create mode 100644 packages/features/ee/organizations/lib/service/onboarding/types.ts create mode 100644 packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql create mode 100644 packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx new file mode 100644 index 0000000000..3f4153df16 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/new/resume/page.tsx @@ -0,0 +1,22 @@ +import { _generateMetadata } from "app/_utils"; + +import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("resume_onboarding"), + (t) => t("resume_onboarding_description"), + undefined, + undefined, + "/settings/organizations/new/resume" + ); + +const ServerPage = async () => { + return ( + + + + ); +}; + +export default ServerPage; diff --git a/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx b/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx index 529363abab..3dbec49cc6 100644 --- a/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx +++ b/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx @@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl 0 ? "mb-2" : ""} diff --git a/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx b/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx index 51d381f395..5ec8541438 100644 --- a/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx +++ b/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx @@ -34,28 +34,28 @@ const useOrgCreation = () => { const session = useSession(); const utils = trpc.useUtils(); const [serverErrorMessage, setServerErrorMessage] = useState(""); - const { useOnboardingStore, isBillingEnabled } = useOnboarding(); + const { useOnboardingStore } = useOnboarding(); const { reset } = useOnboardingStore(); - const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({ - onSuccess: (data) => { - if (data.checkoutUrl) { - window.location.href = data.checkoutUrl; - } - }, - onError: (error) => { - setServerErrorMessage(t(error.message)); - }, - }); - - const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({ + // Single mutation for all flows (billing, self-hosted, admin) + const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({ onSuccess: async (data) => { - if (data.organization) { - // Invalidate the organizations query to ensure fresh data on the next page + reset({ + onboardingId: data.organizationOnboardingId, + }); + + if (data.checkoutUrl) { + // Billing enabled - redirect to Stripe + window.location.href = data.checkoutUrl; + } else if (data.organizationId) { + // Self-hosted - org already created, redirect to organizations await utils.viewer.organizations.listCurrent.invalidate(); await session.update(); reset(); window.location.href = `${window.location.origin}/settings/organizations/profile`; + } else { + // Unexpected state + setServerErrorMessage("Unexpected response from server"); } }, onError: (error) => { @@ -63,12 +63,10 @@ const useOrgCreation = () => { }, }); - const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation; - return { - mutation: mutationToUse, - mutate: mutationToUse.mutate, - isPending: mutationToUse.isPending, + mutation: intentToCreateOrgMutation, + mutate: intentToCreateOrgMutation.mutate, + isPending: intentToCreateOrgMutation.isPending, errorMessage: serverErrorMessage, }; }; @@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => { invitedMembers, logo, bio, - onboardingId, + name, + slug, + billingPeriod, + seats, + pricePerSeat, + brandColor, + bannerUrl, } = useOnboardingStore(); const orgCreation = useOrgCreation(); @@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => { placeholder="colleague@company.com" /> - @@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => { )} -
+
+
+ + {/* Actions */} +
+ + +
); }; diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index c4d8926d9f..eb2afa6ced 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -10,8 +10,7 @@ import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomain import { MINIMUM_NUMBER_OF_ORG_SEATS, IS_SELF_HOSTED } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import slugify from "@calcom/lib/slugify"; -import { CreationSource } from "@calcom/prisma/enums"; -import { UserPermissionRole } from "@calcom/prisma/enums"; +import { BillingPeriod, CreationSource, UserPermissionRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import type { Ensure } from "@calcom/types/utils"; import classNames from "@calcom/ui/classNames"; @@ -37,7 +36,7 @@ function extractDomainFromEmail(email: string) { export const CreateANewOrganizationForm = () => { const session = useSession(); - const { isLoadingOrgOnboarding } = useOnboarding({ step: "start" }); + const { isLoadingOrgOnboarding } = useOnboarding(); if (!session.data || isLoadingOrgOnboarding) { return null; } @@ -45,11 +44,6 @@ export const CreateANewOrganizationForm = () => { return ; }; -enum BillingPeriod { - MONTHLY = "MONTHLY", - ANNUALLY = "ANNUALLY", -} - const CreateANewOrganizationFormChild = ({ session }: { session: Ensure }) => { const { t } = useLocale(); const router = useRouter(); @@ -57,7 +51,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure { - // TODO: To be moved to _invoice.paid.org.ts - // telemetry.event(telemetryEventTypes.org_created); reset({ onboardingId: data.organizationOnboardingId, billingPeriod: data.billingPeriod, @@ -93,9 +85,17 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure setTimeout(resolve, 50)); + + if (data.handoverUrl) { + // Admin handover flow - redirect to handover page router.push("/settings/organizations/new/handover"); + } else if (data.organizationId) { + // Self-hosted flow - org already created, redirect to organizations list + router.push("/settings/organizations"); } else { + // Regular flow - continue to next step router.push("/settings/organizations/new/about"); } }, @@ -122,10 +122,30 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure { if (!needToCreateOnboarding) { + // Resuming existing onboarding - just navigate to next step router.push("/settings/organizations/new/about"); - } else if (!intentToCreateOrgMutation.isPending) { - setServerErrorMessage(null); - intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP }); + } else { + // Check if this is admin handover flow based on the submitted form value + const isAdminHandoverFlow = isAdmin && v.orgOwnerEmail !== session.data.user.email; + + if (isAdminHandoverFlow) { + // Admin creating for someone else - submit immediately with just Step 1 data + if (!intentToCreateOrgMutation.isPending) { + setServerErrorMessage(null); + intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP }); + } + } else { + // Regular user or admin creating for self - store locally and continue + reset({ + billingPeriod: v.billingPeriod, + pricePerSeat: v.pricePerSeat, + seats: v.seats, + orgOwnerEmail: v.orgOwnerEmail, + name: v.name, + slug: v.slug, + }); + router.push("/settings/organizations/new/about"); + } } }}>
diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts index 7806ecdbcc..9a48ba4b20 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts @@ -135,6 +135,8 @@ describe("OrganizationPaymentService", () => { expect(data).toEqual({ bio: "BIO", logo: "LOGO", + brandColor: null, + bannerUrl: null, teams: [ { id: 1, @@ -183,6 +185,8 @@ describe("OrganizationPaymentService", () => { expect(data).toEqual({ bio: "BIO", logo: "LOGO", + brandColor: null, + bannerUrl: null, teams: [ { id: 1, @@ -232,6 +236,9 @@ describe("OrganizationPaymentService", () => { expect(data).toEqual({ bio: "BIO", logo: "LOGO", + brandColor: null, + bannerUrl: null, + invitedMembers: undefined, teams: [ { id: 1, @@ -261,6 +268,9 @@ describe("OrganizationPaymentService", () => { expect(data).toEqual({ bio: "BIO", logo: "LOGO", + brandColor: null, + bannerUrl: null, + invitedMembers: undefined, teams: [ { id: 1, diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts index fea4d56b6e..18da38affa 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts @@ -1,4 +1,5 @@ import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { ORGANIZATION_SELF_SERVE_MIN_SEATS, ORGANIZATION_SELF_SERVE_PRICE, @@ -7,24 +8,25 @@ import { import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; -import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; import type { OrganizationOnboarding } from "@calcom/prisma/client"; import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums"; import { userMetadata } from "@calcom/prisma/zod-utils"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; import { OrganizationPermissionService } from "./OrganizationPermissionService"; +import type { OnboardingUser } from "./service/onboarding/types"; type OrganizationOnboardingId = string; const log = logger.getSubLogger({ prefix: ["OrganizationPaymentService"] }); type CreatePaymentIntentInput = { logo: string | null; bio: string | null; + brandColor?: string | null; + bannerUrl?: string | null; teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[]; - invitedMembers?: { email: string }[]; + invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string }[]; }; type CreateOnboardingInput = { @@ -35,6 +37,12 @@ type CreateOnboardingInput = { seats?: number | null; pricePerSeat?: number | null; createdByUserId: number; + logo?: string | null; + bio?: string | null; + brandColor?: string | null; + bannerUrl?: string | null; + teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[]; + invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string; role?: string }[]; }; type PermissionCheckInput = { @@ -72,9 +80,9 @@ type StripePrice = { export class OrganizationPaymentService { protected billingService: StripeBillingService; protected permissionService: OrganizationPermissionService; - protected user: NonNullable; + protected user: OnboardingUser; - constructor(user: NonNullable, permissionService?: OrganizationPermissionService) { + constructor(user: OnboardingUser, permissionService?: OrganizationPermissionService) { this.billingService = new StripeBillingService(); this.permissionService = permissionService || new OrganizationPermissionService(user); this.user = user; @@ -191,6 +199,12 @@ export class OrganizationPaymentService { seats: config.seats, pricePerSeat: config.pricePerSeat, createdById: input.createdByUserId, + logo: input.logo ?? null, + bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, + teams: input.teams ?? [], + invitedMembers: input.invitedMembers ?? [], }); } @@ -287,7 +301,7 @@ export class OrganizationPaymentService { ) { log.debug("createPaymentIntent", safeStringify(input)); - const { teams: _teams, invitedMembers, logo, bio } = input; + const { teams: _teams, invitedMembers, logo, bio, brandColor, bannerUrl } = input; const teams = _teams?.filter((team) => team.id === -1 || team.isBeingMigrated) || []; const teamIds = teams.filter((team) => team.id > 0).map((team) => team.id); @@ -362,15 +376,20 @@ export class OrganizationPaymentService { }) ); - log.debug("Updating onboarding"); + log.debug("Updating onboarding with stripe details and form data"); + // Update the onboarding record with all the form data await OrganizationOnboardingRepository.update(organizationOnboarding.id, { - bio: bio ?? null, - logo: logo ?? null, - invitedMembers: invitedMembers, - teams, stripeCustomerId, - ...updatedConfig, + seats: updatedConfig.seats, + pricePerSeat: updatedConfig.pricePerSeat, + billingPeriod: updatedConfig.billingPeriod, + logo, + bio, + brandColor: brandColor ?? null, + bannerUrl: bannerUrl ?? null, + teams, + invitedMembers, }); return { diff --git a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts index a324b8b575..6d6f8c3868 100644 --- a/packages/features/ee/organizations/lib/OrganizationPermissionService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPermissionService.ts @@ -5,10 +5,11 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; +import type { OnboardingUser } from "./service/onboarding/types"; + const log = logger.getSubLogger({ prefix: ["ee", "organizations", "OrganizationPermissionService"] }); type SeatsPrice = { seats?: number | null; @@ -28,7 +29,7 @@ export interface validatePermissionsIOrganizationPermissionService { } export class OrganizationPermissionService { - constructor(private readonly user: NonNullable) {} + constructor(private readonly user: OnboardingUser) {} async hasPermissionToCreateForEmail(targetEmail: string): Promise { return this.user.email === targetEmail || this.user.role === "ADMIN"; diff --git a/packages/features/ee/organizations/lib/onboardingStore.test.ts b/packages/features/ee/organizations/lib/onboardingStore.test.ts new file mode 100644 index 0000000000..d16b0312a9 --- /dev/null +++ b/packages/features/ee/organizations/lib/onboardingStore.test.ts @@ -0,0 +1,389 @@ +/** + * @vitest-environment jsdom + */ + +// @ts-nocheck - Test file with mock type compatibility issues that don't affect test functionality + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useSession } from "next-auth/react"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc/react"; + +import { useOnboardingStore, useOnboarding } from "./onboardingStore"; + +// Mock all dependencies +vi.mock("next-auth/react"); +vi.mock("next/navigation"); +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + organizations: { + getOrganizationOnboarding: { + useQuery: vi.fn(), + }, + }, + }, + }, +})); + +// Mock constants +vi.mock("@calcom/lib/constants", async () => { + const actual = await vi.importActual("@calcom/lib/constants"); + return { + ...actual, + IS_SELF_HOSTED: false, // Default to false, will be overridden in specific tests + }; +}); + + +function renderUseOnboardingStore() { + renderHook(() => useOnboarding(), { wrapper: createWrapper() }); + return renderHook(() => useOnboardingStore(), { wrapper: createWrapper() }); +} + +// Test Data Builders +const createTestSession = (overrides?: { + email?: string; + role?: UserPermissionRole; + status?: "loading" | "authenticated" | "unauthenticated"; +}) => { + const status = overrides?.status || "authenticated"; + + if (status === "loading") { + return { + data: null, + status: "loading" as const, + update: vi.fn(), + }; + } + + if (status === "unauthenticated") { + return { + data: null, + status: "unauthenticated" as const, + update: vi.fn(), + }; + } + + return { + data: { + user: { + email: overrides?.email || "test@example.com", + role: overrides?.role || UserPermissionRole.USER, + }, + }, + status: "authenticated" as const, + update: vi.fn(), + }; +}; + +const createTestOrganizationOnboarding = (overrides?: { + isComplete?: boolean; + id?: string; + billingPeriod?: string; + pricePerSeat?: number; + seats?: number; + orgOwnerEmail?: string; + name?: string; + slug?: string; + bio?: string; + logo?: string; +}) => ({ + id: overrides?.id || "test-onboarding-id", + isComplete: overrides?.isComplete ?? false, + billingPeriod: overrides?.billingPeriod || "MONTHLY", + pricePerSeat: overrides?.pricePerSeat ?? 20, + seats: overrides?.seats ?? 5, + orgOwnerEmail: overrides?.orgOwnerEmail || "test@example.com", + name: overrides?.name || "Test Organization", + slug: overrides?.slug || "test-org", + bio: overrides?.bio || "Test bio", + logo: overrides?.logo || "test-logo.png", +}); + + + +function mockOrganizationOnboardingFromDB(data: ReturnType | null) { + const organizationOnboarding = data ? createTestOrganizationOnboarding(data) : null; + (trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType).mockReturnValue({ + data: organizationOnboarding, + isPending: false, + isLoading: false, + error: null, + refetch: vi.fn(), + }); +} + +const createTestSearchParams = (params?: Record) => { + return new URLSearchParams(params); +}; + +// Mock localStorage +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, +}); + +// Mock implementations +const mockRouterPush = vi.fn().mockImplementation((...args: string[]) => { + console.trace(); + console.log("mockRouterPush called", args[0]); +}); +const mockUseSession = vi.mocked(useSession); +vi.mocked(useRouter).mockReturnValue({ + push: mockRouterPush, + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(), +}); +const mockUsePathname = vi.mocked(usePathname); +const mockUseSearchParams = vi.mocked(useSearchParams); + +// Test wrapper for React hooks - no wrapper needed for this test +const createWrapper = () => undefined; + +describe("OnboardingStore", () => { + beforeEach(() => { + // Reset the store and let React do any side effects + act(() => { + useOnboardingStore.getState().reset(); + }); + // Reset localStorage mock + localStorageMock.clear.mockClear(); + localStorageMock.getItem.mockClear(); + localStorageMock.setItem.mockClear(); + localStorageMock.removeItem.mockClear(); + + mockRouterPush.mockClear(); + + // Setup default mocks + mockUsePathname.mockReturnValue("/settings/organizations/new"); + mockUseSearchParams.mockReturnValue(createTestSearchParams()); + + (trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType).mockReturnValue({ + data: undefined, + isPending: false, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + }); + + afterEach(() => { + act(() => { + useOnboardingStore.getState().reset(); + }); + vi.clearAllMocks(); + mockRouterPush.mockClear(); + localStorageMock.clear.mockClear(); + localStorageMock.getItem.mockClear(); + localStorageMock.setItem.mockClear(); + localStorageMock.removeItem.mockClear(); + }); + + describe("Zustand Store Actions - Complex Logic", () => { + describe("Reset Functionality", () => { + it("should reset to initial state when called without parameters", () => { + const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() }); + + // Set some values + act(() => { + result.current.setName("Test Org"); + result.current.setSlug("test-org"); + result.current.setBillingPeriod("ANNUALLY" as const); + result.current.setSeats(15); + result.current.addInvitedMember({ email: "test@example.com", name: "Test User" }); + }); + + // Reset + act(() => { + result.current.reset(); + }); + + expect(result.current.name).toBe(""); + expect(result.current.slug).toBe(""); + expect(result.current.billingPeriod).toBeUndefined(); + expect(result.current.seats).toBeNull(); + expect(result.current.invitedMembers).toEqual([]); + }); + + it("should set specific state when called with parameters", () => { + const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() }); + const newState = { + name: "Partial Reset Org", + slug: "partial-reset", + seats: 25, + }; + + act(() => { + result.current.reset(newState); + }); + + expect(result.current.name).toBe("Partial Reset Org"); + expect(result.current.slug).toBe("partial-reset"); + expect(result.current.seats).toBe(25); + }); + + it("should clear localStorage when resetting without parameters", () => { + const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() }); + + // Set some values to trigger localStorage save + act(() => { + result.current.setName("Test Org"); + }); + + // Reset + act(() => { + result.current.reset(); + }); + + // Verify localStorage removeItem was called + expect(localStorageMock.removeItem).toHaveBeenCalledWith("org-creation-onboarding"); + }); + }); + }); + + describe("useOnboarding Hook", () => { + beforeEach(() => { + mockUseSession.mockReturnValue(createTestSession()); + }); + + describe("Authentication Handling", () => { + it("should redirect to login when session is unauthenticated", async () => { + mockUseSession.mockReturnValue(createTestSession({ status: "unauthenticated" })); + mockUsePathname.mockReturnValue("/settings/organizations/new"); + mockUseSearchParams.mockReturnValue(createTestSearchParams()); + + renderHook(() => useOnboarding(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith( + `/auth/login?callbackUrl=${WEBAPP_URL}/settings/organizations/new` + ); + }); + mockRouterPush.mockClear(); + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it("should not redirect when session is authenticated", () => { + expect(mockRouterPush).not.toHaveBeenCalled(); + mockUseSession.mockReturnValue(createTestSession({ status: "authenticated" })); + + renderHook(() => useOnboarding(), { wrapper: createWrapper() }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); + + describe("Organization Onboarding State Sync", () => { + it("should return organization onboarding data when available", () => { + const organizationOnboarding = createTestOrganizationOnboarding({ + name: "Test Org", + slug: "test-org", + }); + + (trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType).mockReturnValue({ + data: organizationOnboarding, + isPending: false, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + const { result } = renderHook(() => useOnboarding(), { wrapper: createWrapper() }); + + expect(result.current.dbOnboarding).toEqual(organizationOnboarding); + expect(result.current.isLoadingOrgOnboarding).toBe(false); + }); + + it("should set onboardingData from DB to zustand if not set already", async () => { + const organizationOnboarding = createTestOrganizationOnboarding({ + id: "sync-test-id", + name: "Synced Organization", + slug: "synced-org", + bio: "Synced bio content", + logo: "synced-logo.png", + billingPeriod: "ANNUALLY", + pricePerSeat: 25, + seats: 10, + orgOwnerEmail: "owner@synced.com", + }); + + (trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType).mockReturnValue({ + data: organizationOnboarding, + isPending: false, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + renderHook(() => useOnboarding(), { wrapper: createWrapper() }); + const { result: storeResult } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(storeResult.current.onboardingId).toBe("sync-test-id"); + }); + + expect(storeResult.current.name).toBe("Synced Organization"); + expect(storeResult.current.slug).toBe("synced-org"); + expect(storeResult.current.bio).toBe("Synced bio content"); + expect(storeResult.current.logo).toBe("synced-logo.png"); + expect(storeResult.current.billingPeriod).toBe("ANNUALLY"); + expect(storeResult.current.pricePerSeat).toBe(25); + expect(storeResult.current.seats).toBe(10); + expect(storeResult.current.orgOwnerEmail).toBe("owner@synced.com"); + }); + + it("should sync onboardingData from DB to zustand if set already", async () => { + mockOrganizationOnboardingFromDB(createTestOrganizationOnboarding({ + id: "onboarding1", + name: "Onboarding 1 Organization", + slug: "onboarding1-org", + bio: "Onboarding 1 bio content", + logo: "onboarding1-logo.png", + billingPeriod: "MONTHLY", + pricePerSeat: 15, + seats: 5, + orgOwnerEmail: "owner@onboarding1.com", + })) + + // Load the store to set the initial state + const { result: storeResultBefore } = renderUseOnboardingStore(); + + expect(storeResultBefore.current.name).toBe("Onboarding 1 Organization"); + expect(storeResultBefore.current.slug).toBe("onboarding1-org"); + + mockOrganizationOnboardingFromDB(createTestOrganizationOnboarding({ + id: "sync-test-id", + name: "Synced Organization", + slug: "synced-org", + bio: "Synced bio content", + logo: "synced-logo.png", + billingPeriod: "ANNUALLY", + pricePerSeat: 25, + seats: 10, + orgOwnerEmail: "owner@synced.com", + })); + + // Load the store to sync the new data + const { result: storeResultAfter } = renderUseOnboardingStore(); + // Verify the data is synced + expect(storeResultAfter.current.name).toBe("Synced Organization"); + expect(storeResultAfter.current.slug).toBe("synced-org"); + }); + }); + }); +}); diff --git a/packages/features/ee/organizations/lib/onboardingStore.ts b/packages/features/ee/organizations/lib/onboardingStore.ts index 3dd9210aca..acf84ff8b3 100644 --- a/packages/features/ee/organizations/lib/onboardingStore.ts +++ b/packages/features/ee/organizations/lib/onboardingStore.ts @@ -2,17 +2,13 @@ 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 { persist, StorageValue } from "zustand/middleware"; -import { WEBAPP_URL, IS_SELF_HOSTED } from "@calcom/lib/constants"; -import { UserPermissionRole } from "@calcom/prisma/enums"; +import { WEBAPP_URL, IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants"; +import { localStorage } from "@calcom/lib/webstorage"; +import { BillingPeriod, UserPermissionRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; -enum BillingPeriod { - MONTHLY = "MONTHLY", - ANNUALLY = "ANNUALLY", -} - interface OnboardingAdminStoreState { billingPeriod?: BillingPeriod; pricePerSeat?: number | null; @@ -25,6 +21,8 @@ interface OnboardingUserStoreState { slug: string; logo?: string | null; bio?: string | null; + brandColor?: string | null; + bannerUrl?: string | null; onboardingId: string | null; invitedMembers: { email: string; name?: string }[]; teams: { id: number; name: string; slug: string | null; isBeingMigrated: boolean }[]; @@ -42,6 +40,8 @@ interface OnboardingStoreState extends OnboardingAdminStoreState, OnboardingUser setSlug: (slug: string) => void; setLogo: (logo: string) => void; setBio: (bio: string) => void; + setBrandColor: (brandColor: string) => void; + setBannerUrl: (bannerUrl: string) => void; addInvitedMember: (member: { email: string; name?: string }) => void; removeInvitedMember: (email: string) => void; @@ -59,6 +59,8 @@ const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = { slug: "", logo: "", bio: "", + brandColor: "", + bannerUrl: "", orgOwnerEmail: "", seats: null, pricePerSeat: null, @@ -70,7 +72,7 @@ const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = { export const useOnboardingStore = create()( persist( - (set) => ({ + (set, get) => ({ ...initialState, // Admin actions @@ -84,6 +86,8 @@ export const useOnboardingStore = create()( setSlug: (slug) => set({ slug }), setLogo: (logo) => set({ logo }), setBio: (bio) => set({ bio }), + setBrandColor: (brandColor) => set({ brandColor }), + setBannerUrl: (bannerUrl) => set({ bannerUrl }), setOnboardingId: (onboardingId) => set({ onboardingId }), addInvitedMember: (member) => set((state) => ({ @@ -97,7 +101,6 @@ export const useOnboardingStore = create()( // Team actions setTeams: (teams) => set({ teams }), - // Reset action reset: (state) => { if (state) { set(state); @@ -116,57 +119,59 @@ export const useOnboardingStore = create()( ) ); -export const useOnboarding = (params?: { step?: "start" | "status" | null }) => { +export const useOnboarding = () => { 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 isBillingEnabled = process.env.NEXT_PUBLIC_IS_E2E ? false : IS_TEAM_BILLING_ENABLED_CLIENT; + const searchParams = useSearchParams(); const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } = trpc.viewer.organizations.getOrganizationOnboarding.useQuery(); - const { reset, onboardingId } = useOnboardingStore(); - const step = params?.step ?? null; + const { reset } = useOnboardingStore(); useEffect(() => { if (isLoadingOrgOnboarding) { return; } + // Admin on handover page should never touch the store + // The DB query returns admin's own onboarding, not the one being handed over + if (isAdmin && path?.includes("/handover")) { + 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(); - } + // Admin creating for someone else - don't sync from DB, let them proceed to handover + if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) { + return; } - } 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"); - } + // Always sync from DB to ensure UI reflects latest changes + 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, + brandColor: organizationOnboarding.brandColor, + bannerUrl: organizationOnboarding.bannerUrl, + invitedMembers: [], + teams: [], + }); } - }, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]); + // Note: We no longer redirect if onboardingId is missing, as regular users + // don't create the onboarding record until the final step + }, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, reset, path]); useEffect(() => { if (session.status === "loading") { diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts deleted file mode 100644 index 4b8d3fb6af..0000000000 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts +++ /dev/null @@ -1,540 +0,0 @@ -/** - * Goal is to e2e test createOrganizationFromOnboarding except inviteMembersWithNoInviterPermissionCheck, createTeamsHandler and createDomain - * Those are either already tested or should be tested out separately - */ -import prismock from "../../../../../../tests/libs/__mocks__/prisma"; - -import { describe, expect, it, vi, beforeEach } from "vitest"; - -import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; -import * as constants from "@calcom/lib/constants"; -import { createDomain } from "@calcom/lib/domainManager/organization"; -import type { OrganizationOnboarding } from "@calcom/prisma/client"; -import { MembershipRole } from "@calcom/prisma/enums"; -import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; -import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; - -import { createOrganizationFromOnboarding } from "./createOrganizationFromOnboarding"; - -vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({ - LicenseKeySingleton: { - getInstance: vi.fn(), - }, -})); - -// Mock the external functions -vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ - sendEmailVerification: vi.fn(), -})); - -vi.mock("@calcom/emails/email-manager", () => ({ - sendOrganizationCreationEmail: vi.fn(), -})); - -vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({ - inviteMembersWithNoInviterPermissionCheck: vi.fn(), -})); - -vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({ - createTeamsHandler: vi.fn(), -})); - -vi.mock("@calcom/lib/domainManager/organization", () => ({ - createDomain: vi.fn(), -})); - -vi.mock("@calcom/lib/server/i18n", () => { - return { - getTranslation: async (locale: string, namespace: string) => { - const t = (key: string) => key; - t.locale = locale; - t.namespace = namespace; - return t; - }, - }; -}); - -const mockOrganizationOnboarding = { - name: "Test Org", - slug: "test-org", - orgOwnerEmail: "test@example.com", - billingPeriod: "MONTHLY", - seats: 5, - pricePerSeat: 20, - isComplete: false, - stripeCustomerId: "mock_stripe_customer_id", - createdById: 1, - invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }], - teams: [ - { id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" }, - { id: 2, name: "New Team", isBeingMigrated: false, slug: null }, - ], - logo: null, - bio: null, - organizationId: null, - isDomainConfigured: false, - isPlatform: false, -}; - -// Helper functions for creating test scenarios -async function createTestUser(data: { - email: string; - name?: string; - username?: string; - metadata?: any; - onboardingCompleted?: boolean; - emailVerified?: Date | null; -}) { - return prismock.user.create({ - data: { - email: data.email, - name: data.name || "Test User", - username: data.username || "testuser", - metadata: data.metadata || {}, - completedOnboarding: data.onboardingCompleted, - emailVerified: data.emailVerified, - }, - }); -} - -async function createTestOrganization(data: { - name: string; - slug: string; - isOrganization?: boolean; - metadata?: any; -}) { - return prismock.team.create({ - data: { - name: data.name, - slug: data.slug, - isOrganization: data.isOrganization ?? true, - metadata: data.metadata || {}, - }, - }); -} - -async function createTestTeam(data: { name: string; slug: string }) { - return prismock.team.create({ - data: { - name: data.name, - slug: data.slug, - isOrganization: false, - }, - }); -} - -async function createTestOrganizationOnboarding( - data?: Partial & { organizationId?: number | null } -) { - console.log("TEST: Creating organization onboarding", data); - return prismock.organizationOnboarding.create({ - data: { - ...mockOrganizationOnboarding, - ...data, - }, - }); -} - -async function createOnboardingEligibleUserAndOnboarding(data: { - user?: { - email: string; - name?: string; - username?: string; - }; - organizationOnboarding?: Partial & { organizationId?: number | null }; -}) { - const user = await createTestUser({ - ...data.user, - onboardingCompleted: true, - emailVerified: new Date(), - }); - const organizationOnboarding = await createTestOrganizationOnboarding({ - ...data.organizationOnboarding, - createdById: user.id, - }); - return { user, organizationOnboarding }; -} - -async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) { - return prismock.membership.create({ - data: { - createdAt: new Date(), - userId: data.userId, - teamId: data.teamId, - role: data.role || MembershipRole.MEMBER, - accepted: true, - }, - }); -} - -async function expectOrganizationOnboardingToHaveDataContaining({ - onboardingId, - expectedData, -}: { - onboardingId: string; - expectedData: Partial; -}) { - const updatedOrganizationOnboarding = await prismock.organizationOnboarding.findUnique({ - where: { id: onboardingId }, - }); - expect(updatedOrganizationOnboarding).toEqual(expect.objectContaining(expectedData)); -} - -describe("createOrganizationFromOnboarding", () => { - beforeEach(async () => { - vi.resetAllMocks(); - // @ts-expect-error reset is a method on Prismock - await prismock.reset(); - }); - - describe("hosted", () => { - beforeEach(() => { - vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); - }); - - // Skipped because non-existend user creation support isn't there through onboarding now. - it.skip("should create an organization with a non-existent user as owner", async () => { - const organizationOnboarding = await createTestOrganizationOnboarding(); - - const { organization, owner } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - // Verify organization creation - expect(organization).toBeDefined(); - expect(organization.name).toBe(organizationOnboarding.name); - expect(organization.slug).toBe(organizationOnboarding.slug); - expect(organization.metadata).toEqual({ - orgSeats: 5, - orgPricePerSeat: 20, - billingPeriod: "MONTHLY", - subscriptionId: "mock_subscription_id", - subscriptionItemId: "mock_subscription_item_id", - }); - - // Verify owner creation - expect(owner).toBeDefined(); - expect(owner.email).toBe(organizationOnboarding.orgOwnerEmail); - }); - - it("should create an organization with an existing user as owner", async () => { - const orgOwnerEmail = "test@example.com"; - const existingUser = await createTestUser({ - email: orgOwnerEmail, - name: "Existing User", - username: "existinguser", - onboardingCompleted: true, - emailVerified: new Date(), - }); - - const organizationOnboarding = await createTestOrganizationOnboarding({ - orgOwnerEmail, - }); - - const { organization, owner } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - // Verify organization creation - expect(organization).toBeDefined(); - expect(organization.name).toBe(organizationOnboarding.name); - expect(organization.slug).toBe(organizationOnboarding.slug); - - // Verify owner is the existing user - expect(owner.id).toBe(existingUser.id); - expect(owner.email).toBe(existingUser.email); - - expect(createDomain).toHaveBeenCalledWith(organization.slug); - // Verify team creation and member invites still work - expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith( - expect.objectContaining({ - orgSlug: organization.slug, - teamId: organization.id, - invitations: organizationOnboarding.invitedMembers?.map((member) => ({ - usernameOrEmail: member.email, - role: MembershipRole.MEMBER, - })), - }) - ); - expect(createTeamsHandler).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - orgId: organization.id, - teamNames: ["New Team"], - moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "new-team-slug" })]), - }), - }) - ); - }); - - it("should reuse existing organization if organizationId is already set in onboarding. Covers the retry scenario where org got created but then error occurred", async () => { - const orgName = "Existing Org"; - const orgSlug = "existing-org"; - const existingOrg = await createTestOrganization({ - name: orgName, - slug: orgSlug, - }); - - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - name: "Existing User", - username: "existinguser", - email: "test@example.com", - }, - organizationOnboarding: { - organizationId: existingOrg.id, - name: orgName, - slug: orgSlug, - }, - }); - - const { organization } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - // Verify the existing organization was reused - expect(organization.id).toBe(existingOrg.id); - }); - - it("should throw error if organization with same slug exists", async () => { - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - organizationOnboarding: { - slug: "conflicting-org-with-same-slug", - }, - }); - - await createTestOrganization({ - name: "Conflicting Org with same slug", - slug: organizationOnboarding.slug, - }); - - await expect( - createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }) - ).rejects.toThrow("organization_url_taken"); - }); - - it("should create team with slugified slug", async () => { - const teamToMove = await createTestTeam({ - name: "Sales", - slug: "Company Sales", - }); - - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - organizationOnboarding: { - slug: "UPPERCASE-SLUG", - teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }], - }, - }); - - const { organization } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - expect(createTeamsHandler).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - orgId: organization.id, - teamNames: [], - moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]), - }), - }) - ); - }); - - it("should invite members with isDirectUserAction set to false", async () => { - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - username: "org-owner", - email: "owner@example.com", - }, - }); - - // Call createOrganizationFromOnboarding - const result = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "sub_123", - paymentSubscriptionItemId: "si_123", - }); - - // Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false - expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith( - expect.objectContaining({ - teamId: result.organization.id, - invitations: [ - { usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER }, - { usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER }, - ], - isDirectUserAction: false, - }) - ); - }); - - it("should update stripe customer ID for existing user", async () => { - const { user: existingUser, organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - organizationOnboarding: { - slug: "conflicting-org-with-same-slug", - }, - }); - - await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - // Verify user's stripe customer ID was updated - const updatedUser = await prismock.user.findUnique({ - where: { id: existingUser.id }, - }); - - expect(updatedUser?.metadata).toEqual( - expect.objectContaining({ - stripeCustomerId: organizationOnboarding.stripeCustomerId, - }) - ); - }); - - it("should create organization in with slug set even if there is a team with same slug of which orgOwner(to-be) is a member", async () => { - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - organizationOnboarding: { - slug: "conflicting-org-with-same-slug", - }, - }); - - const teamWithConflictingSlug = await createTestTeam({ - name: "TestTeamWithConflictingSlug", - slug: organizationOnboarding.slug, - }); - - // Make the orgOwner a member of the team - await createTestMembership({ - userId: organizationOnboarding.createdById, - teamId: teamWithConflictingSlug.id, - role: MembershipRole.ADMIN, - }); - - const { organization } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - expect(organization.slug).toBe(organizationOnboarding.slug); - }); - - it("should not create organization if there is a team with same slug of which orgOwner(to-be) is not a member", async () => { - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - organizationOnboarding: { - slug: "conflicting-org-with-same-slug", - }, - }); - - await createTestTeam({ - name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner", - slug: organizationOnboarding.slug, - }); - - // Don't make the orgOwner a member of the team - // await createTestMembership({ - // userId: organizationOnboarding.createdById, - // teamId: teamWithConflictingSlug.id, - // role: MembershipRole.ADMIN, - // }); - - await expect( - createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }) - ).rejects.toThrow("organization_url_taken"); - }); - - it("should not mark the Onboarding as completed(isComplete=true)", async () => { - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - }); - - await expectOrganizationOnboardingToHaveDataContaining({ - onboardingId: organizationOnboarding.id, - expectedData: { - isComplete: false, - }, - }); - }); - }); - - describe("self-hosted", () => { - beforeEach(() => { - vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true); - }); - - it("should fail if the license is invalid", async () => { - vi.mocked(LicenseKeySingleton.getInstance as ReturnType).mockResolvedValue({ - checkLicense: vi.fn().mockResolvedValue(false), - }); - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - }); - - await expect( - createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }) - ).rejects.toThrow("Self hosted license not valid"); - }); - - it("should create an organization with a valid license", async () => { - vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ - checkLicense: vi.fn().mockResolvedValue(true), - }); - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - }); - - const { organization, owner } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); - - expect(organization).toBeDefined(); - expect(owner).toBeDefined(); - }); - }); -}); diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts deleted file mode 100644 index bb5573d041..0000000000 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts +++ /dev/null @@ -1,567 +0,0 @@ -import type { TFunction } from "i18next"; - -import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; -import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager"; -import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; -import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { - assertCanCreateOrg, - findUserToBeOrgOwner, - setupDomain, -} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils"; -import { DEFAULT_SCHEDULE } from "@calcom/lib/availability"; -import { getAvailabilityFromSchedule } from "@calcom/lib/availability"; -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import { getTranslation } from "@calcom/lib/server/i18n"; -import { DeploymentRepository } from "@calcom/lib/server/repository/deployment"; -import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; -import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; -import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; -import slugify from "@calcom/lib/slugify"; -import { prisma } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; -import type { Team, User } from "@calcom/prisma/client"; -import type { OrganizationOnboarding } from "@calcom/prisma/client"; -import { MembershipRole, CreationSource } from "@calcom/prisma/enums"; -import { - userMetadata, - orgOnboardingInvitedMembersSchema, - orgOnboardingTeamsSchema, -} from "@calcom/prisma/zod-utils"; -import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; -import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; -import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; - -// Onboarding can only be done from webapp currently and so we consider the source for User as WEBAPP -const creationSource = CreationSource.WEBAPP; -const log = logger.getSubLogger({ prefix: ["createOrganizationFromOnboarding"] }); -// Types for organization data -type OrganizationData = { - id: number | null; - name: string; - slug: string; - isOrganizationConfigured: boolean; - isOrganizationAdminReviewed: boolean; - autoAcceptEmail: string; - seats: number | null; - pricePerSeat: number | null; - isPlatform: boolean; - logoUrl: string | null; - bio: string | null; - billingPeriod?: "MONTHLY" | "ANNUALLY"; -}; - -type TeamData = { - id: number; - name: string; - isBeingMigrated: boolean; - slug: string | null; -}; - -type InvitedMember = { - email: string; - name?: string; -}; - -type OrgOwner = Awaited>; - -type OrganizationOnboardingArg = Pick< - OrganizationOnboarding, - | "id" - | "organizationId" - | "name" - | "slug" - | "orgOwnerEmail" - | "seats" - | "pricePerSeat" - | "billingPeriod" - | "invitedMembers" - | "teams" - | "isPlatform" - | "logo" - | "bio" - | "stripeCustomerId" - | "isDomainConfigured" ->; - -const invitedMembersSchema = orgOnboardingInvitedMembersSchema; -const teamsSchema = orgOnboardingTeamsSchema; - -async function createOrganizationWithExistingUserAsOwner({ - owner, - orgData, -}: { - owner: NonNullable>>; - orgData: OrganizationData; -}) { - const orgOwnerTranslation = await getTranslation(owner.locale || "en", "common"); - // We prefer ID which would be available if it is a retry of createOrganizationFromOnboarding and earlier organization was created and connected with Onboarding - let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null; - - if (organization) { - log.info( - `Reusing existing organization:`, - safeStringify({ - slug: orgData.slug, - id: organization.id, - }) - ); - return { organization }; - } - - const { slugConflictType } = await assertCanCreateOrg({ - slug: orgData.slug, - isPlatform: orgData.isPlatform, - orgOwner: owner, - errorOnUserAlreadyPartOfOrg: false, - // If it would have been restricted, then it would have been done already in intentToCreateOrgHandler - // This restriction doesn't apply for admin as we would have validated it already in intentToCreateOrgHandler, we just relax it here as we don't know who created the organization. - // TODO: If needed, we could start tracking if the onboarding is being done by instance Admin and relax it then only. - restrictBasedOnMinimumPublishedTeams: false, - }); - - // In this case we create the organization with slug=null. Let the teams migrate and then the conflict would go away. - const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true; - - log.info( - `Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}` - ); - - try { - const nonOrgUsername = owner.username || ""; - const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({ - orgData: { - ...orgData, - ...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }), - }, - owner: { - id: owner.id, - email: owner.email, - nonOrgUsername, - }, - }); - organization = orgCreationResult.organization; - const ownerProfile = orgCreationResult.ownerProfile; - - // Send organization creation email - if (!orgData.isPlatform) { - await sendOrganizationCreationEmail({ - language: orgOwnerTranslation, - from: `${organization.name}'s admin`, - to: owner.email, - ownerNewUsername: ownerProfile.username, - ownerOldUsername: nonOrgUsername, - orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }), - orgName: organization.name, - prevLink: `${getOrgFullOrigin("", { protocol: true })}/${owner.username || ""}`, - newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`, - }); - } - - // Set up default availability - const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); - await prisma.availability.createMany({ - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - userId: owner.id, - })), - }); - } catch (error) { - // Catch and log so that redactError doesn't redact the log message - log.error( - `RecoverableError: Error creating organization for owner ${owner.email}.`, - safeStringify(error) - ); - throw error; - } - - return { organization }; -} - -async function createOrganizationWithNonExistentUserAsOwner({ - email, - orgData, -}: { - email: string; - orgData: OrganizationData; -}) { - let organization = orgData.id - ? await OrganizationRepository.findById({ id: orgData.id }) - : await OrganizationRepository.findBySlug({ slug: orgData.slug }); - - if (organization) { - log.info( - `createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`, - safeStringify({ slug: orgData.slug, id: organization.id }) - ); - const owner = await findUserToBeOrgOwner(email); - if (!owner) { - // Can happen when the organization was created earlier and the webhook had failed and when the webhook got fired again in next subscription update, then the email was deleted already - // The fix would be to change the email in Onboarding record to new owner of the organization - // TODO: Identify the owner of the organization from Membership table and use that email instead here. - throw new Error(`Org exists but owner could not be found for email: ${email}`); - } - return { organization, owner }; - } - - const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({ - orgData, - owner: { - email: email, - }, - creationSource, - }); - organization = orgCreationResult.organization; - const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult; - const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email); - if (!orgOwner) { - throw new Error( - `Just created the owner ${orgOwnerFromCreation.email}, but couldn't find it in the database` - ); - } - - const translation = await getTranslation(orgOwner.locale ?? "en", "common"); - - await sendEmailVerification({ - email: orgOwner.email, - language: orgOwner.locale ?? "en", - username: ownerProfile.username || "", - isPlatform: orgData.isPlatform, - }); - - if (!orgData.isPlatform) { - await sendOrganizationCreationEmail({ - language: translation, - from: orgOwner.name ?? `${organization.name}'s admin`, - to: orgOwner.email, - ownerNewUsername: ownerProfile.username, - ownerOldUsername: null, - orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }), - orgName: organization.name, - prevLink: null, - newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`, - }); - } - - return { - organization, - owner: orgOwner, - }; -} - -async function createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, organizationId: number) { - if (teams.length === 0) return; - - const teamsToCreate = teams.filter((team) => !team.isBeingMigrated).map((team) => team.name); - const teamsToMove = teams - .filter((team) => team.isBeingMigrated) - .map((team) => ({ - id: team.id, - newSlug: team.slug ? slugify(team.slug) : team.slug, - shouldMove: true, - })); - - log.info( - `Creating ${teamsToCreate} teams and moving ${teamsToMove.map( - (team) => team.newSlug - )} teams for organization ${organizationId}` - ); - - await createTeamsHandler({ - ctx: { - user: { - ...owner, - organizationId, - }, - }, - input: { - teamNames: teamsToCreate, - orgId: organizationId, - moveTeams: teamsToMove, - creationSource, - }, - }); - - log.info( - `Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}` - ); -} - -async function inviteMembers(invitedMembers: InvitedMember[], organization: Team) { - if (invitedMembers.length === 0) return; - - log.info(`Inviting ${invitedMembers.length} members to organization ${organization.id}`); - await inviteMembersWithNoInviterPermissionCheck({ - inviterName: null, - teamId: organization.id, - language: "en", - creationSource: CreationSource.WEBAPP, - orgSlug: organization.slug || null, - invitations: invitedMembers.map((member) => ({ - usernameOrEmail: member.email, - role: MembershipRole.MEMBER, - })), - isDirectUserAction: false, - }); -} - -async function ensureStripeCustomerIdIsUpdated({ - owner, - stripeCustomerId, -}: { - owner: User; - stripeCustomerId: string; -}) { - const parsedMetadata = userMetadata.parse(owner.metadata); - - await new UserRepository(prisma).updateStripeCustomerId({ - id: owner.id, - stripeCustomerId: stripeCustomerId, - existingMetadata: parsedMetadata, - }); -} - -/** - * Temporary till we adapt all other code reading from metadata about stripeSubscriptionId and stripeSubscriptionItemId - */ -async function backwardCompatibilityForSubscriptionDetails({ - organization, - paymentSubscriptionId, - paymentSubscriptionItemId, -}: { - organization: { - id: number; - metadata: Prisma.JsonValue; - }; - paymentSubscriptionId?: string; - paymentSubscriptionItemId?: string; -}) { - if (!paymentSubscriptionId || !paymentSubscriptionItemId) { - return organization; - } - - const existingMetadata = teamMetadataStrictSchema.parse(organization.metadata); - const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({ - id: organization.id, - stripeSubscriptionId: paymentSubscriptionId, - stripeSubscriptionItemId: paymentSubscriptionItemId, - existingMetadata, - }); - return updatedOrganization; -} - -async function hasConflictingOrganization({ slug, onboardingId }: { slug: string; onboardingId: string }) { - const organization = await OrganizationRepository.findBySlugIncludeOnboarding({ slug }); - if (!organization?.organizationOnboarding) { - // Old organizations created without onboarding, - return false; - } - - // Different onboardingId means existing organization is owned by someone else - return organization.organizationOnboarding.id !== onboardingId; -} - -async function handleDomainSetup({ - organizationOnboarding, - orgOwnerTranslation, -}: { - organizationOnboarding: OrganizationOnboardingArg; - orgOwnerTranslation: TFunction; -}) { - if (!organizationOnboarding.isDomainConfigured) { - // Important: Setup Domain first before creating the organization as Vercel/Cloudflare might not allow the domain to be created and that could cause organization booking pages to not actually work - await setupDomain({ - slug: organizationOnboarding.slug, - isPlatform: organizationOnboarding.isPlatform, - orgOwnerEmail: organizationOnboarding.orgOwnerEmail, - orgOwnerTranslation, - }); - } - - await OrganizationOnboardingRepository.update(organizationOnboarding.id, { - isDomainConfigured: true, - }); -} - -async function handleOrganizationCreation({ - organizationOnboarding, - owner, - paymentSubscriptionId, - paymentSubscriptionItemId, -}: { - organizationOnboarding: OrganizationOnboardingArg; - owner: OrgOwner; - paymentSubscriptionId?: string; - paymentSubscriptionItemId?: string; -}) { - if (IS_SELF_HOSTED) { - const deploymentRepo = new DeploymentRepository(prisma); - const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo); - const hasValidLicense = await licenseKeyService.checkLicense(); - - if (!hasValidLicense) { - throw new Error("Self hosted license not valid"); - } - } - - let organization; - const orgData = { - id: organizationOnboarding.organizationId, - name: organizationOnboarding.name, - // Create organization with slug=null, so that org slug doesn't conflict with existing team that is probably being migrated and thus would not be a conflict any more - // Remember one can have a subteam with same slug as some organization, but regular team and organization can't have same slug - slug: organizationOnboarding.slug, - isOrganizationConfigured: true, - // We believe that as the payment has been accepted first and we also restrict the emails to company emails, it is safe to set this to true. - // We could easily set it to false if needed. In effect, it disables impersonations by non-admin reviewed organization's owner and also disables editing the member details - isOrganizationAdminReviewed: true, - autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1], - seats: organizationOnboarding.seats, - pricePerSeat: organizationOnboarding.pricePerSeat, - isPlatform: false, - billingPeriod: organizationOnboarding.billingPeriod, - logoUrl: organizationOnboarding.logo, - bio: organizationOnboarding.bio, - }; - - log.info( - "handleOrganizationCreation", - safeStringify({ orgId: organizationOnboarding.organizationId, orgSlug: organizationOnboarding.slug }) - ); - if (!owner) { - const result = await createOrganizationWithNonExistentUserAsOwner({ - email: organizationOnboarding.orgOwnerEmail, - orgData, - }); - organization = result.organization; - owner = result.owner; - } else { - const result = await createOrganizationWithExistingUserAsOwner({ - orgData, - owner, - }); - organization = result.organization; - } - - if (organizationOnboarding.stripeCustomerId) { - // Mostly needed for newly created user through the flow, existing user would have it already when checkout was created - // We need to set it there to ensure that for the same user new customerId is not created again - await ensureStripeCustomerIdIsUpdated({ - owner, - stripeCustomerId: organizationOnboarding.stripeCustomerId, - }); - } - - // Connect the organization to the onboarding - await OrganizationOnboardingRepository.update(organizationOnboarding.id, { - organizationId: organization.id, - }); - - const updatedOrganization = await backwardCompatibilityForSubscriptionDetails({ - organization, - paymentSubscriptionId, - paymentSubscriptionItemId, - }); - - return { - organization: { - ...organization, - metadata: updatedOrganization.metadata, - }, - owner, - }; -} - -/** - * This function is used by stripe webhook, so it should expect to be called multiple times till the entire flow completes without any error. - * So, it should be idempotent. - */ -export const createOrganizationFromOnboarding = async ({ - organizationOnboarding, - paymentSubscriptionId, - paymentSubscriptionItemId, -}: { - organizationOnboarding: OrganizationOnboardingArg; - paymentSubscriptionId?: string; - paymentSubscriptionItemId?: string; -}) => { - log.info( - "createOrganizationFromOnboarding", - safeStringify({ - orgId: organizationOnboarding.organizationId, - orgSlug: organizationOnboarding.slug, - }) - ); - - if (!IS_SELF_HOSTED && (!paymentSubscriptionId || !paymentSubscriptionItemId)) { - throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required"); - } - - if ( - await hasConflictingOrganization({ - slug: organizationOnboarding.slug, - onboardingId: organizationOnboarding.id, - }) - ) { - throw new Error("organization_already_exists_with_this_slug"); - } - - // We know admin permissions have been validated in the above step so we can safely normalize the input - const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail); - const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common"); - - // TODO: We need to send emails to admin in the case of SELF_HOSTING where NEXT_PUBLIC_SINGLE_ORG_SLUG isn't set - if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) { - await handleDomainSetup({ - organizationOnboarding, - orgOwnerTranslation, - }); - } - - const { organization, owner } = await handleOrganizationCreation({ - organizationOnboarding, - owner: userFromEmail, - paymentSubscriptionId, - paymentSubscriptionItemId, - }); - - await inviteMembers(invitedMembersSchema.parse(organizationOnboarding.invitedMembers), organization); - - await createOrMoveTeamsToOrganization( - teamsSchema.parse(organizationOnboarding.teams), - owner, - organization.id - ); - - // If the organization was created with slug=null, then set the slug now, assuming that the team having the same slug is migrated now - // If the team wasn't owned by the orgOwner, then org creation would have failed and we wouldn't be here - if (!organization.slug) { - try { - const { slug } = await OrganizationRepository.setSlug({ - id: organization.id, - slug: organizationOnboarding.slug, - }); - organization.slug = slug; - } catch (error) { - // Almost always the reason would be that the organization's slug conflicts with a team's slug - // The owner might not have chosen the conflicting team for migration - Can be confirmed by checking `teams` column in the database. - log.error( - "RecoverableError: Error while setting slug for organization", - safeStringify(error), - safeStringify({ - attemptedSlug: organizationOnboarding.slug, - organizationId: organization.id, - }) - ); - throw new Error( - `Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}` - ); - } - } - - return { organization, owner }; -}; diff --git a/packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts b/packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts new file mode 100644 index 0000000000..40c315d4b2 --- /dev/null +++ b/packages/features/ee/organizations/lib/server/orgCreationUtils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { isNotACompanyEmail } from "./orgCreationUtils"; + +describe("orgCreationUtils", () => { + describe("isNotACompanyEmail", () => { + it("should return true for gmail.com email", () => { + expect(isNotACompanyEmail("user@gmail.com")).toBe(true); + }); + + it("should return true for yahoo.com email", () => { + expect(isNotACompanyEmail("user@yahoo.com")).toBe(true); + }); + + it("should return true for outlook.com email", () => { + expect(isNotACompanyEmail("user@outlook.com")).toBe(true); + }); + + it("should return true for hotmail.com email", () => { + expect(isNotACompanyEmail("user@hotmail.com")).toBe(true); + }); + + it("should return true for protonmail.com email", () => { + expect(isNotACompanyEmail("user@protonmail.com")).toBe(true); + }); + + it("should return true for proton.me email", () => { + expect(isNotACompanyEmail("user@proton.me")).toBe(true); + }); + + it("should return true for icloud.com email", () => { + expect(isNotACompanyEmail("user@icloud.com")).toBe(true); + }); + + it("should return true for live.com email", () => { + expect(isNotACompanyEmail("user@live.com")).toBe(true); + }); + + it("should return false for company email", () => { + expect(isNotACompanyEmail("user@company.com")).toBe(false); + }); + + it("should return false for company email with subdomain", () => { + expect(isNotACompanyEmail("user@mail.company.com")).toBe(false); + }); + + it("should return false for company email with country code", () => { + expect(isNotACompanyEmail("user@company.co.uk")).toBe(false); + }); + + it("should return true for invalid email without @", () => { + expect(isNotACompanyEmail("invalidemail")).toBe(true); + }); + + it("should return true for empty email", () => { + expect(isNotACompanyEmail("")).toBe(true); + }); + + it("should return true for email with multiple @ symbols", () => { + expect(isNotACompanyEmail("user@@company.com")).toBe(false); + }); + + it("should return true for all popular personal email providers", () => { + const personalProviders = [ + "gmail.com", + "yahoo.com", + "outlook.com", + "hotmail.com", + "aol.com", + "icloud.com", + "mail.com", + "protonmail.com", + "proton.me", + "zoho.com", + "yandex.com", + "gmx.com", + "fastmail.com", + "inbox.com", + "me.com", + "hushmail.com", + "live.com", + "rediffmail.com", + "tutanota.com", + "mail.ru", + "usa.com", + "qq.com", + "163.com", + "web.de", + "rocketmail.com", + "excite.com", + "lycos.com", + "outlook.co", + "hotmail.co.uk", + ]; + + personalProviders.forEach((provider) => { + expect(isNotACompanyEmail(`user@${provider}`)).toBe(true); + }); + }); + + it("should return false for various company domains", () => { + const companyDomains = [ + "acme.com", + "techcorp.io", + "business.co", + "startup.ai", + "enterprise.net", + "cal.com", + ]; + + companyDomains.forEach((domain) => { + expect(isNotACompanyEmail(`user@${domain}`)).toBe(false); + }); + }); + }); +}); diff --git a/packages/features/ee/organizations/lib/server/orgCreationUtils.ts b/packages/features/ee/organizations/lib/server/orgCreationUtils.ts index d96e6763fc..6bc078f440 100644 --- a/packages/features/ee/organizations/lib/server/orgCreationUtils.ts +++ b/packages/features/ee/organizations/lib/server/orgCreationUtils.ts @@ -2,6 +2,7 @@ import { lookup } from "dns"; import { type TFunction } from "i18next"; import { sendAdminOrganizationNotification } from "@calcom/emails"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { RESERVED_SUBDOMAINS, ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE, @@ -20,7 +21,7 @@ const log = logger.getSubLogger({ prefix: ["orgCreationUtils"] }); /** * We can only say for sure that the email is not a company email. We can't say for sure if it is a company email. */ -function isNotACompanyEmail(email: string) { +export function isNotACompanyEmail(email: string) { // A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization const emailProviders = [ "gmail.com", @@ -185,7 +186,13 @@ export async function assertCanCreateOrg({ restrictBasedOnMinimumPublishedTeams: boolean; errorOnUserAlreadyPartOfOrg?: boolean; }) { - const verifiedUser = orgOwner.completedOnboarding && !!orgOwner.emailVerified; + const featuresRepository = new FeaturesRepository(prisma); + const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "email-verification" + ); + + const verifiedUser = + orgOwner.completedOnboarding && (emailVerificationEnabled ? !!orgOwner.emailVerified : true); if (!verifiedUser) { log.warn( "you_need_to_complete_user_onboarding_before_creating_an_organization", diff --git a/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts new file mode 100644 index 0000000000..4390024ab7 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts @@ -0,0 +1,605 @@ +import type { TFunction } from "i18next"; + +import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager"; +import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { + assertCanCreateOrg, + findUserToBeOrgOwner, + setupDomain, +} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils"; +import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; +import slugify from "@calcom/lib/slugify"; +import { prisma } from "@calcom/prisma"; +import type { Prisma, Team, User } from "@calcom/prisma/client"; +import { CreationSource, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; +import { + userMetadata, + orgOnboardingInvitedMembersSchema, + orgOnboardingTeamsSchema, + teamMetadataStrictSchema, +} from "@calcom/prisma/zod-utils"; +import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; +import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; + +import { OrganizationPaymentService } from "../../OrganizationPaymentService"; +import { OrganizationPermissionService } from "../../OrganizationPermissionService"; +import type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService"; +import type { + TeamInput, + InvitedMemberInput, + CreateOnboardingIntentInput, + OnboardingUser, + OrganizationData, + TeamData, + InvitedMember, + OrganizationOnboardingData, + OnboardingIntentResult, +} from "./types"; + +const log = logger.getSubLogger({ prefix: ["BaseOnboardingService"] }); +const invitedMembersSchema = orgOnboardingInvitedMembersSchema; +const teamsSchema = orgOnboardingTeamsSchema; + +type OrgOwner = Awaited>; + +export abstract class BaseOnboardingService implements IOrganizationOnboardingService { + protected user: OnboardingUser; + protected paymentService: OrganizationPaymentService; + protected permissionService: OrganizationPermissionService; + + constructor( + user: OnboardingUser, + paymentService?: OrganizationPaymentService, + permissionService?: OrganizationPermissionService + ) { + this.user = user; + this.paymentService = paymentService || new OrganizationPaymentService(user); + this.permissionService = permissionService || new OrganizationPermissionService(user); + } + + abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise; + abstract createOrganization( + organizationOnboarding: OrganizationOnboardingData, + paymentDetails?: { subscriptionId: string; subscriptionItemId: string } + ): Promise<{ organization: Team; owner: User }>; + + protected async createOnboardingRecord(input: CreateOnboardingIntentInput & { onboardingId?: string }) { + // If onboardingId exists, update the existing record (resume flow) + if (input.onboardingId) { + log.debug( + "Updating existing organization onboarding record (resume flow)", + safeStringify({ + onboardingId: input.onboardingId, + slug: input.slug, + name: input.name, + }) + ); + + await OrganizationOnboardingRepository.update(input.onboardingId, { + logo: input.logo ?? null, + bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, + teams: input.teams ?? [], + invitedMembers: input.invitedMembers ?? [], + }); + + const updatedOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId); + if (!updatedOnboarding) { + throw new Error(`Onboarding record ${input.onboardingId} not found after update`); + } + + log.debug("Organization onboarding updated", safeStringify({ onboardingId: updatedOnboarding.id })); + return updatedOnboarding; + } + + // Create new onboarding record + log.debug( + "Creating organization onboarding record", + safeStringify({ + slug: input.slug, + name: input.name, + orgOwnerEmail: input.orgOwnerEmail, + isPlatform: input.isPlatform, + }) + ); + + const organizationOnboarding = await this.paymentService.createOrganizationOnboarding({ + name: input.name, + slug: input.slug, + orgOwnerEmail: input.orgOwnerEmail, + seats: input.seats, + pricePerSeat: input.pricePerSeat, + billingPeriod: input.billingPeriod, + createdByUserId: this.user.id, + logo: input.logo ?? null, + bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, + teams: input.teams ?? [], + invitedMembers: input.invitedMembers ?? [], + }); + + log.debug("Organization onboarding created", safeStringify({ onboardingId: organizationOnboarding.id })); + + return organizationOnboarding; + } + + protected filterTeamsAndInvites(teams: TeamInput[] = [], invitedMembers: InvitedMemberInput[] = []) { + const teamsData = teams + .filter((team) => team.name.trim().length > 0) + .map((team) => ({ + id: team.id === -1 ? -1 : team.id, + name: team.name, + isBeingMigrated: team.isBeingMigrated, + slug: team.slug, + })); + + const invitedMembersData = invitedMembers + .filter((invite) => invite.email.trim().length > 0) + .map((invite) => ({ + email: invite.email, + name: invite.name, + teamId: invite.teamId, + teamName: invite.teamName, + role: invite.role, + })); + + return { teamsData, invitedMembersData }; + } + + protected handleAdminHandoverIfNeeded( + input: CreateOnboardingIntentInput, + onboardingId: string + ): OnboardingIntentResult | null { + const isAdminHandoverFlow = + this.user.role === UserPermissionRole.ADMIN && + this.user.email !== input.orgOwnerEmail && + (!input.teams || input.teams.length === 0) && + (!input.invitedMembers || input.invitedMembers.length === 0); + + log.debug( + "Checking admin handover flow", + safeStringify({ + userRole: this.user.role, + isAdmin: this.user.role === UserPermissionRole.ADMIN, + userEmail: this.user.email, + orgOwnerEmail: input.orgOwnerEmail, + emailsDifferent: this.user.email !== input.orgOwnerEmail, + teamsEmpty: !input.teams || input.teams.length === 0, + membersEmpty: !input.invitedMembers || input.invitedMembers.length === 0, + isAdminHandoverFlow, + }) + ); + + if (!isAdminHandoverFlow) { + return null; + } + + log.debug( + "Admin handover flow detected", + safeStringify({ adminEmail: this.user.email, orgOwnerEmail: input.orgOwnerEmail }) + ); + + return { + userId: this.user.id, + orgOwnerEmail: input.orgOwnerEmail, + name: input.name, + slug: input.slug, + seats: input.seats ?? null, + pricePerSeat: input.pricePerSeat ?? null, + billingPeriod: input.billingPeriod, + isPlatform: input.isPlatform, + organizationOnboardingId: onboardingId, + checkoutUrl: null, + organizationId: null, + handoverUrl: `${WEBAPP_URL}/settings/organizations/new/resume?onboardingId=${onboardingId}`, + }; + } + + protected isAdminCreatingForSelf(input: { orgOwnerEmail: string }): boolean { + return this.user.role === UserPermissionRole.ADMIN && this.user.email === input.orgOwnerEmail; + } + + protected async createOrganizationWithExistingUserAsOwner({ + owner, + orgData, + }: { + owner: NonNullable>>; + orgData: OrganizationData; + }) { + const orgOwnerTranslation = await getTranslation(owner.locale || "en", "common"); + let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null; + + if (organization) { + log.info( + `Reusing existing organization:`, + safeStringify({ + slug: orgData.slug, + id: organization.id, + }) + ); + return { organization }; + } + + const { slugConflictType } = await assertCanCreateOrg({ + slug: orgData.slug, + isPlatform: orgData.isPlatform, + orgOwner: owner, + errorOnUserAlreadyPartOfOrg: false, + restrictBasedOnMinimumPublishedTeams: false, + }); + + const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true; + + log.info( + `Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}` + ); + + try { + const nonOrgUsername = owner.username || ""; + const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({ + orgData: { + ...orgData, + ...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }), + }, + owner: { + id: owner.id, + email: owner.email, + nonOrgUsername, + }, + }); + organization = orgCreationResult.organization; + const ownerProfile = orgCreationResult.ownerProfile; + + if (!orgData.isPlatform) { + await sendOrganizationCreationEmail({ + language: orgOwnerTranslation, + from: `${organization.name}'s admin`, + to: owner.email, + ownerNewUsername: ownerProfile.username, + ownerOldUsername: nonOrgUsername, + orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }), + orgName: organization.name, + prevLink: `${getOrgFullOrigin("", { protocol: true })}/${owner.username || ""}`, + newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`, + }); + } + + const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); + await prisma.availability.createMany({ + data: availability.map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + userId: owner.id, + })), + }); + } catch (error) { + log.error( + `RecoverableError: Error creating organization for owner ${owner.email}.`, + safeStringify(error) + ); + throw error; + } + + return { organization }; + } + + protected async createOrganizationWithNonExistentUserAsOwner({ + email, + orgData, + }: { + email: string; + orgData: OrganizationData; + }) { + let organization = orgData.id + ? await OrganizationRepository.findById({ id: orgData.id }) + : await OrganizationRepository.findBySlug({ slug: orgData.slug }); + + if (organization) { + log.info( + `createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`, + safeStringify({ slug: orgData.slug, id: organization.id }) + ); + const owner = await findUserToBeOrgOwner(email); + if (!owner) { + throw new Error(`Org exists but owner could not be found for email: ${email}`); + } + return { organization, owner }; + } + + const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({ + orgData, + owner: { + email: email, + }, + creationSource: CreationSource.WEBAPP, + }); + organization = orgCreationResult.organization; + const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult; + const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email); + if (!orgOwner) { + throw new Error( + `Just created the owner ${orgOwnerFromCreation.email}, but couldn't find it in the database` + ); + } + + const translation = await getTranslation(orgOwner.locale ?? "en", "common"); + + await sendEmailVerification({ + email: orgOwner.email, + language: orgOwner.locale ?? "en", + username: ownerProfile.username || "", + isPlatform: orgData.isPlatform, + }); + + if (!orgData.isPlatform) { + await sendOrganizationCreationEmail({ + language: translation, + from: orgOwner.name ?? `${organization.name}'s admin`, + to: orgOwner.email, + ownerNewUsername: ownerProfile.username, + ownerOldUsername: null, + orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }), + orgName: organization.name, + prevLink: null, + newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`, + }); + } + + return { + organization, + owner: orgOwner, + }; + } + + protected async createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, organizationId: number) { + if (teams.length === 0) return; + + const teamsToCreate = teams.filter((team) => !team.isBeingMigrated).map((team) => team.name); + const teamsToMove = teams + .filter((team) => team.isBeingMigrated) + .map((team) => ({ + id: team.id, + newSlug: team.slug ? slugify(team.slug) : team.slug, + shouldMove: true, + })); + + log.info( + `Creating ${teamsToCreate} teams and moving ${teamsToMove.map( + (team) => team.newSlug + )} teams for organization ${organizationId}` + ); + + await createTeamsHandler({ + ctx: { + user: { + ...owner, + organizationId, + }, + }, + input: { + teamNames: teamsToCreate, + orgId: organizationId, + moveTeams: teamsToMove, + creationSource: CreationSource.WEBAPP, + }, + }); + + log.info( + `Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}` + ); + } + + protected async inviteMembers(invitedMembers: InvitedMember[], organization: Team, teamsData: TeamData[]) { + if (invitedMembers.length === 0) return; + + log.info( + `Processing ${invitedMembers.length} member invites for organization ${organization.id}`, + safeStringify({ + invitedMembers: invitedMembers.map((m) => ({ + email: m.email, + teamId: m.teamId, + teamName: m.teamName, + role: m.role, + })), + }) + ); + + const createdTeams = await prisma.team.findMany({ + where: { + parentId: organization.id, + }, + select: { + id: true, + name: true, + slug: true, + }, + }); + + log.info( + `Found ${createdTeams.length} teams in organization`, + safeStringify({ + teams: createdTeams.map((t) => ({ id: t.id, name: t.name })), + }) + ); + + const teamNameToId = new Map(); + createdTeams.forEach((team) => { + teamNameToId.set(team.name.toLowerCase(), team.id); + }); + + const teamIdMap = new Map(); + teamsData.forEach((teamData) => { + if (teamData.isBeingMigrated) { + teamIdMap.set(teamData.id, teamData.id); + } else { + const createdTeam = createdTeams.find((t) => t.name === teamData.name); + if (createdTeam) { + teamIdMap.set(teamData.id, createdTeam.id); + } + } + }); + + const invitesToOrg: InvitedMember[] = []; + const invitesToTeams = new Map(); + + for (const member of invitedMembers) { + let targetTeamId: number | undefined; + + if (member.teamId !== undefined) { + targetTeamId = teamIdMap.get(member.teamId) || member.teamId; + log.debug(`Member ${member.email}: teamId ${member.teamId} -> resolved to ${targetTeamId}`); + } else if (member.teamName) { + targetTeamId = teamNameToId.get(member.teamName.toLowerCase()); + log.debug( + `Member ${member.email}: teamName "${member.teamName}" -> resolved to ${ + targetTeamId || "not found" + }` + ); + } + + if (targetTeamId && createdTeams.some((t) => t.id === targetTeamId)) { + if (!invitesToTeams.has(targetTeamId)) { + invitesToTeams.set(targetTeamId, []); + } + invitesToTeams.get(targetTeamId)!.push(member); + log.debug(`Member ${member.email} will be invited to team ${targetTeamId}`); + } else { + invitesToOrg.push(member); + log.debug( + `Member ${member.email} will be invited to organization (no team specified or team not found)` + ); + } + } + + log.info( + "Invite categorization complete", + safeStringify({ + orgInvites: invitesToOrg.length, + teamInvites: invitesToTeams.size, + teamBreakdown: Array.from(invitesToTeams.entries()).map(([teamId, members]) => ({ + teamId, + count: members.length, + emails: members.map((m) => m.email), + })), + }) + ); + + if (invitesToOrg.length > 0) { + log.info(`Inviting ${invitesToOrg.length} members to organization ${organization.id}`); + await inviteMembersWithNoInviterPermissionCheck({ + inviterName: null, + teamId: organization.id, + language: "en", + creationSource: CreationSource.WEBAPP, + orgSlug: organization.slug || null, + invitations: invitesToOrg.map((member) => ({ + usernameOrEmail: member.email, + role: (member.role as MembershipRole) || MembershipRole.MEMBER, + })), + isDirectUserAction: false, + }); + } + + for (const [teamId, members] of Array.from(invitesToTeams.entries())) { + const teamName = createdTeams.find((t) => t.id === teamId)?.name || `team ${teamId}`; + log.info(`Inviting ${members.length} members to team "${teamName}" (${teamId})`); + await inviteMembersWithNoInviterPermissionCheck({ + inviterName: null, + teamId: teamId, + language: "en", + creationSource: CreationSource.WEBAPP, + orgSlug: organization.slug || null, + invitations: members.map((member: InvitedMember) => ({ + usernameOrEmail: member.email, + role: (member.role as MembershipRole) || MembershipRole.MEMBER, + })), + isDirectUserAction: false, + }); + } + + log.info("All member invites processed successfully"); + } + + protected async ensureStripeCustomerIdIsUpdated({ + owner, + stripeCustomerId, + }: { + owner: User; + stripeCustomerId: string; + }) { + const parsedMetadata = userMetadata.parse(owner.metadata); + + await new UserRepository(prisma).updateStripeCustomerId({ + id: owner.id, + stripeCustomerId: stripeCustomerId, + existingMetadata: parsedMetadata, + }); + } + + protected async backwardCompatibilityForSubscriptionDetails({ + organization, + paymentSubscriptionId, + paymentSubscriptionItemId, + }: { + organization: { + id: number; + metadata: Prisma.JsonValue; + }; + paymentSubscriptionId?: string; + paymentSubscriptionItemId?: string; + }) { + if (!paymentSubscriptionId || !paymentSubscriptionItemId) { + return organization; + } + + const existingMetadata = teamMetadataStrictSchema.parse(organization.metadata); + const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({ + id: organization.id, + stripeSubscriptionId: paymentSubscriptionId, + stripeSubscriptionItemId: paymentSubscriptionItemId, + existingMetadata, + }); + return updatedOrganization; + } + + protected async hasConflictingOrganization({ slug, onboardingId }: { slug: string; onboardingId: string }) { + const organization = await OrganizationRepository.findBySlugIncludeOnboarding({ slug }); + if (!organization?.organizationOnboarding) { + return false; + } + + return organization.organizationOnboarding.id !== onboardingId; + } + + protected async handleDomainSetup({ + organizationOnboarding, + orgOwnerTranslation, + }: { + organizationOnboarding: OrganizationOnboardingData; + orgOwnerTranslation: TFunction; + }) { + if (!organizationOnboarding.isDomainConfigured) { + await setupDomain({ + slug: organizationOnboarding.slug, + isPlatform: organizationOnboarding.isPlatform, + orgOwnerEmail: organizationOnboarding.orgOwnerEmail, + orgOwnerTranslation, + }); + } + + await OrganizationOnboardingRepository.update(organizationOnboarding.id, { + isDomainConfigured: true, + }); + } +} diff --git a/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts new file mode 100644 index 0000000000..a8fc3bf6a1 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts @@ -0,0 +1,270 @@ +import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils"; +import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; +import type { Team, User } from "@calcom/prisma/client"; +import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils"; + +import { BaseOnboardingService } from "../onboarding/BaseOnboardingService"; +import type { + CreateOnboardingIntentInput, + OnboardingIntentResult, + OrganizationOnboardingData, + OrganizationData, +} from "../onboarding/types"; + +const log = logger.getSubLogger({ prefix: ["BillingEnabledOrgOnboardingService"] }); + +const invitedMembersSchema = orgOnboardingInvitedMembersSchema; +const teamsSchema = orgOnboardingTeamsSchema; + +/** + * Handles organization onboarding when billing is enabled (Stripe flow). + * + * Flow: + * 1. Create onboarding record + * 2. Store teams/invites in database + * 3. Create Stripe checkout session + * 4. Return checkout URL + * 5. Organization created later via Stripe webhook + * + * Exception: Admin creating org for self - immediately creates organization (no payment required) + */ +export class BillingEnabledOrgOnboardingService extends BaseOnboardingService { + async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise { + log.debug( + "BillingEnabledOrgOnboardingService.createOnboardingIntent - INPUT", + safeStringify({ + invitedMembers: input.invitedMembers, + teams: input.teams, + }) + ); + + const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers); + + log.debug( + "BillingEnabledOrgOnboardingService - After filterTeamsAndInvites", + safeStringify({ + teamsData, + invitedMembersData, + }) + ); + + // Create onboarding record with ALL data at once + const organizationOnboarding = await this.createOnboardingRecord({ + ...input, + teams: teamsData, + invitedMembers: invitedMembersData, + }); + const onboardingId = organizationOnboarding.id; + + // Check if this is an admin handover flow + const handoverResult = this.handleAdminHandoverIfNeeded(input, onboardingId); + if (handoverResult) { + return handoverResult; + } + + // Check if admin is creating org for themselves - skip payment, create immediately + if ( + this.isAdminCreatingForSelf({ + orgOwnerEmail: organizationOnboarding.orgOwnerEmail, + }) + ) { + log.debug( + "Admin creating org for self - skipping payment and creating organization immediately", + safeStringify({ adminEmail: this.user.email, onboardingId }) + ); + + const { organization } = await this.createOrganization(organizationOnboarding); + + await OrganizationOnboardingRepository.markAsComplete(onboardingId); + + log.debug( + "Organization created successfully for admin", + safeStringify({ onboardingId, organizationId: organization.id }) + ); + + return { + userId: this.user.id, + orgOwnerEmail: input.orgOwnerEmail, + name: input.name, + slug: input.slug, + seats: input.seats ?? null, + pricePerSeat: input.pricePerSeat ?? null, + billingPeriod: input.billingPeriod, + isPlatform: input.isPlatform, + organizationOnboardingId: onboardingId, + checkoutUrl: null, + organizationId: organization.id, + }; + } + + // Regular flow - create payment intent + const paymentIntent = await this.paymentService.createPaymentIntent( + { + logo: input.logo ?? null, + bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, + teams: teamsData, + invitedMembers: invitedMembersData, + }, + { + id: organizationOnboarding.id, + pricePerSeat: organizationOnboarding.pricePerSeat, + billingPeriod: organizationOnboarding.billingPeriod, + seats: organizationOnboarding.seats, + isComplete: organizationOnboarding.isComplete, + orgOwnerEmail: organizationOnboarding.orgOwnerEmail, + slug: organizationOnboarding.slug, + stripeCustomerId: organizationOnboarding.stripeCustomerId, + } + ); + + return { + userId: this.user.id, + orgOwnerEmail: input.orgOwnerEmail, + name: input.name, + slug: input.slug, + seats: input.seats ?? null, + pricePerSeat: input.pricePerSeat ?? null, + billingPeriod: input.billingPeriod, + isPlatform: input.isPlatform, + organizationOnboardingId: onboardingId, + checkoutUrl: paymentIntent.checkoutUrl, + organizationId: null, // Organization not created yet (pending payment) + }; + } + + async createOrganization( + organizationOnboarding: OrganizationOnboardingData, + paymentDetails?: { subscriptionId: string; subscriptionItemId: string } + ): Promise<{ organization: Team; owner: User }> { + log.info( + "createOrganization (billing-enabled)", + safeStringify({ + orgId: organizationOnboarding.organizationId, + orgSlug: organizationOnboarding.slug, + }) + ); + + const isAdminForSelf = this.isAdminCreatingForSelf({ + orgOwnerEmail: organizationOnboarding.orgOwnerEmail, + }); + + if ( + !IS_SELF_HOSTED && + !isAdminForSelf && + (!paymentDetails?.subscriptionId || !paymentDetails?.subscriptionItemId) + ) { + throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required"); + } + + if ( + await this.hasConflictingOrganization({ + slug: organizationOnboarding.slug, + onboardingId: organizationOnboarding.id, + }) + ) { + throw new Error("organization_already_exists_with_this_slug"); + } + + let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail); + const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common"); + + if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) { + await this.handleDomainSetup({ + organizationOnboarding, + orgOwnerTranslation, + }); + } + + const orgData: OrganizationData = { + id: organizationOnboarding.organizationId, + name: organizationOnboarding.name, + slug: organizationOnboarding.slug, + isOrganizationConfigured: true, + isOrganizationAdminReviewed: true, + autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1], + seats: organizationOnboarding.seats, + pricePerSeat: organizationOnboarding.pricePerSeat, + isPlatform: false, + billingPeriod: organizationOnboarding.billingPeriod, + logoUrl: organizationOnboarding.logo, + bio: organizationOnboarding.bio, + brandColor: organizationOnboarding.brandColor, + bannerUrl: organizationOnboarding.bannerUrl, + }; + + let organization: Team; + if (!owner) { + const result = await this.createOrganizationWithNonExistentUserAsOwner({ + email: organizationOnboarding.orgOwnerEmail, + orgData, + }); + organization = result.organization; + owner = result.owner; + } else { + const result = await this.createOrganizationWithExistingUserAsOwner({ + orgData, + owner, + }); + organization = result.organization; + } + + if (organizationOnboarding.stripeCustomerId) { + await this.ensureStripeCustomerIdIsUpdated({ + owner, + stripeCustomerId: organizationOnboarding.stripeCustomerId, + }); + } + + await OrganizationOnboardingRepository.update(organizationOnboarding.id, { + organizationId: organization.id, + }); + + const updatedOrganization = await this.backwardCompatibilityForSubscriptionDetails({ + organization, + paymentSubscriptionId: paymentDetails?.subscriptionId, + paymentSubscriptionItemId: paymentDetails?.subscriptionItemId, + }); + + organization.metadata = updatedOrganization.metadata; + + const teamsData = teamsSchema.parse(organizationOnboarding.teams); + await this.createOrMoveTeamsToOrganization(teamsData, owner, organization.id); + + await this.inviteMembers( + invitedMembersSchema.parse(organizationOnboarding.invitedMembers), + organization, + teamsData + ); + + if (!organization.slug) { + try { + const { slug } = await OrganizationRepository.setSlug({ + id: organization.id, + slug: organizationOnboarding.slug, + }); + organization.slug = slug; + } catch (error) { + log.error( + "RecoverableError: Error while setting slug for organization", + safeStringify(error), + safeStringify({ + attemptedSlug: organizationOnboarding.slug, + organizationId: organization.id, + }) + ); + throw new Error( + `Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}` + ); + } + } + + return { organization, owner }; + } +} diff --git a/packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts new file mode 100644 index 0000000000..e66ed3c70a --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/IOrganizationOnboardingService.ts @@ -0,0 +1,32 @@ +import type { Team, User } from "@calcom/prisma/client"; + +import type { + CreateOnboardingIntentInput, + OnboardingIntentResult, + OrganizationOnboardingData, +} from "./types"; + +/** + * Interface for organization onboarding services. + * Implementations handle different onboarding flows (billing-enabled vs self-hosted). + */ +export interface IOrganizationOnboardingService { + /** + * Creates an organization onboarding intent. + * Depending on the implementation: + * - BillingEnabled: Creates Stripe checkout session, returns checkoutUrl + * - SelfHosted: Creates organization immediately, returns organizationId + */ + createOnboardingIntent(input: CreateOnboardingIntentInput): Promise; + + /** + * Creates the actual organization from onboarding data. + * Depending on the implementation: + * - SelfHosted: Validates license, creates organization without payment + * - BillingEnabled: Requires payment details, handles subscription + */ + createOrganization( + organizationOnboarding: OrganizationOnboardingData, + paymentDetails?: { subscriptionId: string; subscriptionItemId: string } + ): Promise<{ organization: Team; owner: User }>; +} diff --git a/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts new file mode 100644 index 0000000000..1f2a95237a --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory.ts @@ -0,0 +1,53 @@ +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +import { BillingEnabledOrgOnboardingService } from "./BillingEnabledOrgOnboardingService"; +import type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService"; +import { SelfHostedOrganizationOnboardingService } from "./SelfHostedOnboardingService"; +import type { OnboardingUser } from "./types"; + +const log = logger.getSubLogger({ prefix: ["OrganizationOnboardingFactory"] }); + +/** + * Factory that selects the appropriate onboarding service based on configuration: + * - Self-hosted flow (billing disabled) when: IS_TEAM_BILLING_ENABLED=false + * - Exception: E2E tests always use self-hosted flow + * Otherwise, billing is enabled (Stripe flow). + */ +export class OrganizationOnboardingFactory { + static create(user: OnboardingUser): IOrganizationOnboardingService { + const isBillingEnabled = this.isBillingEnabled(); + + log.debug( + "Creating onboarding service", + safeStringify({ + userId: user.id, + role: user.role, + IS_TEAM_BILLING_ENABLED: IS_TEAM_BILLING_ENABLED, + isBillingEnabled, + serviceType: isBillingEnabled ? "BillingEnabled" : "SelfHosted", + }) + ); + + if (isBillingEnabled) { + return new BillingEnabledOrgOnboardingService(user); + } else { + return new SelfHostedOrganizationOnboardingService(user); + } + } + + private static isBillingEnabled(): boolean { + // E2E tests always skip billing (use self-hosted flow) + if (process.env.NEXT_PUBLIC_IS_E2E) { + return false; + } + + // If billing is enabled globally, use it + if (IS_TEAM_BILLING_ENABLED) { + return true; + } + + return false; + } +} diff --git a/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md new file mode 100644 index 0000000000..9352144899 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/OrganizationOnboardingSetup.md @@ -0,0 +1,170 @@ +# Organization Onboarding Setup + +This document explains how the organization onboarding system works and how to configure it for different deployment scenarios. + +## Overview + +The organization onboarding system supports two different flows: + +1. **Billing-Enabled Flow** (BillingEnabledOrgOnboardingService): Used on hosted Cal.com with Stripe integration +2. **Self-Hosted Flow** (SelfHostedOrganizationOnboardingService): Used on self-hosted instances where admins can create organizations without payment + +The system automatically selects the appropriate flow based on environment variables and user permissions. + +## Architecture + +### Factory Pattern + +The `OrganizationOnboardingFactory` class determines which onboarding service to use: + +```typescript +OrganizationOnboardingFactory.create(user) + → BillingEnabledOrgOnboardingService | SelfHostedOrganizationOnboardingService +``` + +### Decision Logic + +The factory uses the following logic to determine which service to instantiate: + +``` +IF process.env.NEXT_PUBLIC_IS_E2E is set: + → Use SelfHostedOrganizationOnboardingService (E2E tests always skip billing) + +ELSE IF IS_TEAM_BILLING_ENABLED is true: + → Use BillingEnabledOrgOnboardingService (hosted environment with Stripe) + +ELSE IF user.role is ADMIN: + → Use SelfHostedOrganizationOnboardingService (self-hosted admins skip billing) + +ELSE: + → Use BillingEnabledOrgOnboardingService (non-admins need billing even on self-hosted) +``` + +## Environment Variables + +### Required for Billing-Enabled Flow + +```bash +# Stripe Configuration +STRIPE_CLIENT_ID=your_stripe_client_id +NEXT_PUBLIC_STRIPE_PUBLIC_KEY=your_stripe_public_key +STRIPE_PRIVATE_KEY=your_stripe_private_key + +# These enable IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED +``` + +### Optional Configuration + +```bash +# Force hosted features on self-hosted instances +NEXT_PUBLIC_HOSTED_CAL_FEATURES=true + +# E2E testing (disables billing flow) +NEXT_PUBLIC_IS_E2E=1 + +# Organization pricing +NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=5 +NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW=37 +``` + +## Constants + +The system relies on several computed constants from `@calcom/lib/constants`: + +### Server-Side Constants + +- **`IS_TEAM_BILLING_ENABLED`**: `IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES` + - Determines if billing is globally enabled + - Used in: `OrganizationOnboardingFactory` + +- **`IS_STRIPE_ENABLED`**: `!!(STRIPE_CLIENT_ID && NEXT_PUBLIC_STRIPE_PUBLIC_KEY && STRIPE_PRIVATE_KEY)` + - Checks if Stripe is properly configured + +- **`HOSTED_CAL_FEATURES`**: `process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED` + - Enables hosted features (including billing) + +- **`IS_SELF_HOSTED`**: `!CAL_DOMAINS.some(domain => WEBAPP_HOSTNAME.endsWith(domain))` + - Detects if running on official Cal.com domains + +### Client-Side Constants + +- **`IS_TEAM_BILLING_ENABLED_CLIENT`**: `!!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES` + - Client-safe version of billing check + - Used in: `onboardingStore.ts` + +## Configuration Scenarios + +### Scenario 1: Hosted Cal.com (Official) + +**Environment:** +```bash +# Domain: *.cal.com, *.cal.dev, etc. +STRIPE_CLIENT_ID=ca_xxx +NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_xxx +STRIPE_PRIVATE_KEY=sk_live_xxx +``` + +**Result:** +- `IS_SELF_HOSTED` = `false` +- `HOSTED_CAL_FEATURES` = `true` +- `IS_TEAM_BILLING_ENABLED` = `true` +- All users → **BillingEnabledOrgOnboardingService** + +### Scenario 2: Self-Hosted with Billing (Optional) + +**Environment:** +```bash +# Domain: custom.example.com +NEXT_PUBLIC_HOSTED_CAL_FEATURES=true +STRIPE_CLIENT_ID=ca_xxx +NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_xxx +STRIPE_PRIVATE_KEY=sk_live_xxx +``` + +**Result:** +- `IS_SELF_HOSTED` = `true` +- `HOSTED_CAL_FEATURES` = `true` (forced) +- `IS_TEAM_BILLING_ENABLED` = `true` +- All users → **BillingEnabledOrgOnboardingService** + +### Scenario 3: Self-Hosted without Billing (Default) + +**Environment:** +```bash +# Domain: custom.example.com +# No Stripe configuration +``` + +**Result:** +- `IS_SELF_HOSTED` = `true` +- `HOSTED_CAL_FEATURES` = `false` +- `IS_TEAM_BILLING_ENABLED` = `false` +- Admin users → **SelfHostedOrganizationOnboardingService** +- Regular users → **BillingEnabledOrgOnboardingService** (will fail without Stripe) + +**Recommendation:** In this scenario, only admins should be allowed to create organizations. + +### Scenario 4: E2E Testing + +**Environment:** +```bash +NEXT_PUBLIC_IS_E2E=1 +# Any other configuration +``` + +**Result:** +- All users → **SelfHostedOrganizationOnboardingService** +- Billing flow is completely bypassed + +## User Roles + +The system differentiates between two user roles: + +### Admin Users (`UserPermissionRole.ADMIN`) +- Can create organizations without billing on self-hosted instances +- Can access self-hosted flow when `IS_TEAM_BILLING_ENABLED` is false + +### Regular Users (`UserPermissionRole.USER`) +- Always require billing flow +- Cannot create organizations on self-hosted without Stripe configured +``` diff --git a/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts new file mode 100644 index 0000000000..468f9471f3 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts @@ -0,0 +1,230 @@ +import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; +import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils"; +import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { DeploymentRepository } from "@calcom/lib/server/repository/deployment"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; +import { prisma } from "@calcom/prisma"; +import type { Team, User } from "@calcom/prisma/client"; +import { BillingPeriod } from "@calcom/prisma/enums"; +import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils"; + +import { BaseOnboardingService } from "./BaseOnboardingService"; +import type { + CreateOnboardingIntentInput, + OnboardingIntentResult, + OrganizationOnboardingData, + OrganizationData, +} from "./types"; + +const log = logger.getSubLogger({ prefix: ["SelfHostedOrganizationOnboardingService"] }); + +const invitedMembersSchema = orgOnboardingInvitedMembersSchema; +const teamsSchema = orgOnboardingTeamsSchema; + +/** + * Handles organization onboarding when billing is disabled (self-hosted admin flow). + * + * Flow: + * 1. Create onboarding record + * 2. Store teams/invites in database + * 3. Immediately create organization, teams, and invite members + * 4. Mark onboarding as complete + * 5. Return organization ID + */ +export class SelfHostedOrganizationOnboardingService extends BaseOnboardingService { + async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise { + log.debug( + "Starting self-hosted onboarding flow (immediate organization creation)", + safeStringify({ + slug: input.slug, + teamsCount: input.teams?.length ?? 0, + invitesCount: input.invitedMembers?.length ?? 0, + }) + ); + + // Step 1: Filter and normalize teams/invites + const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers); + + // Step 2: Create onboarding record with ALL data at once + const organizationOnboarding = await this.createOnboardingRecord({ + ...input, + teams: teamsData, + invitedMembers: invitedMembersData, + }); + const onboardingId = organizationOnboarding.id; + + // Check if this is an admin handover flow + const handoverResult = this.handleAdminHandoverIfNeeded(input, onboardingId); + if (handoverResult) { + return handoverResult; + } + + // Step 3: Create organization immediately (regular self-hosted flow) + log.debug("Creating organization immediately (no payment required)", safeStringify({ onboardingId })); + + const { organization } = await this.createOrganization({ + id: onboardingId, + organizationId: null, + name: input.name, + slug: input.slug, + orgOwnerEmail: input.orgOwnerEmail, + seats: input.seats ?? null, + pricePerSeat: input.pricePerSeat ?? null, + billingPeriod: input.billingPeriod ?? BillingPeriod.MONTHLY, + invitedMembers: invitedMembersData, + teams: teamsData, + isPlatform: input.isPlatform, + logo: input.logo ?? null, + bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, + stripeCustomerId: null, + isDomainConfigured: false, + }); + + // Step 4: Mark onboarding as complete + await OrganizationOnboardingRepository.markAsComplete(onboardingId); + + log.debug( + "Organization created successfully", + safeStringify({ onboardingId, organizationId: organization.id }) + ); + + // Step 5: Return result with organization ID + return { + userId: this.user.id, + orgOwnerEmail: input.orgOwnerEmail, + name: input.name, + slug: input.slug, + seats: input.seats ?? null, + pricePerSeat: input.pricePerSeat ?? null, + billingPeriod: input.billingPeriod, + isPlatform: input.isPlatform, + organizationOnboardingId: onboardingId, + checkoutUrl: null, // No checkout required + organizationId: organization.id, // Organization created immediately + }; + } + + async createOrganization( + organizationOnboarding: OrganizationOnboardingData + ): Promise<{ organization: Team; owner: User }> { + log.info( + "createOrganization (self-hosted)", + safeStringify({ + orgId: organizationOnboarding.organizationId, + orgSlug: organizationOnboarding.slug, + }) + ); + + if (IS_SELF_HOSTED) { + const deploymentRepo = new DeploymentRepository(prisma); + const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo); + const hasValidLicense = await licenseKeyService.checkLicense(); + + if (!hasValidLicense) { + throw new Error("Self hosted license not valid"); + } + } + + if ( + await this.hasConflictingOrganization({ + slug: organizationOnboarding.slug, + onboardingId: organizationOnboarding.id, + }) + ) { + throw new Error("organization_already_exists_with_this_slug"); + } + + let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail); + const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common"); + + if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) { + await this.handleDomainSetup({ + organizationOnboarding, + orgOwnerTranslation, + }); + } + + const orgData: OrganizationData = { + id: organizationOnboarding.organizationId, + name: organizationOnboarding.name, + slug: organizationOnboarding.slug, + isOrganizationConfigured: true, + isOrganizationAdminReviewed: true, + autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1], + seats: organizationOnboarding.seats, + pricePerSeat: organizationOnboarding.pricePerSeat, + isPlatform: false, + billingPeriod: organizationOnboarding.billingPeriod, + logoUrl: organizationOnboarding.logo, + bio: organizationOnboarding.bio, + brandColor: organizationOnboarding.brandColor, + bannerUrl: organizationOnboarding.bannerUrl, + }; + + let organization: Team; + if (!owner) { + const result = await this.createOrganizationWithNonExistentUserAsOwner({ + email: organizationOnboarding.orgOwnerEmail, + orgData, + }); + organization = result.organization; + owner = result.owner; + } else { + const result = await this.createOrganizationWithExistingUserAsOwner({ + orgData, + owner, + }); + organization = result.organization; + } + + if (organizationOnboarding.stripeCustomerId) { + await this.ensureStripeCustomerIdIsUpdated({ + owner, + stripeCustomerId: organizationOnboarding.stripeCustomerId, + }); + } + + await OrganizationOnboardingRepository.update(organizationOnboarding.id, { + organizationId: organization.id, + }); + + const teamsData = teamsSchema.parse(organizationOnboarding.teams); + await this.createOrMoveTeamsToOrganization(teamsData, owner, organization.id); + + await this.inviteMembers( + invitedMembersSchema.parse(organizationOnboarding.invitedMembers), + organization, + teamsData + ); + + if (!organization.slug) { + try { + const { slug } = await OrganizationRepository.setSlug({ + id: organization.id, + slug: organizationOnboarding.slug, + }); + organization.slug = slug; + } catch (error) { + log.error( + "RecoverableError: Error while setting slug for organization", + safeStringify(error), + safeStringify({ + attemptedSlug: organizationOnboarding.slug, + organizationId: organization.id, + }) + ); + throw new Error( + `Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}` + ); + } + } + + return { organization, owner }; + } +} diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts new file mode 100644 index 0000000000..96d02fe588 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; + +import { UserPermissionRole } from "@calcom/prisma/enums"; + +import { BaseOnboardingService } from "../BaseOnboardingService"; +import type { CreateOnboardingIntentInput } from "../types"; + +class TestableBaseOnboardingService extends BaseOnboardingService { + async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise { + throw new Error("Not implemented"); + } + + public testFilterTeamsAndInvites( + teams: CreateOnboardingIntentInput["teams"], + invitedMembers: CreateOnboardingIntentInput["invitedMembers"] + ) { + return this.filterTeamsAndInvites(teams, invitedMembers); + } +} + +const mockUser = { + id: 1, + email: "user@example.com", + role: UserPermissionRole.USER, + name: "Test User", +}; + +describe("BaseOnboardingService", () => { + describe("filterTeamsAndInvites", () => { + it("should filter out invites with empty emails", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const invites = [ + { email: "valid@example.com", teamName: "Marketing", role: "MEMBER" }, + { email: "", teamName: "Sales", role: "ADMIN" }, + { email: " ", teamName: "Engineering", role: "MEMBER" }, + { email: "another@example.com", teamName: "Design", role: "MEMBER" }, + ]; + + const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites); + + expect(invitedMembersData).toHaveLength(2); + expect(invitedMembersData).toEqual([ + { + email: "valid@example.com", + name: undefined, + teamId: undefined, + teamName: "Marketing", + role: "MEMBER", + }, + { + email: "another@example.com", + name: undefined, + teamId: undefined, + teamName: "Design", + role: "MEMBER", + }, + ]); + }); + + it("should preserve all fields from invites including role", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const invites = [ + { + email: "member@example.com", + name: "John Doe", + teamId: 5, + teamName: "Marketing", + role: "MEMBER", + }, + { + email: "admin@example.com", + name: "Jane Smith", + teamId: 10, + teamName: "Engineering", + role: "ADMIN", + }, + ]; + + const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites); + + expect(invitedMembersData).toEqual([ + { + email: "member@example.com", + name: "John Doe", + teamId: 5, + teamName: "Marketing", + role: "MEMBER", + }, + { + email: "admin@example.com", + name: "Jane Smith", + teamId: 10, + teamName: "Engineering", + role: "ADMIN", + }, + ]); + }); + + it("should handle invites without optional fields", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const invites = [ + { email: "minimal@example.com" }, + { email: "withteam@example.com", teamName: "Sales" }, + ]; + + const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites); + + expect(invitedMembersData).toEqual([ + { + email: "minimal@example.com", + name: undefined, + teamId: undefined, + teamName: undefined, + role: undefined, + }, + { + email: "withteam@example.com", + name: undefined, + teamId: undefined, + teamName: "Sales", + role: undefined, + }, + ]); + }); + + it("should filter out teams with empty names", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const teams = [ + { id: 1, name: "Marketing", isBeingMigrated: false, slug: null }, + { id: 2, name: "", isBeingMigrated: false, slug: null }, + { id: 3, name: " ", isBeingMigrated: false, slug: null }, + { id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" }, + ]; + + const { teamsData } = service.testFilterTeamsAndInvites(teams, []); + + expect(teamsData).toHaveLength(2); + expect(teamsData).toEqual([ + { id: 1, name: "Marketing", isBeingMigrated: false, slug: null }, + { id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" }, + ]); + }); + + it("should preserve team properties including migration status", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const teams = [ + { id: -1, name: "New Team", isBeingMigrated: false, slug: null }, + { id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" }, + ]; + + const { teamsData } = service.testFilterTeamsAndInvites(teams, []); + + expect(teamsData).toEqual([ + { id: -1, name: "New Team", isBeingMigrated: false, slug: null }, + { id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" }, + ]); + }); + + it("should handle empty teams and invites arrays", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites([], []); + + expect(teamsData).toEqual([]); + expect(invitedMembersData).toEqual([]); + }); + + it("should handle undefined teams and invites", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(undefined, undefined); + + expect(teamsData).toEqual([]); + expect(invitedMembersData).toEqual([]); + }); + + it("should preserve invites with teamId=-1 for new teams", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const teams = [ + { id: -1, name: "Marketing", isBeingMigrated: false, slug: null }, + { id: -1, name: "Sales", isBeingMigrated: false, slug: null }, + ]; + + const invites = [ + { email: "user1@example.com", teamId: -1, teamName: "Marketing", role: "MEMBER" }, + { email: "user2@example.com", teamId: -1, teamName: "Sales", role: "ADMIN" }, + ]; + + const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites); + + expect(teamsData).toHaveLength(2); + expect(invitedMembersData).toHaveLength(2); + expect(invitedMembersData[0].teamId).toBe(-1); + expect(invitedMembersData[1].teamId).toBe(-1); + expect(invitedMembersData[0].role).toBe("MEMBER"); + expect(invitedMembersData[1].role).toBe("ADMIN"); + }); + + it("should handle mixed scenarios with both org-level and team-specific invites", () => { + const service = new TestableBaseOnboardingService(mockUser as any); + + const teams = [ + { id: -1, name: "Marketing", isBeingMigrated: false, slug: null }, + { id: 42, name: "Engineering", isBeingMigrated: true, slug: "eng" }, + ]; + + const invites = [ + { email: "orguser@example.com", role: "MEMBER" }, + { email: "marketing@example.com", teamName: "Marketing", teamId: -1, role: "MEMBER" }, + { email: "eng@example.com", teamName: "Engineering", teamId: 42, role: "ADMIN" }, + ]; + + const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites); + + expect(teamsData).toHaveLength(2); + expect(invitedMembersData).toHaveLength(3); + + expect(invitedMembersData[0]).toEqual({ + email: "orguser@example.com", + name: undefined, + teamId: undefined, + teamName: undefined, + role: "MEMBER", + }); + + expect(invitedMembersData[1]).toEqual({ + email: "marketing@example.com", + name: undefined, + teamId: -1, + teamName: "Marketing", + role: "MEMBER", + }); + + expect(invitedMembersData[2]).toEqual({ + email: "eng@example.com", + name: undefined, + teamId: 42, + teamName: "Engineering", + role: "ADMIN", + }); + }); + }); +}); diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts new file mode 100644 index 0000000000..493cdc9c0b --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts @@ -0,0 +1,814 @@ +import prismock from "../../../../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import * as constants from "@calcom/lib/constants"; +import { createDomain } from "@calcom/lib/domainManager/organization"; +import { UserPermissionRole, CreationSource, MembershipRole, BillingPeriod } from "@calcom/prisma/enums"; +import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; +import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; + +import type { CreateOnboardingIntentInput } from "../../onboarding/types"; +import { BillingEnabledOrgOnboardingService } from "../BillingEnabledOrgOnboardingService"; + +vi.mock("../../OrganizationPaymentService"); + +vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ + sendEmailVerification: vi.fn(), +})); + +vi.mock("@calcom/emails/email-manager", () => ({ + sendOrganizationCreationEmail: vi.fn(), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({ + inviteMembersWithNoInviterPermissionCheck: vi.fn(), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({ + createTeamsHandler: vi.fn(), +})); + +vi.mock("@calcom/lib/domainManager/organization", () => ({ + createDomain: vi.fn(), +})); + +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, + }; +}); + +const mockUser = { + id: 1, + email: "user@example.com", + role: UserPermissionRole.USER, + name: "Test User", +}; + +const mockInput: CreateOnboardingIntentInput = { + name: "Test Organization", + slug: "test-org", + orgOwnerEmail: "owner@example.com", + seats: 10, + pricePerSeat: 20, + isPlatform: false, + creationSource: CreationSource.WEBAPP, + logo: "https://example.com/logo.png", + bio: "Test bio", + brandColor: "#000000", + bannerUrl: "https://example.com/banner.png", + teams: [ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: "Sales", isBeingMigrated: false, slug: null }, + ], + invitedMembers: [ + { email: "member1@example.com", name: "Member 1" }, + { email: "member2@example.com", name: "Member 2" }, + ], +}; + +describe("BillingEnabledOrgOnboardingService", () => { + let service: BillingEnabledOrgOnboardingService; + let mockPaymentService: any; + + beforeEach(async () => { + vi.resetAllMocks(); + // @ts-expect-error reset is a method on Prismock + await prismock.reset(); + + mockPaymentService = { + createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => { + // Actually create the record in prismock so it can be updated later + return await prismock.organizationOnboarding.create({ + data: { + id: "onboarding-123", + name: data.name, + slug: data.slug, + orgOwnerEmail: data.orgOwnerEmail, + seats: data.seats ?? null, + pricePerSeat: data.pricePerSeat ?? null, + billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY, + isComplete: false, + stripeCustomerId: null, + createdById: data.createdByUserId, + teams: [], + invitedMembers: [], + isPlatform: data.isPlatform ?? false, + logo: data.logo ?? null, + bio: data.bio ?? null, + brandColor: data.brandColor ?? null, + bannerUrl: data.bannerUrl ?? null, + }, + }); + }), + createPaymentIntent: vi.fn().mockResolvedValue({ + checkoutUrl: "https://stripe.com/checkout/session", + organizationOnboarding: {}, + subscription: {}, + sessionId: "session-123", + }), + }; + + service = new BillingEnabledOrgOnboardingService(mockUser as any, mockPaymentService); + }); + + describe("createOnboardingIntent", () => { + it("should create onboarding record", async () => { + await service.createOnboardingIntent(mockInput); + + expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + name: mockInput.name, + slug: mockInput.slug, + orgOwnerEmail: mockInput.orgOwnerEmail, + createdByUserId: mockUser.id, + }) + ); + }); + + it("should create Stripe payment intent with teams and invites", async () => { + await service.createOnboardingIntent(mockInput); + + expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith( + expect.objectContaining({ + teams: expect.arrayContaining([ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: "Sales", isBeingMigrated: false, slug: null }, + ]), + invitedMembers: expect.arrayContaining([ + { email: "member1@example.com", name: "Member 1" }, + { email: "member2@example.com", name: "Member 2" }, + ]), + }), + expect.anything() + ); + }); + + it("should return checkout URL", async () => { + const result = await service.createOnboardingIntent(mockInput); + + expect(result.checkoutUrl).toBe("https://stripe.com/checkout/session"); + expect(result.organizationId).toBeNull(); + }); + + it("should filter out empty team names", async () => { + const inputWithEmptyTeams = { + ...mockInput, + teams: [ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: " ", isBeingMigrated: false, slug: null }, + { id: -1, name: "", isBeingMigrated: false, slug: null }, + ], + }; + + await service.createOnboardingIntent(inputWithEmptyTeams); + + expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith( + expect.objectContaining({ + teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }], + }), + expect.anything() + ); + }); + + it("should filter out empty invite emails", async () => { + const inputWithEmptyInvites = { + ...mockInput, + invitedMembers: [ + { email: "member1@example.com", name: "Member 1" }, + { email: " ", name: "Empty" }, + { email: "", name: "Empty 2" }, + ], + }; + + await service.createOnboardingIntent(inputWithEmptyInvites); + + expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith( + expect.objectContaining({ + invitedMembers: [{ email: "member1@example.com", name: "Member 1" }], + }), + expect.anything() + ); + }); + + it("should NOT create organization immediately", async () => { + const result = await service.createOnboardingIntent(mockInput); + + // Organization will be created later via Stripe webhook + expect(result.organizationId).toBeNull(); + expect(result.checkoutUrl).not.toBeNull(); + }); + + it("should return all required fields", async () => { + const result = await service.createOnboardingIntent(mockInput); + + expect(result).toEqual({ + userId: mockUser.id, + orgOwnerEmail: mockInput.orgOwnerEmail, + name: mockInput.name, + slug: mockInput.slug, + seats: mockInput.seats, + pricePerSeat: mockInput.pricePerSeat, + billingPeriod: mockInput.billingPeriod, + isPlatform: mockInput.isPlatform, + organizationOnboardingId: "onboarding-123", + checkoutUrl: "https://stripe.com/checkout/session", + organizationId: null, + }); + }); + + it("should immediately create organization when admin creates org for self", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const adminUser = { + id: 1, + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + name: "Admin User", + }; + + const adminInput = { + ...mockInput, + orgOwnerEmail: adminUser.email, + }; + + await prismock.user.create({ + data: { + id: adminUser.id, + email: adminUser.email, + name: adminUser.name, + username: "admin", + completedOnboarding: true, + emailVerified: new Date(), + }, + }); + + const adminService = new BillingEnabledOrgOnboardingService(adminUser as any, mockPaymentService); + + const result = await adminService.createOnboardingIntent(adminInput); + + expect(result.organizationId).not.toBeNull(); + expect(result.checkoutUrl).toBeNull(); + expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled(); + + const onboarding = await prismock.organizationOnboarding.findUnique({ + where: { id: result.organizationOnboardingId }, + }); + expect(onboarding?.isComplete).toBe(true); + }); + + it("should use checkout flow when admin creates org for someone else with no teams/invites (handover)", async () => { + const adminUser = { + id: 1, + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + name: "Admin User", + }; + + const handoverInput = { + ...mockInput, + orgOwnerEmail: "other@example.com", + teams: [], + invitedMembers: [], + }; + + const adminService = new BillingEnabledOrgOnboardingService(adminUser as any, mockPaymentService); + + const result = await adminService.createOnboardingIntent(handoverInput); + + expect(result.organizationId).toBeNull(); + expect(result.checkoutUrl).toBeNull(); + expect(result.handoverUrl).toContain("/settings/organizations/new/resume"); + expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled(); + }); + }); + + describe("createOrganization", () => { + // Helper functions for creating test data + async function createTestUser(data: { + email: string; + name?: string; + username?: string; + metadata?: any; + onboardingCompleted?: boolean; + emailVerified?: Date | null; + }) { + return prismock.user.create({ + data: { + email: data.email, + name: data.name || "Test User", + username: data.username || "testuser", + metadata: data.metadata || {}, + completedOnboarding: data.onboardingCompleted, + emailVerified: data.emailVerified, + }, + }); + } + + async function createTestOrganization(data: { + name: string; + slug: string; + isOrganization?: boolean; + metadata?: any; + }) { + return prismock.team.create({ + data: { + name: data.name, + slug: data.slug, + isOrganization: data.isOrganization ?? true, + metadata: data.metadata || {}, + }, + }); + } + + async function createTestTeam(data: { name: string; slug: string }) { + return prismock.team.create({ + data: { + name: data.name, + slug: data.slug, + isOrganization: false, + }, + }); + } + + async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) { + return prismock.membership.create({ + data: { + createdAt: new Date(), + userId: data.userId, + teamId: data.teamId, + role: data.role || MembershipRole.MEMBER, + accepted: true, + }, + }); + } + + async function createTestOnboarding(overrides?: Partial) { + return await prismock.organizationOnboarding.create({ + data: { + ...mockOrganizationOnboardingData, + ...overrides, + }, + }); + } + + const mockOrganizationOnboardingData = { + id: "onboarding-123", + organizationId: null, + name: "Test Org", + slug: "test-org", + orgOwnerEmail: "owner@example.com", + seats: 5, + pricePerSeat: 20, + billingPeriod: BillingPeriod.MONTHLY, + invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }], + teams: [ + { id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" }, + { id: -1, name: "New Team", isBeingMigrated: false, slug: null }, + ], + isPlatform: false, + logo: null, + bio: null, + brandColor: null, + bannerUrl: null, + stripeCustomerId: "cus_123", + isDomainConfigured: false, + isComplete: false, + createdById: null, + }; + + it("should require payment details for billing-enabled flow", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow( + "payment_subscription_id_and_payment_subscription_item_id_are_required" + ); + }); + + it("should create organization with existing user as owner", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + name: "Existing User", + username: "existinguser", + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + // Verify organization creation + expect(organization).toBeDefined(); + expect(organization.name).toBe(mockOrganizationOnboarding.name); + expect(organization.slug).toBe(mockOrganizationOnboarding.slug); + + // Verify owner is the existing user + expect(owner.id).toBe(existingUser.id); + expect(owner.email).toBe(existingUser.email); + + expect(createDomain).toHaveBeenCalledWith(organization.slug); + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalled(); + expect(createTeamsHandler).toHaveBeenCalled(); + }); + + it("should reuse existing organization if organizationId is already set (idempotency)", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + const existingOrg = await createTestOrganization({ + name: mockOrganizationOnboarding.name, + slug: mockOrganizationOnboarding.slug, + }); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const onboardingWithOrgId = { + ...mockOrganizationOnboarding, + organizationId: existingOrg.id, + }; + + const { organization } = await service.createOrganization(onboardingWithOrgId, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + // Verify the existing organization was reused + expect(organization.id).toBe(existingOrg.id); + }); + + it("should throw error if organization with same slug exists", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + await createTestOrganization({ + name: "Conflicting Org", + slug: mockOrganizationOnboarding.slug, + }); + + await expect( + service.createOrganization(mockOrganizationOnboarding, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }) + ).rejects.toThrow("organization_url_taken"); + }); + + it("should update stripe customer ID for existing user", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + await service.createOrganization(mockOrganizationOnboarding, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + // Verify user's stripe customer ID was updated + const updatedUser = await prismock.user.findUnique({ + where: { id: existingUser.id }, + }); + + expect(updatedUser?.metadata).toEqual( + expect.objectContaining({ + stripeCustomerId: mockOrganizationOnboarding.stripeCustomerId, + }) + ); + }); + + it("should create organization even if there is a team with same slug that orgOwner is a member of", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const teamWithConflictingSlug = await createTestTeam({ + name: "TestTeamWithConflictingSlug", + slug: mockOrganizationOnboarding.slug, + }); + + // Make the orgOwner a member of the team + await createTestMembership({ + userId: existingUser.id, + teamId: teamWithConflictingSlug.id, + role: MembershipRole.ADMIN, + }); + + const { organization } = await service.createOrganization(mockOrganizationOnboarding, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + expect(organization.slug).toBe(mockOrganizationOnboarding.slug); + }); + + it("should not create organization if there is a team with same slug that orgOwner is NOT a member of", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + await createTestTeam({ + name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner", + slug: mockOrganizationOnboarding.slug, + }); + + await expect( + service.createOrganization(mockOrganizationOnboarding, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }) + ).rejects.toThrow("organization_url_taken"); + }); + + it("should invite members with isDirectUserAction set to false", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + username: "org-owner", + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const result = await service.createOrganization(mockOrganizationOnboarding, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + // Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: result.organization.id, + invitations: [ + { usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER }, + ], + isDirectUserAction: false, + }) + ); + }); + + it("should invite members to specific teams based on teamName", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const onboardingWithTeamInvites = { + ...mockOrganizationOnboarding, + teams: [ + { id: -1, name: "Marketing", isBeingMigrated: false, slug: null }, + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + ], + invitedMembers: [ + { email: "marketer@example.com", teamName: "marketing", role: MembershipRole.MEMBER }, + { email: "engineer@example.com", teamName: "engineering", role: MembershipRole.ADMIN }, + { email: "orguser@example.com" }, + ], + }; + + // Setup: createTeamsHandler will be called and we need teams to exist for inviteMembers + vi.mocked(createTeamsHandler).mockImplementation(async (options) => { + const marketing = await prismock.team.create({ + data: { + name: "Marketing", + slug: "marketing", + parentId: options.input.orgId, + }, + }); + const engineering = await prismock.team.create({ + data: { + name: "Engineering", + slug: "engineering", + parentId: options.input.orgId, + }, + }); + return { teams: [marketing, engineering] } as any; + }); + + const result = await service.createOrganization(onboardingWithTeamInvites, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + // Verify invites were sent to 3 different targets (org + 2 teams) + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(3); + + // Check org-level invite + const orgInviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === result.organization.id); + expect(orgInviteCall).toBeDefined(); + expect(orgInviteCall![0].invitations).toEqual([ + { usernameOrEmail: "orguser@example.com", role: MembershipRole.MEMBER }, + ]); + + // Check Marketing team invite + const teams = await prismock.team.findMany({ where: { parentId: result.organization.id } }); + const marketingTeam = teams.find((t) => t.name === "Marketing"); + const marketingInviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === marketingTeam!.id); + expect(marketingInviteCall).toBeDefined(); + expect(marketingInviteCall![0].invitations).toEqual([ + { usernameOrEmail: "marketer@example.com", role: MembershipRole.MEMBER }, + ]); + + // Check Engineering team invite + const engineeringTeam = teams.find((t) => t.name === "Engineering"); + const engineeringInviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === engineeringTeam!.id); + expect(engineeringInviteCall).toBeDefined(); + expect(engineeringInviteCall![0].invitations).toEqual([ + { usernameOrEmail: "engineer@example.com", role: MembershipRole.ADMIN }, + ]); + }); + + it("should preserve custom roles for invited members", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const onboardingWithRoles = { + ...mockOrganizationOnboarding, + invitedMembers: [ + { email: "admin@example.com", role: MembershipRole.ADMIN }, + { email: "member@example.com", role: MembershipRole.MEMBER }, + ], + }; + + const result = await service.createOrganization(onboardingWithRoles, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + const inviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === result.organization.id); + + expect(inviteCall).toBeDefined(); + expect(inviteCall![0].invitations).toEqual([ + { usernameOrEmail: "admin@example.com", role: MembershipRole.ADMIN }, + { usernameOrEmail: "member@example.com", role: MembershipRole.MEMBER }, + ]); + }); + + it("should handle mixed org-level and team-specific invites", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const onboardingWithMixedInvites = { + ...mockOrganizationOnboarding, + teams: [{ id: -1, name: "Sales", isBeingMigrated: false, slug: null }], + invitedMembers: [ + { email: "sales1@example.com", teamName: "sales", role: MembershipRole.MEMBER }, + { email: "sales2@example.com", teamName: "sales", role: MembershipRole.ADMIN }, + { email: "org1@example.com", role: MembershipRole.MEMBER }, + { email: "org2@example.com", role: MembershipRole.MEMBER }, + ], + }; + + vi.mocked(createTeamsHandler).mockImplementation(async (options) => { + const salesTeam = await prismock.team.create({ + data: { + name: "Sales", + slug: "sales", + parentId: options.input.orgId, + }, + }); + return { teams: [salesTeam] } as any; + }); + + const result = await service.createOrganization(onboardingWithMixedInvites, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(2); + + // Find org-level invites + const orgInviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === result.organization.id); + + expect(orgInviteCall).toBeDefined(); + expect(orgInviteCall![0].invitations).toEqual([ + { usernameOrEmail: "org1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "org2@example.com", role: MembershipRole.MEMBER }, + ]); + + // Find Sales team invites + const teams = await prismock.team.findMany({ where: { parentId: result.organization.id } }); + const salesTeam = teams.find((t) => t.name === "Sales"); + const salesInviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === salesTeam!.id); + + expect(salesInviteCall).toBeDefined(); + expect(salesInviteCall![0].invitations).toEqual([ + { usernameOrEmail: "sales1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "sales2@example.com", role: MembershipRole.ADMIN }, + ]); + }); + + it("should fall back to org-level invite if teamName does not match any created team", async () => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); + + const mockOrganizationOnboarding = await createTestOnboarding(); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const onboardingWithNonMatchingTeam = { + ...mockOrganizationOnboarding, + teams: [{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null }], + invitedMembers: [ + { email: "user@example.com", teamName: "NonExistentTeam", role: MembershipRole.MEMBER }, + ], + }; + + vi.mocked(createTeamsHandler).mockResolvedValue({ + teams: [{ id: 300, name: "Marketing" }], + } as any); + + const result = await service.createOrganization(onboardingWithNonMatchingTeam, { + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + }); + + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: result.organization.id, + invitations: [{ usernameOrEmail: "user@example.com", role: MembershipRole.MEMBER }], + isDirectUserAction: false, + }) + ); + }); + }); +}); diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts new file mode 100644 index 0000000000..2299ffe0f9 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/OrganizationOnboardingFactory.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { UserPermissionRole } from "@calcom/prisma/enums"; + +const mockRegularUser = { + id: 1, + email: "user@example.com", + role: UserPermissionRole.USER, + name: "Regular User", +}; + +const mockAdminUser = { + id: 2, + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + name: "Admin User", +}; + +describe("OrganizationOnboardingFactory", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + describe("create", () => { + it("should return BillingEnabledOrgOnboardingService for regular user when billing is enabled", async () => { + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: true, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(mockRegularUser as any); + + expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService"); + }); + + it("should return BillingEnabledOrgOnboardingService for admin on hosted (billing enabled)", async () => { + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: true, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(mockAdminUser as any); + + expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService"); + }); + + it("should return SelfHostedOrganizationOnboardingService for admin when IS_TEAM_BILLING_ENABLED is false", async () => { + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: false, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(mockAdminUser as any); + + // Self-hosted admins skip billing + expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService"); + }); + + it("should return SelfHostedOrganizationOnboardingService for regular user when IS_TEAM_BILLING_ENABLED is false", async () => { + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: false, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(mockRegularUser as any); + + // When billing is disabled, everyone uses self-hosted flow + expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService"); + }); + + it("should return SelfHostedOrganizationOnboardingService in E2E mode", async () => { + process.env.NEXT_PUBLIC_IS_E2E = "1"; + + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: true, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(mockAdminUser as any); + + // E2E mode returns false from isBillingEnabled, meaning SelfHosted flow + expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService"); + }); + + it("should return BillingEnabledOrgOnboardingService when billing is enabled for regular user", async () => { + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: true, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(mockRegularUser as any); + + expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService"); + }); + }); + + describe("Factory Decision Matrix", () => { + const testCases = [ + { + scenario: "Hosted (billing enabled) + Regular User", + user: mockRegularUser, + isBillingEnabled: true, + isE2E: false, + expected: "BillingEnabled", + }, + { + scenario: "Hosted (billing enabled) + Admin User", + user: mockAdminUser, + isBillingEnabled: true, + isE2E: false, + expected: "BillingEnabled", + }, + { + scenario: "Self-hosted (billing disabled) + Regular User", + user: mockRegularUser, + isBillingEnabled: false, + isE2E: false, + expected: "SelfHosted", // Everyone uses self-hosted when billing disabled + }, + { + scenario: "Self-hosted (billing disabled) + Admin User", + user: mockAdminUser, + isBillingEnabled: false, + isE2E: false, + expected: "SelfHosted", // Everyone uses self-hosted when billing disabled + }, + { + scenario: "E2E mode + Admin User", + user: mockAdminUser, + isBillingEnabled: true, + isE2E: true, + expected: "SelfHosted", // E2E always uses self-hosted flow + }, + { + scenario: "E2E mode + Regular User", + user: mockRegularUser, + isBillingEnabled: true, + isE2E: true, + expected: "SelfHosted", // E2E always uses self-hosted flow + }, + ]; + + testCases.forEach(({ scenario, user, isBillingEnabled, isE2E, expected }) => { + it(`${scenario} → ${expected}`, async () => { + // Setup environment + if (isE2E) { + process.env.NEXT_PUBLIC_IS_E2E = "1"; + } else { + delete process.env.NEXT_PUBLIC_IS_E2E; + } + + vi.doMock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: isBillingEnabled, + }; + }); + + const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory"); + const service = Factory.create(user as any); + + if (expected === "BillingEnabled") { + expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService"); + } else { + expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService"); + } + }); + }); + }); +}); diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts new file mode 100644 index 0000000000..84bad8b010 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/SelfHostedOnboardingService.test.ts @@ -0,0 +1,523 @@ +import prismock from "../../../../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; +import * as constants from "@calcom/lib/constants"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; +import { UserPermissionRole, CreationSource, MembershipRole, BillingPeriod } from "@calcom/prisma/enums"; +import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; +import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; + +import { SelfHostedOrganizationOnboardingService } from "../SelfHostedOnboardingService"; +import type { CreateOnboardingIntentInput, OrganizationOnboardingData } from "../types"; + +vi.mock("../../OrganizationPaymentService"); +vi.mock("@calcom/lib/server/repository/organizationOnboarding"); +vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({ + LicenseKeySingleton: { + getInstance: vi.fn(), + }, +})); + +vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ + sendEmailVerification: vi.fn(), +})); + +vi.mock("@calcom/emails/email-manager", () => ({ + sendOrganizationCreationEmail: vi.fn(), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({ + inviteMembersWithNoInviterPermissionCheck: vi.fn(), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({ + createTeamsHandler: vi.fn(), +})); + +vi.mock("@calcom/lib/domainManager/organization", () => ({ + createDomain: vi.fn(), +})); + +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: async (locale: string, namespace: string) => { + const t = (key: string) => key; + t.locale = locale; + t.namespace = namespace; + return t; + }, + }; +}); + +const mockAdminUser = { + id: 2, + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + name: "Admin User", +}; + +const mockInput: CreateOnboardingIntentInput = { + name: "Test Organization", + slug: "test-org", + orgOwnerEmail: "owner@example.com", + seats: 10, + pricePerSeat: 20, + isPlatform: false, + creationSource: CreationSource.WEBAPP, + logo: "https://example.com/logo.png", + bio: "Test bio", + brandColor: "#000000", + bannerUrl: "https://example.com/banner.png", + teams: [ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: "Sales", isBeingMigrated: false, slug: null }, + ], + invitedMembers: [ + { email: "member1@example.com", name: "Member 1" }, + { email: "member2@example.com", name: "Member 2" }, + ], +}; + +describe("SelfHostedOrganizationOnboardingService", () => { + let service: SelfHostedOrganizationOnboardingService; + let mockPaymentService: any; + + beforeEach(async () => { + vi.resetAllMocks(); + // @ts-expect-error reset is a method on Prismock + await prismock.reset(); + + // Mock license check + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + mockPaymentService = { + createOrganizationOnboarding: vi.fn().mockResolvedValue({ + id: "onboarding-123", + name: mockInput.name, + slug: mockInput.slug, + orgOwnerEmail: mockInput.orgOwnerEmail, + seats: mockInput.seats, + pricePerSeat: mockInput.pricePerSeat, + billingPeriod: "MONTHLY", + isComplete: false, + stripeCustomerId: null, + }), + }; + + vi.mocked(OrganizationOnboardingRepository.update).mockResolvedValue({ + id: "onboarding-123", + teams: [], + invitedMembers: [], + } as any); + + vi.mocked(OrganizationOnboardingRepository.markAsComplete).mockResolvedValue({} as any); + + service = new SelfHostedOrganizationOnboardingService(mockAdminUser as any, mockPaymentService); + }); + + describe("createOnboardingIntent", () => { + it("should create onboarding record", async () => { + await service.createOnboardingIntent(mockInput); + + expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + name: mockInput.name, + slug: mockInput.slug, + orgOwnerEmail: mockInput.orgOwnerEmail, + createdByUserId: mockAdminUser.id, + }) + ); + }); + + it("should store teams and invites in onboarding record", async () => { + await service.createOnboardingIntent(mockInput); + + // Teams and invites are now passed during creation, not via update + expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + teams: expect.arrayContaining([ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: "Sales", isBeingMigrated: false, slug: null }, + ]), + invitedMembers: expect.arrayContaining([ + { email: "member1@example.com", name: "Member 1" }, + { email: "member2@example.com", name: "Member 2" }, + ]), + }) + ); + }); + + it("should mark onboarding as complete", async () => { + await service.createOnboardingIntent(mockInput); + + expect(OrganizationOnboardingRepository.markAsComplete).toHaveBeenCalledWith("onboarding-123"); + }); + + it("should return organization ID (not checkout URL)", async () => { + const result = await service.createOnboardingIntent(mockInput); + + expect(result.organizationId).toBe(1); + expect(result.checkoutUrl).toBeNull(); + }); + + it("should filter out empty team names", async () => { + const inputWithEmptyTeams = { + ...mockInput, + teams: [ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: " ", isBeingMigrated: false, slug: null }, + { id: -1, name: "", isBeingMigrated: false, slug: null }, + ], + }; + + await service.createOnboardingIntent(inputWithEmptyTeams); + + // Teams are now passed during creation with empty names filtered out + expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }], + }) + ); + }); + + it("should filter out empty invite emails", async () => { + const inputWithEmptyInvites = { + ...mockInput, + invitedMembers: [ + { email: "member1@example.com", name: "Member 1" }, + { email: " ", name: "Empty" }, + { email: "", name: "Empty 2" }, + ], + }; + + await service.createOnboardingIntent(inputWithEmptyInvites); + + // Invites are now passed during creation with empty emails filtered out + expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + invitedMembers: [{ email: "member1@example.com", name: "Member 1" }], + }) + ); + }); + + it("should create organization immediately", async () => { + const result = await service.createOnboardingIntent(mockInput); + + // Organization created immediately, not pending payment + expect(result.organizationId).toBe(1); + expect(result.checkoutUrl).toBeNull(); + }); + + it("should return all required fields", async () => { + const result = await service.createOnboardingIntent(mockInput); + + expect(result).toEqual({ + userId: mockAdminUser.id, + orgOwnerEmail: mockInput.orgOwnerEmail, + name: mockInput.name, + slug: mockInput.slug, + seats: mockInput.seats, + pricePerSeat: mockInput.pricePerSeat, + billingPeriod: mockInput.billingPeriod, + isPlatform: mockInput.isPlatform, + organizationOnboardingId: "onboarding-123", + checkoutUrl: null, + organizationId: 1, + }); + }); + }); + + describe("createOrganization", () => { + beforeEach(() => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true); + }); + + // Helper functions + async function createTestUser(data: { + email: string; + name?: string; + username?: string; + metadata?: any; + onboardingCompleted?: boolean; + emailVerified?: Date | null; + }) { + return prismock.user.create({ + data: { + email: data.email, + name: data.name || "Test User", + username: data.username || "testuser", + metadata: data.metadata || {}, + completedOnboarding: data.onboardingCompleted, + emailVerified: data.emailVerified, + }, + }); + } + + async function createTestTeam(data: { name: string; slug: string }) { + return prismock.team.create({ + data: { + name: data.name, + slug: data.slug, + isOrganization: false, + }, + }); + } + + async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) { + return prismock.membership.create({ + data: { + createdAt: new Date(), + userId: data.userId, + teamId: data.teamId, + role: data.role || MembershipRole.MEMBER, + accepted: true, + }, + }); + } + + const mockOrganizationOnboarding: OrganizationOnboardingData = { + id: "onboarding-123", + organizationId: null, + name: "Test Org", + slug: "test-org", + orgOwnerEmail: "test@example.com", + billingPeriod: BillingPeriod.MONTHLY, + seats: 5, + pricePerSeat: 20, + stripeCustomerId: null, + invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }], + teams: [ + { id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" }, + { id: 2, name: "New Team", isBeingMigrated: false, slug: null }, + ], + logo: null, + bio: null, + brandColor: null, + bannerUrl: null, + isDomainConfigured: false, + isPlatform: false, + }; + + it("should fail if the license is invalid", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(false), + } as any); + + await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow( + "Self hosted license not valid" + ); + }); + + it("should create an organization with a valid license", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + name: "Existing User", + username: "existinguser", + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding); + + expect(organization).toBeDefined(); + expect(organization.name).toBe(mockOrganizationOnboarding.name); + expect(organization.slug).toBe(mockOrganizationOnboarding.slug); + expect(owner).toBeDefined(); + expect(owner.id).toBe(existingUser.id); + }); + + it("should create organization with existing user as owner", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + name: "Existing User", + username: "existinguser", + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding); + + expect(organization).toBeDefined(); + expect(owner.id).toBe(existingUser.id); + expect(owner.email).toBe(existingUser.email); + + // Verify teams and members + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalled(); + expect(createTeamsHandler).toHaveBeenCalled(); + }); + + it("should throw error if organization with same slug exists", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + // Create existing organization with same slug + await prismock.team.create({ + data: { + name: "Conflicting Org", + slug: mockOrganizationOnboarding.slug, + isOrganization: true, + }, + }); + + await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow( + "organization_url_taken" + ); + }); + + it("should create organization with slug set even if there is a team with same slug owned by orgOwner", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + const existingUser = await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const teamWithConflictingSlug = await createTestTeam({ + name: "TestTeamWithConflictingSlug", + slug: mockOrganizationOnboarding.slug, + }); + + // Make the orgOwner a member of the team + await createTestMembership({ + userId: existingUser.id, + teamId: teamWithConflictingSlug.id, + role: MembershipRole.ADMIN, + }); + + const { organization } = await service.createOrganization(mockOrganizationOnboarding); + + expect(organization.slug).toBe(mockOrganizationOnboarding.slug); + }); + + it("should not create organization if there is a team with same slug not owned by orgOwner", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + onboardingCompleted: true, + emailVerified: new Date(), + }); + + await createTestTeam({ + name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner", + slug: mockOrganizationOnboarding.slug, + }); + + // Don't make the orgOwner a member of the team + + await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow( + "organization_url_taken" + ); + }); + + it("should invite members with isDirectUserAction set to false", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + await createTestUser({ + email: mockOrganizationOnboarding.orgOwnerEmail, + username: "org-owner", + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const result = await service.createOrganization(mockOrganizationOnboarding); + + // Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: result.organization.id, + invitations: [ + { usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER }, + ], + isDirectUserAction: false, + }) + ); + }); + + it("should invite members to specific teams based on teamName", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + } as any); + + await createTestUser({ + email: "owner@example.com", + onboardingCompleted: true, + emailVerified: new Date(), + }); + + const onboardingWithTeamInvites: OrganizationOnboardingData = { + ...mockOrganizationOnboarding, + orgOwnerEmail: "owner@example.com", + teams: [ + { id: -1, name: "Marketing", isBeingMigrated: false, slug: null }, + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + ], + invitedMembers: [ + { email: "marketer@example.com", teamName: "marketing", role: "MEMBER" }, + { email: "engineer@example.com", teamName: "engineering", role: "ADMIN" }, + { email: "orguser@example.com" }, + ], + }; + + // Setup: createTeamsHandler will be called and we need teams to exist for inviteMembers + vi.mocked(createTeamsHandler).mockImplementation(async (options) => { + const marketing = await prismock.team.create({ + data: { + name: "Marketing", + slug: "marketing", + parentId: options.input.orgId, + }, + }); + const engineering = await prismock.team.create({ + data: { + name: "Engineering", + slug: "engineering", + parentId: options.input.orgId, + }, + }); + return { teams: [marketing, engineering] } as any; + }); + + const result = await service.createOrganization(onboardingWithTeamInvites); + + // Verify invites were sent to 3 different targets (org + 2 teams) + expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(3); + + // Check org-level invite + const orgInviteCall = vi + .mocked(inviteMembersWithNoInviterPermissionCheck) + .mock.calls.find((call) => call[0].teamId === result.organization.id); + expect(orgInviteCall).toBeDefined(); + expect(orgInviteCall![0].invitations).toEqual([ + { usernameOrEmail: "orguser@example.com", role: MembershipRole.MEMBER }, + ]); + }); + }); +}); diff --git a/packages/features/ee/organizations/lib/service/onboarding/index.ts b/packages/features/ee/organizations/lib/service/onboarding/index.ts new file mode 100644 index 0000000000..dc9f67ba9b --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/index.ts @@ -0,0 +1,5 @@ +export { OrganizationOnboardingFactory } from "./OrganizationOnboardingFactory"; +export type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService"; +export type { CreateOnboardingIntentInput, OnboardingIntentResult } from "./types"; +export { BillingEnabledOrgOnboardingService } from "../service/BillingEnabledOrgOnboardingService"; +export { SelfHostedOrganizationOnboardingService } from "./SelfHostedOnboardingService"; diff --git a/packages/features/ee/organizations/lib/service/onboarding/types.ts b/packages/features/ee/organizations/lib/service/onboarding/types.ts new file mode 100644 index 0000000000..32a68cc2d6 --- /dev/null +++ b/packages/features/ee/organizations/lib/service/onboarding/types.ts @@ -0,0 +1,109 @@ +import type { Prisma } from "@calcom/prisma/client"; +import type { BillingPeriod, CreationSource } from "@calcom/prisma/enums"; + +export type TeamInput = { + id: number; + name: string; + isBeingMigrated: boolean; + slug: string | null; +}; + +export type InvitedMemberInput = { + email: string; + name?: string; + teamId?: number; + teamName?: string; + role?: string; +}; + +export type CreateOnboardingIntentInput = { + name: string; + slug: string; + orgOwnerEmail: string; + seats?: number | null; + pricePerSeat?: number | null; + billingPeriod?: BillingPeriod; + isPlatform: boolean; + creationSource: CreationSource; + logo?: string | null; + bio?: string | null; + brandColor?: string | null; + bannerUrl?: string | null; + teams?: TeamInput[]; + invitedMembers?: InvitedMemberInput[]; + onboardingId?: string; +}; + +export type OnboardingIntentResult = { + userId: number; + orgOwnerEmail: string; + name: string; + slug: string; + seats: number | null; + pricePerSeat: number | null; + billingPeriod?: BillingPeriod; + isPlatform: boolean; + organizationOnboardingId: string; + checkoutUrl: string | null; + organizationId?: number | null; + handoverUrl?: string | null; +}; + +export interface OnboardingUser { + id: number; + email: string; + role: "ADMIN" | "USER"; + name?: string; +} + +export type OrganizationOnboardingData = { + id: string; + organizationId: number | null; + name: string; + slug: string; + orgOwnerEmail: string; + seats: number | null; + pricePerSeat: number | null; + billingPeriod: BillingPeriod; + invitedMembers: Prisma.JsonValue; + teams: Prisma.JsonValue; + isPlatform: boolean; + logo: string | null; + bio: string | null; + brandColor: string | null; + bannerUrl: string | null; + stripeCustomerId: string | null; + isDomainConfigured: boolean; +}; + +export type OrganizationData = { + id: number | null; + name: string; + slug: string; + isOrganizationConfigured: boolean; + isOrganizationAdminReviewed: boolean; + autoAcceptEmail: string; + seats: number | null; + pricePerSeat: number | null; + isPlatform: boolean; + logoUrl: string | null; + bio: string | null; + brandColor: string | null; + bannerUrl: string | null; + billingPeriod?: "MONTHLY" | "ANNUALLY"; +}; + +export type TeamData = { + id: number; + name: string; + isBeingMigrated: boolean; + slug: string | null; +}; + +export type InvitedMember = { + email: string; + name?: string; + teamId?: number; + teamName?: string; + role?: string; +}; diff --git a/packages/features/ee/organizations/lib/utils.ts b/packages/features/ee/organizations/lib/utils.ts index 72180e2852..5f2d778a43 100644 --- a/packages/features/ee/organizations/lib/utils.ts +++ b/packages/features/ee/organizations/lib/utils.ts @@ -6,3 +6,45 @@ export function extractDomainFromEmail(email: string) { } catch (ignore) {} return out.split(".")[0]; } + +/** + * Checks if an email is a company email (not a personal email provider) + */ +export function isCompanyEmail(email: string): boolean { + // A list of popular @domains that are personal email providers + const personalEmailProviders = [ + "gmail.com", + "yahoo.com", + "outlook.com", + "hotmail.com", + "aol.com", + "icloud.com", + "mail.com", + "protonmail.com", + "proton.me", + "zoho.com", + "yandex.com", + "gmx.com", + "fastmail.com", + "inbox.com", + "me.com", + "hushmail.com", + "live.com", + "rediffmail.com", + "tutanota.com", + "mail.ru", + "usa.com", + "qq.com", + "163.com", + "web.de", + "rocketmail.com", + "excite.com", + "lycos.com", + "outlook.co", + "hotmail.co.uk", + ]; + + const emailParts = email.split("@"); + if (emailParts.length < 2) return false; + return !personalEmailProviders.includes(emailParts[1].toLowerCase()); +} diff --git a/packages/features/ee/organizations/types/schemas.ts b/packages/features/ee/organizations/types/schemas.ts index 27c0155a20..2df5e8e1ee 100644 --- a/packages/features/ee/organizations/types/schemas.ts +++ b/packages/features/ee/organizations/types/schemas.ts @@ -7,6 +7,8 @@ export const createOrganizationSchema = z.object({ language: z.string().optional(), logo: z.string().nullish(), bio: z.string().nullish(), + brandColor: z.string().nullish(), + bannerUrl: z.string().nullish(), onboardingId: z.string(), invitedMembers: orgOnboardingInvitedMembersSchema.optional(), teams: orgOnboardingTeamsSchema.optional(), diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 81baf16ca5..79582d7ac5 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -28,6 +28,7 @@ export type AppFlags = { "tiered-support-chat": boolean; "calendar-subscription-cache": boolean; "calendar-subscription-sync": boolean; + "onboarding-v3": boolean; "booker-botid": boolean; }; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index b8c311a33f..05ce9e61ab 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -27,6 +27,7 @@ const initialData: AppFlags = { "tiered-support-chat": false, "calendar-subscription-cache": false, "calendar-subscription-sync": false, + "onboarding-v3": false, "booker-botid": false, }; diff --git a/packages/lib/server/repository/organizationOnboarding.ts b/packages/lib/server/repository/organizationOnboarding.ts index 59a6598d79..d7998bae96 100644 --- a/packages/lib/server/repository/organizationOnboarding.ts +++ b/packages/lib/server/repository/organizationOnboarding.ts @@ -16,6 +16,8 @@ export type CreateOrganizationOnboardingInput = { slug: string; logo?: string | null; bio?: string | null; + brandColor?: string | null; + bannerUrl?: string | null; stripeCustomerId?: string; stripeSubscriptionId?: string; stripeSubscriptionItemId?: string; @@ -41,6 +43,8 @@ export class OrganizationOnboardingRepository { slug: data.slug, logo: data.logo, bio: data.bio, + brandColor: data.brandColor, + bannerUrl: data.bannerUrl, stripeCustomerId: data.stripeCustomerId, stripeSubscriptionId: data.stripeSubscriptionId, invitedMembers: data.invitedMembers || [], diff --git a/packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql b/packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql new file mode 100644 index 0000000000..6aff33cc8f --- /dev/null +++ b/packages/prisma/migrations/20251005102651_add_onboarding_v3_feature_flag/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'onboarding-v3', + false, + 'Enable onboarding v3 for all users', + 'EXPERIMENT' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql b/packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql new file mode 100644 index 0000000000..a48fbd08f9 --- /dev/null +++ b/packages/prisma/migrations/20251007090722_modify_onboarding_table_orgs/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "OrganizationOnboarding" ADD COLUMN "bannerUrl" TEXT, +ADD COLUMN "brandColor" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 463d2ed3ec..0b2d04e7e5 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2424,6 +2424,8 @@ model OrganizationOnboarding { slug String logo String? bio String? + brandColor String? + bannerUrl String? isDomainConfigured Boolean @default(false) // Set when payment intent is there. diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 8741f38f62..ad0ee178c9 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -58,7 +58,13 @@ export const bookerLayouts = z .nullable(); export const orgOnboardingInvitedMembersSchema = z.array( - z.object({ email: z.string().email(), name: z.string().optional() }) + z.object({ + email: z.string().email(), + name: z.string().optional(), + teamId: z.number().optional(), + teamName: z.string().optional(), + role: z.enum(["MEMBER", "ADMIN"]).optional().default("MEMBER"), + }) ); export const orgOnboardingTeamsSchema = z.array( diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index ff6e98fe15..5b0f09d929 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -1,6 +1,7 @@ import { lookup } from "dns"; import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { isNotACompanyEmail } from "@calcom/ee/organizations/lib/server/orgCreationUtils"; import { sendAdminOrganizationNotification, sendOrganizationCreationEmail } from "@calcom/emails"; import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; @@ -23,47 +24,6 @@ import type { TrpcSessionUser } from "../../../types"; import { BillingPeriod } from "./create.schema"; import type { TCreateInputSchema } from "./create.schema"; -/** - * We can only say for sure that the email is not a company email. We can't say for sure if it is a company email. - */ -function isNotACompanyEmail(email: string) { - // A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization - const emailProviders = [ - "gmail.com", - "yahoo.com", - "outlook.com", - "hotmail.com", - "aol.com", - "icloud.com", - "mail.com", - "protonmail.com", - "zoho.com", - "yandex.com", - "gmx.com", - "fastmail.com", - "inbox.com", - "me.com", - "hushmail.com", - "live.com", - "rediffmail.com", - "tutanota.com", - "mail.ru", - "usa.com", - "qq.com", - "163.com", - "web.de", - "rocketmail.com", - "excite.com", - "lycos.com", - "outlook.co", - "hotmail.co.uk", - ]; - - const emailParts = email.split("@"); - if (emailParts.length < 2) return true; - return emailProviders.includes(emailParts[1]); -} - type CreateOptions = { ctx: { user: NonNullable; diff --git a/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts index 5374315d9f..6a3b418be6 100644 --- a/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts @@ -1,4 +1,4 @@ -import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding"; +import { SelfHostedOrganizationOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService"; import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; @@ -45,24 +45,32 @@ export const createSelfHostedHandler = async ({ input, ctx }: CreateSelfHostedOp }); } - const { organization } = await createOrganizationFromOnboarding({ - organizationOnboarding: { - id: input.onboardingId, - logo: input.logo ?? null, - bio: input.bio ?? null, - invitedMembers: input.invitedMembers ?? [], - teams: input.teams ?? [], - orgOwnerEmail: organizationOnboarding.orgOwnerEmail, - slug: organizationOnboarding.slug, - name: organizationOnboarding.name, - billingPeriod: organizationOnboarding.billingPeriod, - seats: organizationOnboarding.seats, - pricePerSeat: organizationOnboarding.pricePerSeat, - stripeCustomerId: organizationOnboarding.stripeCustomerId, - isPlatform: organizationOnboarding.isPlatform, - isDomainConfigured: organizationOnboarding.isDomainConfigured, - organizationId: organizationOnboarding.organizationId, - }, + const userContext = { + id: ctx.user.id, + email: ctx.user.email, + role: ctx.user.role as "ADMIN" | "USER", + name: ctx.user.name || undefined, + }; + + const onboardingService = new SelfHostedOrganizationOnboardingService(userContext); + const { organization } = await onboardingService.createOrganization({ + id: input.onboardingId, + logo: input.logo ?? null, + bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, + invitedMembers: input.invitedMembers ?? [], + teams: input.teams ?? [], + orgOwnerEmail: organizationOnboarding.orgOwnerEmail, + slug: organizationOnboarding.slug, + name: organizationOnboarding.name, + billingPeriod: organizationOnboarding.billingPeriod, + seats: organizationOnboarding.seats, + pricePerSeat: organizationOnboarding.pricePerSeat, + stripeCustomerId: organizationOnboarding.stripeCustomerId, + isPlatform: organizationOnboarding.isPlatform, + isDomainConfigured: organizationOnboarding.isDomainConfigured, + organizationId: organizationOnboarding.organizationId, }); await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id); diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts index 3bc6fff30d..03303d271a 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.ts @@ -1,3 +1,12 @@ +/** + * @deprecated This endpoint is deprecated. Use `intentToCreateOrg` instead, which now handles + * teams and invites in a single mutation call. This endpoint will be removed in a future release. + * + * Migration guide: + * - Instead of calling `intentToCreateOrg` followed by `createWithPaymentIntent`, + * pass teams and invitedMembers directly to `intentToCreateOrg` + * - The new endpoint returns checkoutUrl directly in the response + */ import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -15,8 +24,20 @@ type CreateOptions = { input: TCreateWithPaymentIntentInputSchema; }; const log = logger.getSubLogger({ prefix: ["viewer", "organizations", "createWithPaymentIntent"] }); + +/** + * @deprecated Use intentToCreateOrg with teams and invitedMembers instead + */ export const createHandler = async ({ input, ctx }: CreateOptions) => { - const paymentService = new OrganizationPaymentService(ctx.user); + log.warn( + "DEPRECATED: createWithPaymentIntent is deprecated. Use intentToCreateOrg with teams and invitedMembers instead." + ); + const paymentService = new OrganizationPaymentService({ + id: ctx.user.id, + email: ctx.user.email, + role: ctx.user.role, + name: ctx.user.name ?? undefined, + }); const isAdmin = ctx.user.role === "ADMIN"; // Regular user can send onboardingId if the onboarding was started by ADMIN/someone else and they shared the link with them. // ADMIN flow doesn't send onboardingId @@ -57,6 +78,8 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { ...input, logo: input.logo ?? null, bio: input.bio ?? null, + brandColor: input.brandColor ?? null, + bannerUrl: input.bannerUrl ?? null, }, organizationOnboarding ); diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts index b3c6747236..7ee2074d52 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts @@ -1,3 +1,8 @@ +/** + * @deprecated This schema is deprecated. Use ZIntentToCreateOrgInputSchema instead. + * See createWithPaymentIntent.handler.ts for migration guide. + */ + import type { z } from "zod"; import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas"; @@ -7,6 +12,9 @@ export enum BillingPeriod { ANNUALLY = "ANNUALLY", } +/** + * @deprecated Use ZIntentToCreateOrgInputSchema instead + */ export const ZCreateWithPaymentIntentInputSchema = createOrganizationSchema; export type TCreateWithPaymentIntentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts index e7753efa26..f1d5834c5a 100644 --- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts @@ -3,7 +3,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; -import { UserPermissionRole } from "@calcom/prisma/enums"; +import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService"; +import { BillingPeriod, UserPermissionRole, CreationSource } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -15,6 +16,8 @@ vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({ }, })); +vi.mock("@calcom/features/ee/organizations/lib/OrganizationPaymentService"); + const mockInput = { name: "Test Org", slug: "test-org", @@ -23,6 +26,7 @@ const mockInput = { seats: 5, pricePerSeat: 20, isPlatform: false, + creationSource: "WEBAPP", }; // Helper functions for creating test data @@ -49,8 +53,37 @@ async function createTestUser(data: { describe("intentToCreateOrgHandler", () => { beforeEach(async () => { vi.resetAllMocks(); - // @ts-expect-error reset is a method on Prismock await prismock.reset(); + + vi.mocked(OrganizationPaymentService).mockImplementation(() => { + return { + createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => { + return await prismock.organizationOnboarding.create({ + data: { + id: "onboarding-123", + name: data.name, + slug: data.slug, + orgOwnerEmail: data.orgOwnerEmail, + seats: data.seats ?? 10, + pricePerSeat: data.pricePerSeat ?? 15, + billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY, + isComplete: false, + stripeCustomerId: null, + createdById: data.createdByUserId, + teams: data.teams ?? [], + invitedMembers: data.invitedMembers ?? [], + isPlatform: data.isPlatform ?? false, + }, + }); + }), + createPaymentIntent: vi.fn().mockResolvedValue({ + checkoutUrl: "https://stripe.com/checkout/session", + organizationOnboarding: {}, + subscription: {}, + sessionId: "session-123", + }), + } as any; + }); }); describe("hosted", () => { @@ -64,7 +97,10 @@ describe("intentToCreateOrgHandler", () => { }); vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ checkLicense: vi.fn().mockResolvedValue(true), + incrementUsage: vi.fn().mockResolvedValue(undefined), }); + process.env.STRIPE_ORG_PRODUCT_ID = "prod_test123"; + process.env.STRIPE_ORG_MONTHLY_PRICE_ID = "price_test123"; }); it("should allow admin user to create org for another user", async () => { @@ -88,7 +124,7 @@ describe("intentToCreateOrgHandler", () => { }, }); - // Verify the result shape + // Admin creating for different user with no teams/invites triggers handover expect(result).toEqual({ userId: expect.any(Number), orgOwnerEmail: mockInput.orgOwnerEmail, @@ -99,6 +135,9 @@ describe("intentToCreateOrgHandler", () => { billingPeriod: mockInput.billingPeriod, isPlatform: mockInput.isPlatform, organizationOnboardingId: expect.any(String), + checkoutUrl: null, + organizationId: null, // Not created yet - handover flow + handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="), }); // Verify organization onboarding was created @@ -193,7 +232,7 @@ describe("intentToCreateOrgHandler", () => { ); }); - it("should throw error when organization onboarding already exists", async () => { + it("should throw error when organization onboarding already exists and is complete", async () => { // Create admin user const adminUser = await createTestUser({ email: "admin@example.com", @@ -207,7 +246,7 @@ describe("intentToCreateOrgHandler", () => { emailVerified: new Date(), }); - // Create existing organization onboarding + // Create existing COMPLETE organization onboarding await prismock.organizationOnboarding.create({ data: { name: mockInput.name, @@ -217,6 +256,7 @@ describe("intentToCreateOrgHandler", () => { seats: mockInput.seats, pricePerSeat: mockInput.pricePerSeat, createdById: adminUser.id, + isComplete: true, // Must be complete to throw error }, }); @@ -249,6 +289,7 @@ describe("intentToCreateOrgHandler", () => { it("should throw error when license is not valid", async () => { vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ checkLicense: vi.fn().mockResolvedValue(false), + incrementUsage: vi.fn().mockResolvedValue(undefined), }); const adminUser = await createTestUser({ email: "admin@example.com", @@ -268,6 +309,7 @@ describe("intentToCreateOrgHandler", () => { it("should allow admin user to create org for another user", async () => { vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ checkLicense: vi.fn().mockResolvedValue(true), + incrementUsage: vi.fn().mockResolvedValue(undefined), }); // Create admin user const adminUser = await createTestUser({ @@ -289,7 +331,7 @@ describe("intentToCreateOrgHandler", () => { }, }); - // Verify the result shape + // Admin creating for different user with no teams/invites triggers handover (same as hosted) expect(result).toEqual({ userId: expect.any(Number), orgOwnerEmail: mockInput.orgOwnerEmail, @@ -300,6 +342,9 @@ describe("intentToCreateOrgHandler", () => { billingPeriod: mockInput.billingPeriod, isPlatform: mockInput.isPlatform, organizationOnboardingId: expect.any(String), + checkoutUrl: null, + organizationId: null, // Not created yet - handover flow + handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="), }); // Verify organization onboarding was created @@ -314,5 +359,119 @@ describe("intentToCreateOrgHandler", () => { expect(organizationOnboarding?.slug).toBe(mockInput.slug); expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail); }); + + it("should handle teams and invites in the request", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + incrementUsage: vi.fn().mockResolvedValue(undefined), + }); + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); + + await createTestUser({ + email: mockInput.orgOwnerEmail, + completedOnboarding: true, + emailVerified: new Date(), + }); + + const inputWithTeamsAndInvites = { + ...mockInput, + teams: [ + { id: -1, name: "Engineering", isBeingMigrated: false, slug: null }, + { id: -1, name: "Sales", isBeingMigrated: false, slug: null }, + ], + invitedMembers: [ + { email: "member1@example.com", name: "Member 1" }, + { email: "member2@example.com", name: "Member 2" }, + ], + }; + + const result = await intentToCreateOrgHandler({ + input: inputWithTeamsAndInvites, + ctx: { + user: adminUser, + }, + }); + + expect(result.organizationOnboardingId).toBeDefined(); + + const organizationOnboarding = await prismock.organizationOnboarding.findFirst({ + where: { + slug: mockInput.slug, + }, + }); + + expect(organizationOnboarding).toBeDefined(); + expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams); + expect(organizationOnboarding?.invitedMembers).toEqual(inputWithTeamsAndInvites.invitedMembers); + }); + + it("should preserve teamName, teamId, and role in invites payload", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + incrementUsage: vi.fn().mockResolvedValue(undefined), + }); + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); + + await createTestUser({ + email: mockInput.orgOwnerEmail, + completedOnboarding: true, + emailVerified: new Date(), + }); + + // This matches the exact payload from the frontend + const inputWithTeamsAndInvites = { + ...mockInput, + teams: [ + { id: -1, name: "New", isBeingMigrated: false, slug: null }, + { id: -1, name: "team", isBeingMigrated: false, slug: null }, + ], + invitedMembers: [ + { email: "new@new.com", teamName: "new", teamId: -1, role: "ADMIN" }, + { email: "team@new.com", teamName: "team", teamId: -1, role: "ADMIN" }, + ], + }; + + const result = await intentToCreateOrgHandler({ + input: inputWithTeamsAndInvites, + ctx: { + user: adminUser, + }, + }); + + expect(result.organizationOnboardingId).toBeDefined(); + + const organizationOnboarding = await prismock.organizationOnboarding.findFirst({ + where: { + slug: mockInput.slug, + }, + }); + + expect(organizationOnboarding).toBeDefined(); + expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams); + + // Verify invitedMembers are stored with all fields including teamName, teamId, and role + expect(organizationOnboarding?.invitedMembers).toBeDefined(); + expect(organizationOnboarding?.invitedMembers).toHaveLength(2); + + const invitedMembers = organizationOnboarding?.invitedMembers as any[]; + expect(invitedMembers[0]).toMatchObject({ + email: "new@new.com", + teamName: "new", + teamId: -1, + role: "ADMIN", + }); + expect(invitedMembers[1]).toMatchObject({ + email: "team@new.com", + teamName: "team", + teamId: -1, + role: "ADMIN", + }); + }); }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts index b61500d926..50ac1a837f 100644 --- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts @@ -1,5 +1,5 @@ import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; -import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService"; +import { OrganizationOnboardingFactory } from "@calcom/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory"; import { assertCanCreateOrg, findUserToBeOrgOwner, @@ -27,7 +27,7 @@ type CreateOptions = { }; export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => { - const { slug, name, orgOwnerEmail, seats, pricePerSeat, billingPeriod, isPlatform } = input; + const { slug, name, orgOwnerEmail, isPlatform } = input; log.debug( "Starting organization creation intent", safeStringify({ slug, name, orgOwnerEmail, isPlatform }) @@ -41,7 +41,6 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => if (!hasValidLicense) { throw new TRPCError({ code: "BAD_REQUEST", - // TODO: We need to send translation keys from here and frontend should translate it message: "License is not valid", }); } @@ -75,9 +74,24 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => } log.debug("Found organization owner", safeStringify({ orgOwnerId: orgOwner.id, email: orgOwner.email })); - let organizationOnboarding = await OrganizationOnboardingRepository.findByOrgOwnerEmail(orgOwner.email); + const organizationOnboarding = await OrganizationOnboardingRepository.findByOrgOwnerEmail(orgOwner.email); + + // If onboarding exists and is incomplete, this is a resume flow (e.g., admin handover) + // Allow proceeding with the existing onboarding record if (organizationOnboarding) { - throw new Error("organization_onboarding_already_exists"); + if (organizationOnboarding.isComplete) { + // Organization already created - shouldn't create another one + throw new Error("organization_onboarding_already_exists"); + } + + // Incomplete onboarding exists - this is expected for resume/handover flows + log.debug( + "Found incomplete onboarding record - proceeding with resume flow", + safeStringify({ onboardingId: organizationOnboarding.id, slug }) + ); + + // Use existing onboarding ID for the resume flow + input.onboardingId = organizationOnboarding.id; } await assertCanCreateOrg({ @@ -87,24 +101,16 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => restrictBasedOnMinimumPublishedTeams: !IS_USER_ADMIN, }); - const paymentService = new OrganizationPaymentService(ctx.user); - organizationOnboarding = await paymentService.createOrganizationOnboarding({ - ...input, - createdByUserId: loggedInUser.id, + const onboardingService = OrganizationOnboardingFactory.create({ + id: ctx.user.id, + email: ctx.user.email, + role: ctx.user.role, }); + const result = await onboardingService.createOnboardingIntent(input); log.debug("Organization creation intent successful", safeStringify({ slug, orgOwnerId: orgOwner.id })); - return { - userId: orgOwner.id, - orgOwnerEmail, - name, - slug, - seats, - pricePerSeat, - billingPeriod, - isPlatform, - organizationOnboardingId: organizationOnboarding.id, - }; + + return result; }; export default intentToCreateOrgHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts index 3c4a9fdd24..874c237920 100644 --- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.schema.ts @@ -2,12 +2,8 @@ import { z } from "zod"; import { emailSchema } from "@calcom/lib/emailSchema"; import slugify from "@calcom/lib/slugify"; -import { CreationSource } from "@calcom/prisma/enums"; - -export enum BillingPeriod { - MONTHLY = "MONTHLY", - ANNUALLY = "ANNUALLY", -} +import { BillingPeriod, CreationSource } from "@calcom/prisma/enums"; +import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils"; export const ZIntentToCreateOrgInputSchema = z.object({ name: z.string(), @@ -19,6 +15,16 @@ export const ZIntentToCreateOrgInputSchema = z.object({ isPlatform: z.boolean().default(false), billingPeriod: z.nativeEnum(BillingPeriod).default(BillingPeriod.MONTHLY), creationSource: z.nativeEnum(CreationSource), + // Brand fields + logo: z.string().nullish(), + bio: z.string().nullish(), + brandColor: z.string().nullish(), + bannerUrl: z.string().nullish(), + // Teams and invites (new) + teams: orgOnboardingTeamsSchema.optional(), + invitedMembers: orgOnboardingInvitedMembersSchema.optional(), + // Optional onboarding ID for resume flows (admin handover) + onboardingId: z.string().optional(), }); export type TIntentToCreateOrgInputSchema = z.infer;