From 6ed197d35c1d9b17248bbee1d0f746d79098d847 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 25 Jun 2025 09:58:18 -0400 Subject: [PATCH] fix: Org creation for self-hosters (#21994) * Restart form if admin is not org admin * Show pricing is admin and hosted * Only show billing specific fields when enabled * Create user onboarding only if valid valid license when self hosting * Reset store when org creation is completed * Create schema for trpc input when creating an org * Add `organization.createSelfHosted` trpc endpoint * `createOrganizationFromOnboarding` do not require stripe data * Create organization without billing if self-hosted admin * Update organizationOnboarding record as completed * Handle updating subscription for self hosters * Refactor `isBillingEnabled` to store * Type fix * Fix admin org creation for hosted * Hide UI from admin * Add tests * fix: Fix LicenseKeySingleton mocking in organization tests - Add proper vi.mock() module-level mocking for LicenseKeySingleton - Replace incorrect casting syntax with vi.mocked() approach - Add LicenseKeySingleton mock to hosted tests in intentToCreateOrg.handler.test.ts - Fix IS_SELF_HOSTED mocking for hosted vs self-hosted test scenarios - All organization creation tests now pass locally Co-Authored-By: joe@cal.com * Fix tests * Fix tests * Test fixes * Type fix * Type fix * Type fix * Update packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts Fix typo Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: Add IS_SELF_HOSTED mocking for E2E organization creation tests Co-Authored-By: joe@cal.com * Fix E2E test * Address feedback * Type fix * Fix e2e tests * Remove unused code * Small fixes * fix unit tests * review fixes --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: joe@cal.com Co-authored-by: Benny Joo Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Hariom --- .../new/_components/OnboardMembersView.tsx | 53 +- .../organization/organization-creation.e2e.ts | 4 +- .../src/__tests__/embed-iframe.test.ts | 5 + .../components/CreateANewOrganizationForm.tsx | 17 +- .../ee/organizations/lib/onboardingStore.ts | 26 +- .../createOrganizationFromOnboarding.test.ts | 552 ++++++++++-------- .../createOrganizationFromOnboarding.ts | 45 +- .../ee/organizations/types/schemas.ts | 13 + .../repository/organizationOnboarding.ts | 6 +- .../routers/viewer/organizations/_router.tsx | 5 + .../organizations/createSelfHosted.handler.ts | 75 +++ .../organizations/createSelfHosted.schema.ts | 7 + .../createWithPaymentIntent.schema.ts | 14 +- .../intentToCreateOrg.handler.test.ts | 393 ++++++++----- .../intentToCreateOrg.handler.ts | 18 + 15 files changed, 802 insertions(+), 431 deletions(-) create mode 100644 packages/features/ee/organizations/types/schemas.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/createSelfHosted.schema.ts diff --git a/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx b/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx index e2486c8071..51d381f395 100644 --- a/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx +++ b/apps/web/modules/settings/organizations/new/_components/OnboardMembersView.tsx @@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc"; import { trpc } from "@calcom/trpc"; -import { Badge } from "@calcom/ui/components/badge"; -import { Tooltip } from "@calcom/ui/components/tooltip"; -import { TextField } from "@calcom/ui/components/form"; import { Alert } from "@calcom/ui/components/alert"; import { Avatar } from "@calcom/ui/components/avatar"; +import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; +import { TextField } from "@calcom/ui/components/form"; import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number]; @@ -29,10 +29,15 @@ const AddNewTeamMembers = () => { return ; }; -const useCheckout = () => { +const useOrgCreation = () => { const { t } = useLocale(); + const session = useSession(); + const utils = trpc.useUtils(); const [serverErrorMessage, setServerErrorMessage] = useState(""); - const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({ + const { useOnboardingStore, isBillingEnabled } = useOnboarding(); + const { reset } = useOnboardingStore(); + + const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({ onSuccess: (data) => { if (data.checkoutUrl) { window.location.href = data.checkoutUrl; @@ -43,17 +48,34 @@ const useCheckout = () => { }, }); + const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({ + onSuccess: async (data) => { + if (data.organization) { + // Invalidate the organizations query to ensure fresh data on the next page + await utils.viewer.organizations.listCurrent.invalidate(); + await session.update(); + reset(); + window.location.href = `${window.location.origin}/settings/organizations/profile`; + } + }, + onError: (error) => { + setServerErrorMessage(t(error.message)); + }, + }); + + const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation; + return { - mutation, - mutate: mutation.mutate, - isPending: mutation.isPending, + mutation: mutationToUse, + mutate: mutationToUse.mutate, + isPending: mutationToUse.isPending, errorMessage: serverErrorMessage, }; }; export const AddNewTeamMembersForm = () => { const { t } = useLocale(); - const { useOnboardingStore } = useOnboarding(); + const { useOnboardingStore, isBillingEnabled } = useOnboarding(); const { addInvitedMember, removeInvitedMember, @@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => { bio, onboardingId, } = useOnboardingStore(); - const checkout = useCheckout(); + const orgCreation = useOrgCreation(); const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id); @@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => { return ( <> - {checkout.errorMessage && ( + {orgCreation.errorMessage && (
- +
)}
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => { )}
+
diff --git a/apps/web/playwright/organization/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts index f93bb861d9..5fb6d0ca7b 100644 --- a/apps/web/playwright/organization/organization-creation.e2e.ts +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -134,7 +134,7 @@ test.describe("Organization", () => { await test.step("Basic info", async () => { // Check required fields await page.locator("button[type=submit]").click(); - await expect(page.getByTestId("field-error")).toHaveCount(3); + await expect(page.getByTestId("field-error")).toHaveCount(2); // Happy path await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug); @@ -168,7 +168,7 @@ test.describe("Organization", () => { await test.step("Basic info", async () => { // Check required fields await page.locator("button[type=submit]").click(); - await expect(page.getByTestId("field-error")).toHaveCount(3); + await expect(page.getByTestId("field-error")).toHaveCount(2); // Happy path await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug); diff --git a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts index f2598d6b44..41b6a1d442 100644 --- a/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts +++ b/packages/embeds/embed-core/src/__tests__/embed-iframe.test.ts @@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = { params?: Record; }; +beforeEach(() => { + // Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down + vi.useFakeTimers(); +}); + function fakeCurrentDocumentUrl({ origin = "https://example.com", path = "", diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index ee63ad4bdf..c4d8926d9f 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -7,7 +7,7 @@ import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { MINIMUM_NUMBER_OF_ORG_SEATS } from "@calcom/lib/constants"; +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"; @@ -55,8 +55,9 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure(null); const isAdmin = session.data.user.role === UserPermissionRole.ADMIN; - const defaultOrgOwnerEmail = session.data.user.email ?? ""; - const { useOnboardingStore } = useOnboarding({ step: "start" }); + // Let self-hosters create an organization with their own email. Hosted's Admin already has an organization for their email + const defaultOrgOwnerEmail = (!isAdmin || IS_SELF_HOSTED ? session.data.user.email : null) ?? ""; + const { useOnboardingStore, isBillingEnabled } = useOnboarding({ step: "start" }); const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } = useOnboardingStore(); @@ -71,7 +72,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure )} - {isAdmin && ( + {isBillingEnabled && isAdmin && (
- {isAdmin && ( + {isBillingEnabled && isAdmin && ( <>
@@ -305,7 +306,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure

diff --git a/packages/features/ee/organizations/lib/onboardingStore.ts b/packages/features/ee/organizations/lib/onboardingStore.ts index aa590d19d6..3dd9210aca 100644 --- a/packages/features/ee/organizations/lib/onboardingStore.ts +++ b/packages/features/ee/organizations/lib/onboardingStore.ts @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL, IS_SELF_HOSTED } from "@calcom/lib/constants"; import { UserPermissionRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; @@ -102,6 +102,10 @@ export const useOnboardingStore = create()( if (state) { set(state); } else { + // Clear the localStorage entry for this store + if (typeof window !== "undefined") { + localStorage.removeItem("org-creation-onboarding"); + } set(initialState); } }, @@ -117,6 +121,7 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) => const router = useRouter(); const path = usePathname(); const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN; + const isBillingEnabled = !(IS_SELF_HOSTED && isAdmin) || process.env.NEXT_PUBLIC_IS_E2E; const searchParams = useSearchParams(); const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } = trpc.viewer.organizations.getOrganizationOnboarding.useQuery(); @@ -127,6 +132,10 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) => 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) { @@ -143,6 +152,9 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) => bio: organizationOnboarding.bio, logo: organizationOnboarding.logo, }); + if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) { + reset(); + } } } else { // First step doesn't require onboardingId @@ -154,11 +166,6 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) => router.push("/settings/organizations/new"); } } - - // Admin must start fresh, so that they can create organization with any email and data doesn't mix from different org creations - if (isAdmin) { - reset(); - } }, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]); useEffect(() => { @@ -170,5 +177,10 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) => router.push(`/auth/login?callbackUrl=${WEBAPP_URL}${path}${searchString ? `?${searchString}` : ""}`); } }, [session, router, path, searchParams]); - return { useOnboardingStore, isLoadingOrgOnboarding, dbOnboarding: organizationOnboarding }; + return { + useOnboardingStore, + isLoadingOrgOnboarding, + dbOnboarding: organizationOnboarding, + isBillingEnabled, + }; }; diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts index 0f6e8d4500..f32cdfed2f 100644 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts +++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts @@ -6,13 +6,22 @@ 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(), @@ -147,6 +156,19 @@ async function createTestMembership(data: { userId: number; teamId: number; role }); } +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(); @@ -154,260 +176,326 @@ describe("createOrganizationFromOnboarding", () => { await prismock.reset(); }); - // 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", + describe("hosted", () => { + beforeEach(() => { + vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false); }); - // 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", + // 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); }); - // 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: { + 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", - email: "test@example.com", - }, - organizationOnboarding: { - organizationId: existingOrg.id, + 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 { organization } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); + const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ + user: { + name: "Existing User", + username: "existinguser", + email: "test@example.com", + }, + organizationOnboarding: { + organizationId: existingOrg.id, + name: orgName, + slug: orgSlug, + }, + }); - // 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({ + const { organization } = await 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", + // Verify the existing organization was reused + expect(organization.id).toBe(existingOrg.id); }); - const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ - user: { - email: "test@example.com", - }, - organizationOnboarding: { - slug: "UPPERCASE-SLUG", - teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }], - }, + 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"); }); - const { organization } = await createOrganizationFromOnboarding({ - organizationOnboarding, - paymentSubscriptionId: "mock_subscription_id", - paymentSubscriptionItemId: "mock_subscription_item_id", - }); + it("should create team with slugified slug", async () => { + const teamToMove = await createTestTeam({ + name: "Sales", + slug: "Company Sales", + }); - expect(createTeamsHandler).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - orgId: organization.id, - teamNames: [], - moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]), - }), - }) - ); - }); + const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({ + user: { + email: "test@example.com", + }, + organizationOnboarding: { + slug: "UPPERCASE-SLUG", + teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }], + }, + }); - 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({ + const { organization } = await createOrganizationFromOnboarding({ organizationOnboarding, paymentSubscriptionId: "mock_subscription_id", paymentSubscriptionItemId: "mock_subscription_item_id", - }) - ).rejects.toThrow("organization_url_taken"); + }); + + expect(createTeamsHandler).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + orgId: organization.id, + teamNames: [], + moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]), + }), + }) + ); + }); + + 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 index 6bb139e631..e697fcc24c 100644 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts +++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts @@ -1,5 +1,6 @@ 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"; @@ -10,9 +11,11 @@ import { } 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/lib/server/repository/organization"; import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; import { UserRepository } from "@calcom/lib/server/repository/user"; @@ -333,9 +336,13 @@ async function backwardCompatibilityForSubscriptionDetails({ id: number; metadata: Prisma.JsonValue; }; - paymentSubscriptionId: string; - paymentSubscriptionItemId: string; + paymentSubscriptionId?: string; + paymentSubscriptionItemId?: string; }) { + if (!paymentSubscriptionId || !paymentSubscriptionItemId) { + return organization; + } + const existingMetadata = teamMetadataSchema.parse(organization.metadata); const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({ id: organization.id, @@ -387,9 +394,19 @@ async function handleOrganizationCreation({ }: { organizationOnboarding: OrganizationOnboardingArg; owner: OrgOwner; - paymentSubscriptionId: string; - paymentSubscriptionItemId: string; + 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, @@ -468,8 +485,8 @@ export const createOrganizationFromOnboarding = async ({ paymentSubscriptionItemId, }: { organizationOnboarding: OrganizationOnboardingArg; - paymentSubscriptionId: string; - paymentSubscriptionItemId: string; + paymentSubscriptionId?: string; + paymentSubscriptionItemId?: string; }) => { log.info( "createOrganizationFromOnboarding", @@ -478,6 +495,11 @@ export const createOrganizationFromOnboarding = async ({ 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, @@ -491,10 +513,13 @@ export const createOrganizationFromOnboarding = async ({ const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail); const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common"); - await handleDomainSetup({ - organizationOnboarding, - orgOwnerTranslation, - }); + // 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, diff --git a/packages/features/ee/organizations/types/schemas.ts b/packages/features/ee/organizations/types/schemas.ts new file mode 100644 index 0000000000..27c0155a20 --- /dev/null +++ b/packages/features/ee/organizations/types/schemas.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils"; + +// Base user schema - fields that any user can set +export const createOrganizationSchema = z.object({ + language: z.string().optional(), + logo: z.string().nullish(), + bio: z.string().nullish(), + onboardingId: z.string(), + invitedMembers: orgOnboardingInvitedMembersSchema.optional(), + teams: orgOnboardingTeamsSchema.optional(), +}); diff --git a/packages/lib/server/repository/organizationOnboarding.ts b/packages/lib/server/repository/organizationOnboarding.ts index 9a5c7de8ba..59a6598d79 100644 --- a/packages/lib/server/repository/organizationOnboarding.ts +++ b/packages/lib/server/repository/organizationOnboarding.ts @@ -23,6 +23,7 @@ export type CreateOrganizationOnboardingInput = { teams?: { id: number; name: string; isBeingMigrated: boolean; slug: string | null }[]; error?: string | null; isDomainConfigured?: boolean; + isComplete?: boolean; }; export class OrganizationOnboardingRepository { @@ -95,13 +96,16 @@ export class OrganizationOnboardingRepository { static async update(id: OnboardingId, data: Partial) { logger.debug("Updating organization onboarding", safeStringify({ id, data })); + // We don't want to update the createdById field in update + const { organizationId, createdById: _, ...rest } = data; return await prisma.organizationOnboarding.update({ where: { id, }, data: { - ...data, + ...rest, + ...(organizationId ? { organization: { connect: { id: organizationId } } } : {}), updatedAt: new Date(), }, }); diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index ff935e97c1..2b366a6cd4 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -15,6 +15,7 @@ import { ZAdminUpdate } from "./adminUpdate.schema"; import { ZAdminVerifyInput } from "./adminVerify.schema"; import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema."; import { ZCreateInputSchema } from "./create.schema"; +import { ZCreateSelfHostedInputSchema } from "./createSelfHosted.schema"; import { ZCreateTeamsSchema } from "./createTeams.schema"; import { ZCreateWithPaymentIntentInputSchema } from "./createWithPaymentIntent.schema"; import { ZDeleteTeamInputSchema } from "./deleteTeam.schema"; @@ -158,4 +159,8 @@ export const viewerOrganizationsRouter = router({ const { default: handler } = await import("./createPhoneCall.handler"); return handler(opts); }), + createSelfHosted: authedProcedure.input(ZCreateSelfHostedInputSchema).mutation(async (opts) => { + const { default: handler } = await import("./createSelfHosted.handler"); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts new file mode 100644 index 0000000000..5374315d9f --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.handler.ts @@ -0,0 +1,75 @@ +import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TCreateSelfHostedInputSchema } from "./createSelfHosted.schema"; + +type CreateSelfHostedOptions = { + ctx: { + user: NonNullable; + }; + input: TCreateSelfHostedInputSchema; +}; + +export const createSelfHostedHandler = async ({ input, ctx }: CreateSelfHostedOptions) => { + if (!IS_SELF_HOSTED) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Self-hosted is not enabled", + }); + } + + if (!input.onboardingId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "onboardingId is required", + }); + } + + const organizationOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId); + + if (!organizationOnboarding) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Organization onboarding not found", + }); + } + + if (!organizationOnboarding.orgOwnerEmail) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Organization owner email not found", + }); + } + + 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, + }, + }); + + await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id); + + return { + organization, + }; +}; + +export default createSelfHostedHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/createSelfHosted.schema.ts b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.schema.ts new file mode 100644 index 0000000000..5c49160fdb --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/createSelfHosted.schema.ts @@ -0,0 +1,7 @@ +import type { z } from "zod"; + +import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas"; + +export const ZCreateSelfHostedInputSchema = createOrganizationSchema; + +export type TCreateSelfHostedInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts index 039d9d912f..b3c6747236 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.schema.ts @@ -1,20 +1,12 @@ -import { z } from "zod"; +import type { z } from "zod"; -import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils"; +import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas"; export enum BillingPeriod { MONTHLY = "MONTHLY", ANNUALLY = "ANNUALLY", } -// Base user schema - fields that any user can set -export const ZCreateWithPaymentIntentInputSchema = z.object({ - language: z.string().optional(), - logo: z.string().nullish(), - bio: z.string().nullish(), - onboardingId: z.string(), - invitedMembers: orgOnboardingInvitedMembersSchema.optional(), - teams: orgOnboardingTeamsSchema.optional(), -}); +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 3d29a9bb6c..e7753efa26 100644 --- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.test.ts @@ -2,12 +2,19 @@ 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 { TRPCError } from "@trpc/server"; import { intentToCreateOrgHandler } from "./intentToCreateOrg.handler"; +vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({ + LicenseKeySingleton: { + getInstance: vi.fn(), + }, +})); + const mockInput = { name: "Test Org", slug: "test-org", @@ -46,170 +53,266 @@ describe("intentToCreateOrgHandler", () => { await prismock.reset(); }); - it("should allow admin user to create org for another user", async () => { - // Create admin user - const adminUser = await createTestUser({ - email: "admin@example.com", - role: UserPermissionRole.ADMIN, + describe("hosted", () => { + beforeEach(() => { + vi.mock("@calcom/lib/constants", async () => { + const actualImport = await vi.importActual("@calcom/lib/constants"); + return { + ...actualImport, + IS_SELF_HOSTED: false, + }; + }); + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + }); }); - // Create target org owner with completed onboarding and verified email - await createTestUser({ - email: mockInput.orgOwnerEmail, - completedOnboarding: true, - emailVerified: new Date(), - }); + it("should allow admin user to create org for another user", async () => { + // Create admin user + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); - const result = await intentToCreateOrgHandler({ - input: mockInput, - ctx: { - user: adminUser, - }, - }); + // Create target org owner with completed onboarding and verified email + await createTestUser({ + email: mockInput.orgOwnerEmail, + completedOnboarding: true, + emailVerified: new Date(), + }); - // Verify the result shape - expect(result).toEqual({ - userId: expect.any(Number), - orgOwnerEmail: mockInput.orgOwnerEmail, - name: mockInput.name, - slug: mockInput.slug, - seats: mockInput.seats, - pricePerSeat: mockInput.pricePerSeat, - billingPeriod: mockInput.billingPeriod, - isPlatform: mockInput.isPlatform, - organizationOnboardingId: expect.any(String), - }); - - // Verify organization onboarding was created - const organizationOnboarding = await prismock.organizationOnboarding.findFirst({ - where: { - slug: mockInput.slug, - }, - }); - - expect(organizationOnboarding).toBeDefined(); - expect(organizationOnboarding?.name).toBe(mockInput.name); - expect(organizationOnboarding?.slug).toBe(mockInput.slug); - expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail); - }); - - it("should allow user to create org for themselves", async () => { - // Create user - const user = await createTestUser({ - email: mockInput.orgOwnerEmail, - completedOnboarding: true, - emailVerified: new Date(), - }); - - const result = await intentToCreateOrgHandler({ - input: { - ...mockInput, - // Use default payment settings - seats: undefined, - pricePerSeat: undefined, - }, - ctx: { - user, - }, - }); - - expect(result.userId).toBe(user.id); - expect(result.orgOwnerEmail).toBe(user.email); - }); - - it("should throw unauthorized error when no user is logged in", async () => { - await expect( - intentToCreateOrgHandler({ - input: mockInput, - ctx: { - user: null as any, - }, - }) - ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." })); - }); - - it("should throw forbidden error when non-admin tries to create org for another user", async () => { - // Create non-admin user - const nonAdminUser = await createTestUser({ - email: "nonadmin@example.com", - role: UserPermissionRole.USER, - }); - - await expect( - intentToCreateOrgHandler({ - input: mockInput, - ctx: { - user: nonAdminUser, - }, - }) - ).rejects.toThrow( - new TRPCError({ - code: "FORBIDDEN", - message: "You can only create organization where you are the owner", - }) - ); - }); - - it("should throw error when target user is not found", async () => { - // Create admin user - const adminUser = await createTestUser({ - email: "admin@example.com", - role: UserPermissionRole.ADMIN, - }); - - await expect( - intentToCreateOrgHandler({ + const result = await intentToCreateOrgHandler({ input: mockInput, ctx: { user: adminUser, }, - }) - ).rejects.toThrow( - new TRPCError({ - code: "BAD_REQUEST", - message: `No user found with email ${mockInput.orgOwnerEmail}`, - }) - ); - }); + }); - it("should throw error when organization onboarding already exists", async () => { - // Create admin user - const adminUser = await createTestUser({ - email: "admin@example.com", - role: UserPermissionRole.ADMIN, - }); - - // Create target org owner - await createTestUser({ - email: mockInput.orgOwnerEmail, - completedOnboarding: true, - emailVerified: new Date(), - }); - - // Create existing organization onboarding - await prismock.organizationOnboarding.create({ - data: { + // Verify the result shape + expect(result).toEqual({ + userId: expect.any(Number), + orgOwnerEmail: mockInput.orgOwnerEmail, name: mockInput.name, slug: mockInput.slug, - orgOwnerEmail: mockInput.orgOwnerEmail, - billingPeriod: mockInput.billingPeriod, seats: mockInput.seats, pricePerSeat: mockInput.pricePerSeat, - createdById: adminUser.id, - }, + billingPeriod: mockInput.billingPeriod, + isPlatform: mockInput.isPlatform, + organizationOnboardingId: expect.any(String), + }); + + // Verify organization onboarding was created + const organizationOnboarding = await prismock.organizationOnboarding.findFirst({ + where: { + slug: mockInput.slug, + }, + }); + + expect(organizationOnboarding).toBeDefined(); + expect(organizationOnboarding?.name).toBe(mockInput.name); + expect(organizationOnboarding?.slug).toBe(mockInput.slug); + expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail); }); - await expect( - intentToCreateOrgHandler({ - input: mockInput, - ctx: { - user: adminUser, + it("should allow user to create org for themselves", async () => { + // Create user + const user = await createTestUser({ + email: mockInput.orgOwnerEmail, + completedOnboarding: true, + emailVerified: new Date(), + }); + + const result = await intentToCreateOrgHandler({ + input: { + ...mockInput, + // Use default payment settings + seats: undefined, + pricePerSeat: undefined, }, - }) - ).rejects.toThrow("organization_onboarding_already_exists"); + ctx: { + user, + }, + }); + + expect(result.userId).toBe(user.id); + expect(result.orgOwnerEmail).toBe(user.email); + }); + + it("should throw unauthorized error when no user is logged in", async () => { + await expect( + intentToCreateOrgHandler({ + input: mockInput, + ctx: { + user: null as any, + }, + }) + ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." })); + }); + + it("should throw forbidden error when non-admin tries to create org for another user", async () => { + // Create non-admin user + const nonAdminUser = await createTestUser({ + email: "nonadmin@example.com", + role: UserPermissionRole.USER, + }); + + await expect( + intentToCreateOrgHandler({ + input: mockInput, + ctx: { + user: nonAdminUser, + }, + }) + ).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: "You can only create organization where you are the owner", + }) + ); + }); + + it("should throw error when target user is not found", async () => { + // Create admin user + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); + + await expect( + intentToCreateOrgHandler({ + input: mockInput, + ctx: { + user: adminUser, + }, + }) + ).rejects.toThrow( + new TRPCError({ + code: "BAD_REQUEST", + message: `No user found with email ${mockInput.orgOwnerEmail}`, + }) + ); + }); + + it("should throw error when organization onboarding already exists", async () => { + // Create admin user + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); + + // Create target org owner + await createTestUser({ + email: mockInput.orgOwnerEmail, + completedOnboarding: true, + emailVerified: new Date(), + }); + + // Create existing organization onboarding + await prismock.organizationOnboarding.create({ + data: { + name: mockInput.name, + slug: mockInput.slug, + orgOwnerEmail: mockInput.orgOwnerEmail, + billingPeriod: mockInput.billingPeriod, + seats: mockInput.seats, + pricePerSeat: mockInput.pricePerSeat, + createdById: adminUser.id, + }, + }); + + await expect( + intentToCreateOrgHandler({ + input: mockInput, + ctx: { + user: adminUser, + }, + }) + ).rejects.toThrow("organization_onboarding_already_exists"); + }); }); // This endpoint doesn't handle platform organizations as that is completely different flow at the moment // eslint-disable-next-line playwright/no-skipped-test describe.skip("platform organization tests"); + + describe("self hosted", () => { + beforeEach(() => { + vi.mock("@calcom/lib/constants", async () => { + const actualImport = await vi.importActual("@calcom/lib/constants"); + return { + ...actualImport, + IS_SELF_HOSTED: true, + }; + }); + }); + + it("should throw error when license is not valid", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(false), + }); + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); + + await expect( + intentToCreateOrgHandler({ + input: mockInput, + ctx: { + user: adminUser, + }, + }) + ).rejects.toThrow("License is not valid"); + }); + + it("should allow admin user to create org for another user", async () => { + vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({ + checkLicense: vi.fn().mockResolvedValue(true), + }); + // Create admin user + const adminUser = await createTestUser({ + email: "admin@example.com", + role: UserPermissionRole.ADMIN, + }); + + // Create target org owner with completed onboarding and verified email + await createTestUser({ + email: mockInput.orgOwnerEmail, + completedOnboarding: true, + emailVerified: new Date(), + }); + + const result = await intentToCreateOrgHandler({ + input: mockInput, + ctx: { + user: adminUser, + }, + }); + + // Verify the result shape + expect(result).toEqual({ + userId: expect.any(Number), + orgOwnerEmail: mockInput.orgOwnerEmail, + name: mockInput.name, + slug: mockInput.slug, + seats: mockInput.seats, + pricePerSeat: mockInput.pricePerSeat, + billingPeriod: mockInput.billingPeriod, + isPlatform: mockInput.isPlatform, + organizationOnboardingId: expect.any(String), + }); + + // Verify organization onboarding was created + const organizationOnboarding = await prismock.organizationOnboarding.findFirst({ + where: { + slug: mockInput.slug, + }, + }); + + expect(organizationOnboarding).toBeDefined(); + expect(organizationOnboarding?.name).toBe(mockInput.name); + expect(organizationOnboarding?.slug).toBe(mockInput.slug); + expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail); + }); + }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts index 5437a0978a..b61500d926 100644 --- a/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/intentToCreateOrg.handler.ts @@ -1,11 +1,15 @@ +import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"; import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService"; import { assertCanCreateOrg, findUserToBeOrgOwner, } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { DeploymentRepository } from "@calcom/lib/server/repository/deployment"; import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; +import { prisma } from "@calcom/prisma"; import { UserPermissionRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -29,6 +33,20 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => safeStringify({ slug, name, orgOwnerEmail, isPlatform }) ); + if (IS_SELF_HOSTED) { + const deploymentRepo = new DeploymentRepository(prisma); + const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo); + const hasValidLicense = await licenseKeyService.checkLicense(); + + 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", + }); + } + } + const loggedInUser = ctx.user; if (!loggedInUser) throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." });