fix: Org creation for self-hosters (#21994)

* Restart form if admin is not org admin

* Show pricing is admin and hosted

* Only show billing specific fields when enabled

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

* Reset store when org creation is completed

* Create schema for trpc input when creating an org

* Add `organization.createSelfHosted` trpc endpoint

* `createOrganizationFromOnboarding` do not require stripe data

* Create organization without billing if self-hosted admin

* Update organizationOnboarding record as completed

* Handle updating subscription for self hosters

* Refactor `isBillingEnabled` to store

* Type fix

* Fix admin org creation for hosted

* Hide UI from admin

* Add tests

* fix: Fix LicenseKeySingleton mocking in organization tests

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

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

* Fix tests

* Fix tests

* Test fixes

* Type fix

* Type fix

* Type fix

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

Fix typo

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

* fix: Add IS_SELF_HOSTED mocking for E2E organization creation tests

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

* Fix E2E test

* Address feedback

* Type fix

* Fix e2e tests

* Remove unused code

* Small fixes

* fix unit tests

* review fixes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
This commit is contained in:
authored and GitHub committed 2025-06-25 16:58:18 +03:00
1 parent 164c9e37d7
commit 6ed197d35c
15 files changed
+802 -431

No files matched your search

@@ -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 <AddNewTeamMembersForm />;
};
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 && (
<div className="mb-4">
<Alert severity="error" message={checkout.errorMessage} />
<Alert severity="error" message={orgCreation.errorMessage} />
</div>
)}
<div className="space-y-6">
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
</ul>
)}
</div>
<div className="mt-3 mt-6 flex items-center justify-end">
<Button
onClick={() => {
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
});
return;
}
checkout.mutation.mutate({
orgCreation.mutation.mutate({
logo,
bio,
teams,
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
onboardingId,
});
}}
loading={checkout.isPending}>
{t("checkout")}
loading={orgCreation.isPending}>
{isBillingEnabled ? t("checkout") : t("create")}
</Button>
</div>
</>
@@ -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);
@@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = {
params?: Record<string, string>;
};
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 = "",
@@ -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<SessionC
const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(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<SessionC
defaultValues: {
billingPeriod: billingPeriod ?? BillingPeriod.MONTHLY,
slug: slug ?? (!isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined),
orgOwnerEmail: orgOwnerEmail || (!isAdmin ? defaultOrgOwnerEmail : undefined),
orgOwnerEmail: orgOwnerEmail || defaultOrgOwnerEmail,
name: name ?? (!isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined),
seats: seats ?? null,
pricePerSeat: pricePerSeat ?? null,
@@ -92,7 +93,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
slug: data.slug,
});
if (isAdmin) {
if (isAdmin && data.userId !== session.data.user.id) {
router.push("/settings/organizations/new/handover");
} else {
router.push("/settings/organizations/new/about");
@@ -133,7 +134,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
<Alert severity="error" message={serverErrorMessage} />
</div>
)}
{isAdmin && (
{isBillingEnabled && isAdmin && (
<div className="mb-5">
<Controller
name="billingPeriod"
@@ -251,7 +252,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
/>
</div>
{isAdmin && (
{isBillingEnabled && isAdmin && (
<>
<section className="grid grid-cols-2 gap-2">
<div className="w-full">
@@ -305,7 +306,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
)}
{/* This radio group does nothing - its just for visual purposes */}
{!isAdmin && (
{isBillingEnabled && !isAdmin && (
<>
<div className="bg-subtle space-y-5 rounded-lg p-5">
<h3 className="font-cal text-default text-lg font-semibold leading-4">
@@ -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<OnboardingStoreState>()(
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,
};
};
@@ -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<OrganizationOnboarding>;
}) {
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<typeof vi.fn>).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();
});
});
});
@@ -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,
@@ -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(),
});
@@ -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<CreateOrganizationOnboardingInput>) {
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(),
},
});
@@ -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);
}),
});
@@ -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<TrpcSessionUser>;
};
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;
@@ -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<typeof ZCreateSelfHostedInputSchema>;
@@ -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<typeof ZCreateWithPaymentIntentInputSchema>;
@@ -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);
});
});
});
@@ -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." });