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:
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,
|
||||
};
|
||||
};
|
||||
+320
-232
@@ -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>;
|
||||
+3
-11
@@ -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>;
|
||||
+248
-145
@@ -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." });
|
||||
|
||||
|
||||
Reference in new issue
Block a user