chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow * Getting started * Brand details * Preview organization brands * Orgs team pages * Invite team steps * Move to global zustand store * Few darkmdoe fixes * Wip onboarding + stripe flow * Default plan state Server Action for gettting slug satus of org * Remove onboardingId * Confirmation prompt * Update old onboarding flow handlers to handle new fields * update onboarding hook * Filter out organization section for none -company emails * Match placeholders to users domain * Drop migration * Wip new onboarding intent * WIP flow for self-hosted. Same service call just split logic * WIP * Add TODO * Use onboarding user type instead of trpc session * WIP * WIP * pass role and team name from onboarding to save in schema * Add test to ensure role + name + team are persisted into onboarding table * migrate roles to enum values * Update ENUM * Fix type error * Redirect if flag is disabled * Remove web * WIP * WIP * Fix migration * Fix calls * User onboarding User types instead of trpc session * Fix factory tests * Fix flow for self hoste * Type error * More type fixes * Fix handler tests * Fix enum return type being different * Use consistant types across the oganization stuff * Fix * Use TEAM_BILLING for e2e test * Refactor is not company email and add tests * Fix * Fix * Refactor flow to submit after form complete * Fix flow with billing disabled * Fix tests * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Rename and move test files * WIP * Fix types * Update repo paths + tests * Move to service folder * Fix tests * Fix types * Remove old test files * Restore lock * Fix path * Fix tests with new paths and factory logic * Fix updaetdAt * WIP onboardingID isolation * Fix e2e test * verify test * Code rabbit * Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService * Fix stores * Fix type error * Fix types * remove tsignore * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * NITS * Add the logic to auto complete admin org when billing enabled * Fix store being weird * We need to return the parsed value * fixes * sync from db always * Add onboardingSgtore tests * fix test * remove step and status --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
co-authored by
coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Hariom Balhara
parent
1eb6ff3e65
commit
fa35cc5210
@@ -0,0 +1,22 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("resume_onboarding"),
|
||||
(t) => t("resume_onboarding_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/new/resume"
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<ResumeOnboardingPage />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
<TextField
|
||||
key={field.id}
|
||||
{...register(`teams.${index}.name`)}
|
||||
data-testid={`team.${index}.name`}
|
||||
label=""
|
||||
addOnClassname="bg-transparent p-0 border-l-0"
|
||||
className={index > 0 ? "mb-2" : ""}
|
||||
|
||||
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setServerErrorMessage(t(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||
// Single mutation for all flows (billing, self-hosted, admin)
|
||||
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data.organization) {
|
||||
// Invalidate the organizations query to ensure fresh data on the next page
|
||||
reset({
|
||||
onboardingId: data.organizationOnboardingId,
|
||||
});
|
||||
|
||||
if (data.checkoutUrl) {
|
||||
// Billing enabled - redirect to Stripe
|
||||
window.location.href = data.checkoutUrl;
|
||||
} else if (data.organizationId) {
|
||||
// Self-hosted - org already created, redirect to organizations
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
} else {
|
||||
// Unexpected state
|
||||
setServerErrorMessage("Unexpected response from server");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||
|
||||
return {
|
||||
mutation: mutationToUse,
|
||||
mutate: mutationToUse.mutate,
|
||||
isPending: mutationToUse.isPending,
|
||||
mutation: intentToCreateOrgMutation,
|
||||
mutate: intentToCreateOrgMutation.mutate,
|
||||
isPending: intentToCreateOrgMutation.isPending,
|
||||
errorMessage: serverErrorMessage,
|
||||
};
|
||||
};
|
||||
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
|
||||
invitedMembers,
|
||||
logo,
|
||||
bio,
|
||||
onboardingId,
|
||||
name,
|
||||
slug,
|
||||
billingPeriod,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
brandColor,
|
||||
bannerUrl,
|
||||
} = useOnboardingStore();
|
||||
const orgCreation = useOrgCreation();
|
||||
|
||||
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
placeholder="colleague@company.com"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" StartIcon="plus" color="secondary">
|
||||
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<Button
|
||||
data-testid="publish-button"
|
||||
onClick={() => {
|
||||
if (!onboardingId) {
|
||||
console.error("Org owner email and onboardingId are required", {
|
||||
orgOwnerEmail,
|
||||
onboardingId,
|
||||
});
|
||||
// Submit ALL data to intentToCreateOrg
|
||||
if (!name || !slug || !orgOwnerEmail) {
|
||||
console.error("Required fields missing", { name, slug, orgOwnerEmail });
|
||||
showToast(t("required_fields_missing"), "error");
|
||||
return;
|
||||
}
|
||||
orgCreation.mutation.mutate({
|
||||
|
||||
orgCreation.mutate({
|
||||
name,
|
||||
slug,
|
||||
orgOwnerEmail,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
billingPeriod,
|
||||
creationSource: "WEBAPP" as const,
|
||||
logo,
|
||||
bio,
|
||||
brandColor,
|
||||
bannerUrl,
|
||||
teams,
|
||||
invitedMembers,
|
||||
onboardingId,
|
||||
});
|
||||
}}
|
||||
loading={orgCreation.isPending}>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const PaymentStatusView = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const paymentStatus = searchParams?.get("paymentStatus");
|
||||
const paymentError = searchParams?.get("error");
|
||||
const { useOnboardingStore } = useOnboarding({
|
||||
step: "status",
|
||||
});
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
|
||||
const { name } = useOnboardingStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={5}>
|
||||
{children}
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeOnboardingView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const onboardingIdParam = searchParams?.get("onboardingId");
|
||||
|
||||
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onboardingIdParam) {
|
||||
router.push("/settings/organizations/new");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbOnboarding.isComplete) {
|
||||
router.push("/settings/organizations");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load onboarding data into store
|
||||
reset({
|
||||
onboardingId: dbOnboarding.id,
|
||||
name: dbOnboarding.name,
|
||||
slug: dbOnboarding.slug,
|
||||
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
|
||||
billingPeriod: dbOnboarding.billingPeriod,
|
||||
seats: dbOnboarding.seats,
|
||||
pricePerSeat: dbOnboarding.pricePerSeat,
|
||||
logo: dbOnboarding.logo,
|
||||
bio: dbOnboarding.bio,
|
||||
brandColor: dbOnboarding.brandColor,
|
||||
bannerUrl: dbOnboarding.bannerUrl,
|
||||
});
|
||||
|
||||
// Redirect to next step (About page)
|
||||
router.push("/settings/organizations/new/about");
|
||||
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
|
||||
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return (
|
||||
<SkeletonContainer className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-4 w-3/4" />
|
||||
<SkeletonText className="h-4 w-1/2" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onboardingIdParam) {
|
||||
return (
|
||||
<Alert
|
||||
data-testid="error"
|
||||
severity="error"
|
||||
title={t("error")}
|
||||
message={t("no_onboarding_id_provided")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbOnboarding) {
|
||||
return (
|
||||
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
|
||||
);
|
||||
}
|
||||
|
||||
if (dbOnboarding.isComplete) {
|
||||
return (
|
||||
<Alert
|
||||
data-testid="error"
|
||||
severity="info"
|
||||
title={t("onboarding_already_complete")}
|
||||
message={t("onboarding_already_complete_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state while redirecting
|
||||
return (
|
||||
<SkeletonContainer className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-4 w-3/4" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeOnboardingView;
|
||||
@@ -0,0 +1,451 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
/**
|
||||
* Utility function to complete the organization onboarding wizard steps
|
||||
* (about, add-teams, onboard-members)
|
||||
*/
|
||||
async function completeOnboardingWizard(
|
||||
page: Page,
|
||||
options: {
|
||||
aboutText?: string;
|
||||
teams?: string[];
|
||||
members?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
|
||||
|
||||
// Step 1: Complete "About" step
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
await page.locator('textarea[name="about"]').fill(aboutText);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 2: Complete "Add Teams" step
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
for (let i = 0; i < teams.length; i++) {
|
||||
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
|
||||
if (i < teams.length - 1) {
|
||||
await page.getByTestId("add_a_team").click();
|
||||
}
|
||||
}
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 3: Complete "Onboard Members" step
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
|
||||
// Add members if provided
|
||||
for (const memberEmail of members) {
|
||||
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
|
||||
}
|
||||
|
||||
// Click publish and wait for response
|
||||
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
|
||||
await page.getByTestId("publish-button").click();
|
||||
|
||||
const response = await responsePromise;
|
||||
const responseData = await response.json();
|
||||
|
||||
// TRPC batch response is an array with one element
|
||||
const result = responseData[0].result.data.json;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Admin Handover Flow (Billing Disabled)", () => {
|
||||
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
|
||||
// Setup: Create admin and future owner
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const ownerEmail = users.trackEmail({
|
||||
username: "orgowner",
|
||||
domain: "example.com",
|
||||
});
|
||||
const ownerUser = await users.create({
|
||||
username: "orgowner",
|
||||
email: ownerEmail,
|
||||
});
|
||||
|
||||
const orgName = "Test Organization";
|
||||
const orgSlug = `test-org-${uuid()}`.toLowerCase();
|
||||
|
||||
// Step 1: Admin fills form
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("10");
|
||||
await page.locator("input[name=pricePerSeat]").fill("15");
|
||||
}
|
||||
|
||||
// Submit and wait for intentToCreateOrg
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
|
||||
// Step 2: Verify admin is on handover page
|
||||
await page.waitForURL("**/settings/organizations/new/handover");
|
||||
|
||||
const onboardingUrlElement = page.getByTestId("onboarding-url");
|
||||
await expect(onboardingUrlElement).toBeVisible();
|
||||
|
||||
const onboardingUrl = await onboardingUrlElement.textContent();
|
||||
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
|
||||
|
||||
// Extract onboardingId from URL
|
||||
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
|
||||
expect(onboardingIdMatch).toBeTruthy();
|
||||
const onboardingId = onboardingIdMatch?.[1];
|
||||
|
||||
// Step 3: Switch to owner user
|
||||
await page.context().clearCookies();
|
||||
await ownerUser.apiLogin();
|
||||
|
||||
// Step 4: Owner opens resume URL and completes the onboarding wizard
|
||||
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
|
||||
|
||||
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
|
||||
const result = await completeOnboardingWizard(page, {
|
||||
aboutText: "This is our test organization",
|
||||
teams: ["Engineering", "Marketing"],
|
||||
members: [memberEmail],
|
||||
});
|
||||
|
||||
// Verify the organization was created successfully in the API response
|
||||
expect(result.organizationId).toBeTruthy();
|
||||
expect(result.name).toBe(orgName);
|
||||
expect(result.slug).toBe(orgSlug);
|
||||
});
|
||||
|
||||
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const orgName = "Admin Org";
|
||||
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Fill form with admin's own email
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("5");
|
||||
await page.locator("input[name=pricePerSeat]").fill("20");
|
||||
}
|
||||
|
||||
// Submit - admin creating for self goes through wizard
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Admin creating for self goes through the onboarding wizard
|
||||
const result = await completeOnboardingWizard(page, {
|
||||
aboutText: "Admin's organization",
|
||||
teams: ["Operations"],
|
||||
members: [],
|
||||
});
|
||||
|
||||
// Verify the organization was created successfully
|
||||
expect(result.organizationId).toBeTruthy();
|
||||
expect(result.name).toBe(orgName);
|
||||
expect(result.slug).toBe(orgSlug);
|
||||
});
|
||||
|
||||
test("Admin handover URL structure is correct", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const ownerEmail = users.trackEmail({
|
||||
username: "testowner",
|
||||
domain: "example.com",
|
||||
});
|
||||
await users.create({
|
||||
username: "testowner",
|
||||
email: ownerEmail,
|
||||
});
|
||||
|
||||
const orgName = "Handover Test";
|
||||
const orgSlug = `handover-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("10");
|
||||
await page.locator("input[name=pricePerSeat]").fill("15");
|
||||
}
|
||||
|
||||
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Wait for the response
|
||||
await responsePromise;
|
||||
|
||||
// Wait for redirect to handover page
|
||||
await page.waitForURL("**/settings/organizations/new/handover");
|
||||
|
||||
// Wait for handover page elements to be visible (with longer timeout for store to populate)
|
||||
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
|
||||
|
||||
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
|
||||
|
||||
// Verify URL format
|
||||
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
|
||||
|
||||
// Verify copy button is clickable (toast message may vary by locale)
|
||||
await page.getByTestId("copy-onboarding-url").click();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Non-Admin User Flow", () => {
|
||||
test("Regular user creates org through full wizard", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "regularuser",
|
||||
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
const orgName = "User Organization";
|
||||
const orgSlug = `user-org-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify user email is pre-filled and disabled
|
||||
const emailInput = page.locator("input[name=orgOwnerEmail]");
|
||||
await expect(emailInput).toBeDisabled();
|
||||
await expect(emailInput).toHaveValue(user.email);
|
||||
|
||||
// Verify billing fields are NOT visible to regular users
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await expect(page.locator("input[name=seats]")).not.toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
|
||||
|
||||
// Verify "Upgrade to Organizations" UI is shown
|
||||
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
|
||||
}
|
||||
|
||||
// Fill form
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Verify navigated to /about step
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
|
||||
// Complete about step
|
||||
await page.locator('textarea[name="about"]').fill("User's organization description");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Complete add-teams step
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
await page.getByTestId("team.0.name").fill("Team Alpha");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Complete onboard-members step
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.getByTestId("publish-button").click(),
|
||||
]);
|
||||
|
||||
// In billing enabled mode, should redirect to Stripe or payment
|
||||
// In billing disabled mode, org created immediately
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
// Would redirect to Stripe checkout
|
||||
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
|
||||
} else {
|
||||
// After org creation, session update causes redirect to profile page
|
||||
// Wait for it to load, then navigate to organizations
|
||||
await page.waitForURL("**/settings/my-account/profile");
|
||||
await page.goto("/settings/organizations");
|
||||
await page.waitForURL(/\/settings\/organizations/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Regular user - email field is disabled", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "testuser",
|
||||
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify email field state
|
||||
const emailInput = page.locator("input[name=orgOwnerEmail]");
|
||||
await expect(emailInput).toBeVisible();
|
||||
await expect(emailInput).toBeDisabled();
|
||||
await expect(emailInput).toHaveValue(user.email);
|
||||
|
||||
// Verify name and slug are derived from email domain
|
||||
const nameInput = page.locator("input[name=name]");
|
||||
const slugInput = page.locator("input[name=slug]");
|
||||
|
||||
// Name should be capitalized version of domain
|
||||
const nameValue = await nameInput.inputValue();
|
||||
expect(nameValue).toBeTruthy();
|
||||
expect(nameValue).toMatch(/company/i);
|
||||
|
||||
// Slug should be domain-based
|
||||
const slugValue = await slugInput.inputValue();
|
||||
expect(slugValue).toBeTruthy();
|
||||
expect(slugValue).toContain("company");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Form Validation", () => {
|
||||
test("Admin form - required fields validation", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Submit empty form
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Should show validation errors
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
|
||||
});
|
||||
|
||||
test("Slug already taken shows error", async ({ page, users, orgs }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
// Create an existing org with a specific slug
|
||||
const existingSlug = `existing-${uuid()}`.toLowerCase();
|
||||
await orgs.create({
|
||||
name: "Existing Org",
|
||||
slug: existingSlug,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
|
||||
await users.create({ username: "owner", email: ownerEmail });
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill("New Org");
|
||||
await page.locator("input[name=slug]").fill(existingSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("5");
|
||||
await page.locator("input[name=pricePerSeat]").fill("10");
|
||||
}
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Should show slug taken error
|
||||
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Navigation Between Steps", () => {
|
||||
test("User can navigate through wizard steps", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "wizarduser",
|
||||
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
const orgName = "Wizard Test";
|
||||
const orgSlug = `wizard-${uuid()}`.toLowerCase();
|
||||
|
||||
// Step 1
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 2: About
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
await page.locator('textarea[name="about"]').fill("Wizard test org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 3: Add teams
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
await page.getByTestId("team.0.name").fill("Team 1");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 4: Onboard members
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
|
||||
// Verify wizard shows correct step numbers
|
||||
// The WizardLayout component shows currentStep/maxSteps
|
||||
// Just verify we reached the last step
|
||||
await expect(page.getByTestId("publish-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Billing Field Visibility", () => {
|
||||
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
|
||||
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify billing fields are visible for admin
|
||||
await expect(page.locator("input[name=seats]")).toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
|
||||
await expect(page.locator("#billingPeriod")).toBeVisible();
|
||||
|
||||
// Verify toggle between monthly/annual
|
||||
await expect(page.getByText("Monthly")).toBeVisible();
|
||||
await expect(page.getByText("Annually")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "pricinguser",
|
||||
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Should NOT see admin billing fields
|
||||
await expect(page.locator("input[name=seats]")).not.toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
|
||||
await expect(page.locator("#billingPeriod")).not.toBeVisible();
|
||||
|
||||
// Should see upgrade/pricing UI
|
||||
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
|
||||
await expect(page.getByText(/organization/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Messages } from "mailhog";
|
||||
import path from "path";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { fillStripeTestCheckout } from "../lib/testUtils";
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
|
||||
async function expectEmailWithSubject(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
|
||||
const allEmails = (receivedEmails as Messages).items;
|
||||
const email = allEmails.find((email) => email.subject === subject);
|
||||
if (!email) {
|
||||
throw new Error(`Email with subject ${subject} not found`);
|
||||
}
|
||||
const dom = new JSDOM(email.html);
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
|
||||
const document = dom?.window?.document;
|
||||
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
oldUsername,
|
||||
newUsername,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
oldUsername: string;
|
||||
newUsername: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
});
|
||||
const document = dom?.window.document;
|
||||
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
|
||||
if (!links) {
|
||||
throw new Error(`data-testid="organization-link-info doesn't have links`);
|
||||
}
|
||||
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
|
||||
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
|
||||
}
|
||||
|
||||
export async function expectEmailVerificationEmailToBeSent(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string
|
||||
) {
|
||||
const subject = "Cal.com: Verify your account";
|
||||
return expectEmailWithSubject(page, emails, userEmail, subject);
|
||||
}
|
||||
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
function capitalize(text: string) {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
|
||||
const orgOwnerUsernamePrefix = "owner";
|
||||
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
email: orgOwnerEmail,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
|
||||
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
|
||||
const orgName = capitalize(`${orgOwnerUser.username}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
await test.step("Handover", async () => {
|
||||
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
|
||||
expect(onboardingUrl).toContain("settings/organizations/new");
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
const orgOwnerUsername = `owner`;
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
orgSlug,
|
||||
});
|
||||
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
|
||||
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
|
||||
});
|
||||
|
||||
test("User can create and upgrade a org", async ({ page, users, emails }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
});
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
|
||||
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgOwnerUsername);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.getByTestId("add_a_team").click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const numberOfTeams = 3;
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create(
|
||||
{
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
},
|
||||
{ hasTeam: true, numberOfTeams }
|
||||
);
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Has org self serve banner", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
|
||||
await expect(selfServeButtonLocator).toBeVisible();
|
||||
|
||||
await selfServeButtonLocator.click();
|
||||
await page.waitForURL("/settings/organizations/new");
|
||||
});
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
const slugLocator = await page.locator("input[name=slug]");
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await slugLocator.inputValue()).toBe("example");
|
||||
|
||||
await slugLocator.fill(`example-${stringUUID}`);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Move existing teams to org", async () => {
|
||||
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
|
||||
const foundCheckboxes = page.locator('input[type="checkbox"]');
|
||||
const count = await foundCheckboxes.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const checkbox = foundCheckboxes.nth(i);
|
||||
await checkbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.getByTestId("new-team-dialog-button").click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
await page.goto("/teams");
|
||||
const teamListItems = await page.getByTestId("team-list-item-link").all();
|
||||
|
||||
// Number of teams migrated + the two created in the create teams step
|
||||
expect(teamListItems.length).toBe(numberOfTeams + 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function fillAndSubmitFirstStepAsAdmin(
|
||||
page: Page,
|
||||
targetOrgEmail: string,
|
||||
orgName: string,
|
||||
orgSlug: string
|
||||
) {
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
|
||||
// Since we are admin fill in this information instead of deriving it
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// Fill in seat information
|
||||
await page.locator("input[name=seats]").fill("30");
|
||||
await page.locator("input[name=pricePerSeat]").fill("30");
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
}
|
||||
@@ -3299,6 +3299,12 @@
|
||||
"organization_onboarding_already_completed": "Organization onboarding already completed",
|
||||
"organization_onboarding_already_exists": "Organization onboarding already exists",
|
||||
"organization_onboarding_not_found": "Organization onboarding not found",
|
||||
"no_onboarding_id_provided": "No onboarding ID provided",
|
||||
"onboarding_not_found": "Onboarding not found",
|
||||
"onboarding_already_complete": "Onboarding already complete",
|
||||
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
|
||||
"resume_onboarding": "Resume Onboarding",
|
||||
"resume_onboarding_description": "Continue with your organization onboarding process",
|
||||
"payment_successful": "Payment successful!",
|
||||
"handover_onboarding_page_title": "Handover onboarding",
|
||||
"handover_onboarding_page_description": "Handover onboarding to the user",
|
||||
|
||||
@@ -3,11 +3,13 @@ import { z } from "zod";
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
|
||||
import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import type { SWHMap } from "./__handler";
|
||||
|
||||
@@ -92,10 +94,31 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
|
||||
};
|
||||
}
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
// Get the user who created the onboarding (for service instantiation)
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const creator = organizationOnboarding.createdById
|
||||
? await userRepo.findById({ id: organizationOnboarding.createdById })
|
||||
: null;
|
||||
|
||||
// Create a minimal user context for the service
|
||||
// If no creator, use a system user context (webhook is system-initiated)
|
||||
const userContext = creator
|
||||
? {
|
||||
id: creator.id,
|
||||
email: creator.email,
|
||||
role: "ADMIN" as const,
|
||||
name: creator.name || undefined,
|
||||
}
|
||||
: {
|
||||
id: 0, // System user
|
||||
email: organizationOnboarding.orgOwnerEmail,
|
||||
role: "ADMIN" as const,
|
||||
};
|
||||
|
||||
const onboardingService = new BillingEnabledOrgOnboardingService(userContext);
|
||||
const { organization } = await onboardingService.createOrganization(organizationOnboarding, {
|
||||
subscriptionId: paymentSubscriptionId,
|
||||
subscriptionItemId: paymentSubscriptionItemId,
|
||||
});
|
||||
|
||||
// Get the Stripe subscription object
|
||||
|
||||
@@ -1,50 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
export const AdminOnboardingHandover = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.user.role === "ADMIN";
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { onboardingId, orgOwnerEmail } = useOnboardingStore();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onboardingUrl = `${WEBAPP_URL}/settings/organizations/new`;
|
||||
// Admin handover page should ONLY use store data, not DB query
|
||||
// The DB query returns the admin's own onboarding, not the one being handed over
|
||||
if (!onboardingId) {
|
||||
return <Alert severity="error" title={t("error")} message="No onboarding ID found. Please try again." />;
|
||||
}
|
||||
|
||||
const onboardingUrl = `${WEBAPP_URL}/settings/organizations/new/resume?onboardingId=${onboardingId}`;
|
||||
|
||||
return (
|
||||
<div className="bg-subtle rounded-md p-4">
|
||||
<div className="flex-col items-center gap-2">
|
||||
<span className="text-emphasis font-medium">
|
||||
User can now continue onboarding with the following link
|
||||
</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code
|
||||
data-testid="onboarding-url"
|
||||
className="bg-default text-default flex w-full items-center truncate rounded-md px-3 py-2 font-mono text-sm">
|
||||
{onboardingUrl}
|
||||
</code>
|
||||
<Button
|
||||
data-testid="copy-onboarding-url"
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
className="h-9 w-9"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(onboardingUrl);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
StartIcon="clipboard"
|
||||
tooltip={t("copy_to_clipboard")}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* Instructions Card */}
|
||||
<div className="bg-subtle rounded-lg p-6">
|
||||
<h3 className="text-emphasis mb-2 text-lg font-medium">{t("next_steps")}</h3>
|
||||
<p className="text-default mb-4 text-sm">
|
||||
Send the following link to <strong>{orgOwnerEmail}</strong> so they can complete the organization
|
||||
setup:
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="bg-default flex items-center gap-2 rounded-md border p-3">
|
||||
<code className="text-default flex-1 truncate font-mono text-sm" data-testid="onboarding-url">
|
||||
{onboardingUrl}
|
||||
</code>
|
||||
<Button
|
||||
data-testid="copy-onboarding-url"
|
||||
type="button"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(onboardingUrl);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
StartIcon="clipboard">
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button color="minimal" StartIcon="arrow-left" onClick={() => router.push("/settings/organizations")}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
router.push("/event-types");
|
||||
}}>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,8 +10,7 @@ import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomain
|
||||
import { MINIMUM_NUMBER_OF_ORG_SEATS, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { BillingPeriod, CreationSource, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -37,7 +36,7 @@ function extractDomainFromEmail(email: string) {
|
||||
export const CreateANewOrganizationForm = () => {
|
||||
const session = useSession();
|
||||
|
||||
const { isLoadingOrgOnboarding } = useOnboarding({ step: "start" });
|
||||
const { isLoadingOrgOnboarding } = useOnboarding();
|
||||
if (!session.data || isLoadingOrgOnboarding) {
|
||||
return null;
|
||||
}
|
||||
@@ -45,11 +44,6 @@ export const CreateANewOrganizationForm = () => {
|
||||
return <CreateANewOrganizationFormChild session={session} />;
|
||||
};
|
||||
|
||||
enum BillingPeriod {
|
||||
MONTHLY = "MONTHLY",
|
||||
ANNUALLY = "ANNUALLY",
|
||||
}
|
||||
|
||||
const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -57,7 +51,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
||||
// 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 { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } =
|
||||
useOnboardingStore();
|
||||
|
||||
@@ -81,8 +75,6 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
|
||||
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
// TODO: To be moved to _invoice.paid.org.ts
|
||||
// telemetry.event(telemetryEventTypes.org_created);
|
||||
reset({
|
||||
onboardingId: data.organizationOnboardingId,
|
||||
billingPeriod: data.billingPeriod,
|
||||
@@ -93,9 +85,17 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
slug: data.slug,
|
||||
});
|
||||
|
||||
if (isAdmin && data.userId !== session.data.user.id) {
|
||||
// Small delay to ensure Zustand persist middleware has time to write to localStorage
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
if (data.handoverUrl) {
|
||||
// Admin handover flow - redirect to handover page
|
||||
router.push("/settings/organizations/new/handover");
|
||||
} else if (data.organizationId) {
|
||||
// Self-hosted flow - org already created, redirect to organizations list
|
||||
router.push("/settings/organizations");
|
||||
} else {
|
||||
// Regular flow - continue to next step
|
||||
router.push("/settings/organizations/new/about");
|
||||
}
|
||||
},
|
||||
@@ -122,10 +122,30 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
id="createOrg"
|
||||
handleSubmit={async (v) => {
|
||||
if (!needToCreateOnboarding) {
|
||||
// Resuming existing onboarding - just navigate to next step
|
||||
router.push("/settings/organizations/new/about");
|
||||
} else if (!intentToCreateOrgMutation.isPending) {
|
||||
setServerErrorMessage(null);
|
||||
intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP });
|
||||
} else {
|
||||
// Check if this is admin handover flow based on the submitted form value
|
||||
const isAdminHandoverFlow = isAdmin && v.orgOwnerEmail !== session.data.user.email;
|
||||
|
||||
if (isAdminHandoverFlow) {
|
||||
// Admin creating for someone else - submit immediately with just Step 1 data
|
||||
if (!intentToCreateOrgMutation.isPending) {
|
||||
setServerErrorMessage(null);
|
||||
intentToCreateOrgMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP });
|
||||
}
|
||||
} else {
|
||||
// Regular user or admin creating for self - store locally and continue
|
||||
reset({
|
||||
billingPeriod: v.billingPeriod,
|
||||
pricePerSeat: v.pricePerSeat,
|
||||
seats: v.seats,
|
||||
orgOwnerEmail: v.orgOwnerEmail,
|
||||
name: v.name,
|
||||
slug: v.slug,
|
||||
});
|
||||
router.push("/settings/organizations/new/about");
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
|
||||
@@ -135,6 +135,8 @@ describe("OrganizationPaymentService", () => {
|
||||
expect(data).toEqual({
|
||||
bio: "BIO",
|
||||
logo: "LOGO",
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
@@ -183,6 +185,8 @@ describe("OrganizationPaymentService", () => {
|
||||
expect(data).toEqual({
|
||||
bio: "BIO",
|
||||
logo: "LOGO",
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
@@ -232,6 +236,9 @@ describe("OrganizationPaymentService", () => {
|
||||
expect(data).toEqual({
|
||||
bio: "BIO",
|
||||
logo: "LOGO",
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
invitedMembers: undefined,
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
@@ -261,6 +268,9 @@ describe("OrganizationPaymentService", () => {
|
||||
expect(data).toEqual({
|
||||
bio: "BIO",
|
||||
logo: "LOGO",
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
invitedMembers: undefined,
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import {
|
||||
ORGANIZATION_SELF_SERVE_MIN_SEATS,
|
||||
ORGANIZATION_SELF_SERVE_PRICE,
|
||||
@@ -7,24 +8,25 @@ import {
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { OrganizationOnboarding } from "@calcom/prisma/client";
|
||||
import { UserPermissionRole, type BillingPeriod } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { OrganizationPermissionService } from "./OrganizationPermissionService";
|
||||
import type { OnboardingUser } from "./service/onboarding/types";
|
||||
|
||||
type OrganizationOnboardingId = string;
|
||||
const log = logger.getSubLogger({ prefix: ["OrganizationPaymentService"] });
|
||||
type CreatePaymentIntentInput = {
|
||||
logo: string | null;
|
||||
bio: string | null;
|
||||
brandColor?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
|
||||
invitedMembers?: { email: string }[];
|
||||
invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string }[];
|
||||
};
|
||||
|
||||
type CreateOnboardingInput = {
|
||||
@@ -35,6 +37,12 @@ type CreateOnboardingInput = {
|
||||
seats?: number | null;
|
||||
pricePerSeat?: number | null;
|
||||
createdByUserId: number;
|
||||
logo?: string | null;
|
||||
bio?: string | null;
|
||||
brandColor?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
teams?: { id: number; isBeingMigrated: boolean; slug: string | null; name: string }[];
|
||||
invitedMembers?: { email: string; name?: string; teamId?: number; teamName?: string; role?: string }[];
|
||||
};
|
||||
|
||||
type PermissionCheckInput = {
|
||||
@@ -72,9 +80,9 @@ type StripePrice = {
|
||||
export class OrganizationPaymentService {
|
||||
protected billingService: StripeBillingService;
|
||||
protected permissionService: OrganizationPermissionService;
|
||||
protected user: NonNullable<TrpcSessionUser>;
|
||||
protected user: OnboardingUser;
|
||||
|
||||
constructor(user: NonNullable<TrpcSessionUser>, permissionService?: OrganizationPermissionService) {
|
||||
constructor(user: OnboardingUser, permissionService?: OrganizationPermissionService) {
|
||||
this.billingService = new StripeBillingService();
|
||||
this.permissionService = permissionService || new OrganizationPermissionService(user);
|
||||
this.user = user;
|
||||
@@ -191,6 +199,12 @@ export class OrganizationPaymentService {
|
||||
seats: config.seats,
|
||||
pricePerSeat: config.pricePerSeat,
|
||||
createdById: input.createdByUserId,
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
teams: input.teams ?? [],
|
||||
invitedMembers: input.invitedMembers ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,7 +301,7 @@ export class OrganizationPaymentService {
|
||||
) {
|
||||
log.debug("createPaymentIntent", safeStringify(input));
|
||||
|
||||
const { teams: _teams, invitedMembers, logo, bio } = input;
|
||||
const { teams: _teams, invitedMembers, logo, bio, brandColor, bannerUrl } = input;
|
||||
|
||||
const teams = _teams?.filter((team) => team.id === -1 || team.isBeingMigrated) || [];
|
||||
const teamIds = teams.filter((team) => team.id > 0).map((team) => team.id);
|
||||
@@ -362,15 +376,20 @@ export class OrganizationPaymentService {
|
||||
})
|
||||
);
|
||||
|
||||
log.debug("Updating onboarding");
|
||||
log.debug("Updating onboarding with stripe details and form data");
|
||||
|
||||
// Update the onboarding record with all the form data
|
||||
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
||||
bio: bio ?? null,
|
||||
logo: logo ?? null,
|
||||
invitedMembers: invitedMembers,
|
||||
teams,
|
||||
stripeCustomerId,
|
||||
...updatedConfig,
|
||||
seats: updatedConfig.seats,
|
||||
pricePerSeat: updatedConfig.pricePerSeat,
|
||||
billingPeriod: updatedConfig.billingPeriod,
|
||||
logo,
|
||||
bio,
|
||||
brandColor: brandColor ?? null,
|
||||
bannerUrl: bannerUrl ?? null,
|
||||
teams,
|
||||
invitedMembers,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,10 +5,11 @@ import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { OnboardingUser } from "./service/onboarding/types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["ee", "organizations", "OrganizationPermissionService"] });
|
||||
type SeatsPrice = {
|
||||
seats?: number | null;
|
||||
@@ -28,7 +29,7 @@ export interface validatePermissionsIOrganizationPermissionService {
|
||||
}
|
||||
|
||||
export class OrganizationPermissionService {
|
||||
constructor(private readonly user: NonNullable<TrpcSessionUser>) {}
|
||||
constructor(private readonly user: OnboardingUser) {}
|
||||
|
||||
async hasPermissionToCreateForEmail(targetEmail: string): Promise<boolean> {
|
||||
return this.user.email === targetEmail || this.user.role === "ADMIN";
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
// @ts-nocheck - Test file with mock type compatibility issues that don't affect test functionality
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { useOnboardingStore, useOnboarding } from "./onboardingStore";
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock("next-auth/react");
|
||||
vi.mock("next/navigation");
|
||||
vi.mock("@calcom/trpc/react", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
organizations: {
|
||||
getOrganizationOnboarding: {
|
||||
useQuery: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
const actual = await vi.importActual("@calcom/lib/constants");
|
||||
return {
|
||||
...actual,
|
||||
IS_SELF_HOSTED: false, // Default to false, will be overridden in specific tests
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function renderUseOnboardingStore() {
|
||||
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
|
||||
return renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
|
||||
}
|
||||
|
||||
// Test Data Builders
|
||||
const createTestSession = (overrides?: {
|
||||
email?: string;
|
||||
role?: UserPermissionRole;
|
||||
status?: "loading" | "authenticated" | "unauthenticated";
|
||||
}) => {
|
||||
const status = overrides?.status || "authenticated";
|
||||
|
||||
if (status === "loading") {
|
||||
return {
|
||||
data: null,
|
||||
status: "loading" as const,
|
||||
update: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return {
|
||||
data: null,
|
||||
status: "unauthenticated" as const,
|
||||
update: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
user: {
|
||||
email: overrides?.email || "test@example.com",
|
||||
role: overrides?.role || UserPermissionRole.USER,
|
||||
},
|
||||
},
|
||||
status: "authenticated" as const,
|
||||
update: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const createTestOrganizationOnboarding = (overrides?: {
|
||||
isComplete?: boolean;
|
||||
id?: string;
|
||||
billingPeriod?: string;
|
||||
pricePerSeat?: number;
|
||||
seats?: number;
|
||||
orgOwnerEmail?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
bio?: string;
|
||||
logo?: string;
|
||||
}) => ({
|
||||
id: overrides?.id || "test-onboarding-id",
|
||||
isComplete: overrides?.isComplete ?? false,
|
||||
billingPeriod: overrides?.billingPeriod || "MONTHLY",
|
||||
pricePerSeat: overrides?.pricePerSeat ?? 20,
|
||||
seats: overrides?.seats ?? 5,
|
||||
orgOwnerEmail: overrides?.orgOwnerEmail || "test@example.com",
|
||||
name: overrides?.name || "Test Organization",
|
||||
slug: overrides?.slug || "test-org",
|
||||
bio: overrides?.bio || "Test bio",
|
||||
logo: overrides?.logo || "test-logo.png",
|
||||
});
|
||||
|
||||
|
||||
|
||||
function mockOrganizationOnboardingFromDB(data: ReturnType<typeof createTestOrganizationOnboarding> | null) {
|
||||
const organizationOnboarding = data ? createTestOrganizationOnboarding(data) : null;
|
||||
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: organizationOnboarding,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const createTestSearchParams = (params?: Record<string, string>) => {
|
||||
return new URLSearchParams(params);
|
||||
};
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock implementations
|
||||
const mockRouterPush = vi.fn().mockImplementation((...args: string[]) => {
|
||||
console.trace();
|
||||
console.log("mockRouterPush called", args[0]);
|
||||
});
|
||||
const mockUseSession = vi.mocked(useSession);
|
||||
vi.mocked(useRouter).mockReturnValue({
|
||||
push: mockRouterPush,
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
});
|
||||
const mockUsePathname = vi.mocked(usePathname);
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams);
|
||||
|
||||
// Test wrapper for React hooks - no wrapper needed for this test
|
||||
const createWrapper = () => undefined;
|
||||
|
||||
describe("OnboardingStore", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the store and let React do any side effects
|
||||
act(() => {
|
||||
useOnboardingStore.getState().reset();
|
||||
});
|
||||
// Reset localStorage mock
|
||||
localStorageMock.clear.mockClear();
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
|
||||
mockRouterPush.mockClear();
|
||||
|
||||
// Setup default mocks
|
||||
mockUsePathname.mockReturnValue("/settings/organizations/new");
|
||||
mockUseSearchParams.mockReturnValue(createTestSearchParams());
|
||||
|
||||
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useOnboardingStore.getState().reset();
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
mockRouterPush.mockClear();
|
||||
localStorageMock.clear.mockClear();
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
});
|
||||
|
||||
describe("Zustand Store Actions - Complex Logic", () => {
|
||||
describe("Reset Functionality", () => {
|
||||
it("should reset to initial state when called without parameters", () => {
|
||||
const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
|
||||
|
||||
// Set some values
|
||||
act(() => {
|
||||
result.current.setName("Test Org");
|
||||
result.current.setSlug("test-org");
|
||||
result.current.setBillingPeriod("ANNUALLY" as const);
|
||||
result.current.setSeats(15);
|
||||
result.current.addInvitedMember({ email: "test@example.com", name: "Test User" });
|
||||
});
|
||||
|
||||
// Reset
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.name).toBe("");
|
||||
expect(result.current.slug).toBe("");
|
||||
expect(result.current.billingPeriod).toBeUndefined();
|
||||
expect(result.current.seats).toBeNull();
|
||||
expect(result.current.invitedMembers).toEqual([]);
|
||||
});
|
||||
|
||||
it("should set specific state when called with parameters", () => {
|
||||
const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
|
||||
const newState = {
|
||||
name: "Partial Reset Org",
|
||||
slug: "partial-reset",
|
||||
seats: 25,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.reset(newState);
|
||||
});
|
||||
|
||||
expect(result.current.name).toBe("Partial Reset Org");
|
||||
expect(result.current.slug).toBe("partial-reset");
|
||||
expect(result.current.seats).toBe(25);
|
||||
});
|
||||
|
||||
it("should clear localStorage when resetting without parameters", () => {
|
||||
const { result } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
|
||||
|
||||
// Set some values to trigger localStorage save
|
||||
act(() => {
|
||||
result.current.setName("Test Org");
|
||||
});
|
||||
|
||||
// Reset
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
// Verify localStorage removeItem was called
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith("org-creation-onboarding");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useOnboarding Hook", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSession.mockReturnValue(createTestSession());
|
||||
});
|
||||
|
||||
describe("Authentication Handling", () => {
|
||||
it("should redirect to login when session is unauthenticated", async () => {
|
||||
mockUseSession.mockReturnValue(createTestSession({ status: "unauthenticated" }));
|
||||
mockUsePathname.mockReturnValue("/settings/organizations/new");
|
||||
mockUseSearchParams.mockReturnValue(createTestSearchParams());
|
||||
|
||||
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(
|
||||
`/auth/login?callbackUrl=${WEBAPP_URL}/settings/organizations/new`
|
||||
);
|
||||
});
|
||||
mockRouterPush.mockClear();
|
||||
expect(mockRouterPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not redirect when session is authenticated", () => {
|
||||
expect(mockRouterPush).not.toHaveBeenCalled();
|
||||
mockUseSession.mockReturnValue(createTestSession({ status: "authenticated" }));
|
||||
|
||||
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
|
||||
|
||||
expect(mockRouterPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Onboarding State Sync", () => {
|
||||
it("should return organization onboarding data when available", () => {
|
||||
const organizationOnboarding = createTestOrganizationOnboarding({
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
});
|
||||
|
||||
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: organizationOnboarding,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOnboarding(), { wrapper: createWrapper() });
|
||||
|
||||
expect(result.current.dbOnboarding).toEqual(organizationOnboarding);
|
||||
expect(result.current.isLoadingOrgOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it("should set onboardingData from DB to zustand if not set already", async () => {
|
||||
const organizationOnboarding = createTestOrganizationOnboarding({
|
||||
id: "sync-test-id",
|
||||
name: "Synced Organization",
|
||||
slug: "synced-org",
|
||||
bio: "Synced bio content",
|
||||
logo: "synced-logo.png",
|
||||
billingPeriod: "ANNUALLY",
|
||||
pricePerSeat: 25,
|
||||
seats: 10,
|
||||
orgOwnerEmail: "owner@synced.com",
|
||||
});
|
||||
|
||||
(trpc.viewer.organizations.getOrganizationOnboarding.useQuery as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: organizationOnboarding,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderHook(() => useOnboarding(), { wrapper: createWrapper() });
|
||||
const { result: storeResult } = renderHook(() => useOnboardingStore(), { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storeResult.current.onboardingId).toBe("sync-test-id");
|
||||
});
|
||||
|
||||
expect(storeResult.current.name).toBe("Synced Organization");
|
||||
expect(storeResult.current.slug).toBe("synced-org");
|
||||
expect(storeResult.current.bio).toBe("Synced bio content");
|
||||
expect(storeResult.current.logo).toBe("synced-logo.png");
|
||||
expect(storeResult.current.billingPeriod).toBe("ANNUALLY");
|
||||
expect(storeResult.current.pricePerSeat).toBe(25);
|
||||
expect(storeResult.current.seats).toBe(10);
|
||||
expect(storeResult.current.orgOwnerEmail).toBe("owner@synced.com");
|
||||
});
|
||||
|
||||
it("should sync onboardingData from DB to zustand if set already", async () => {
|
||||
mockOrganizationOnboardingFromDB(createTestOrganizationOnboarding({
|
||||
id: "onboarding1",
|
||||
name: "Onboarding 1 Organization",
|
||||
slug: "onboarding1-org",
|
||||
bio: "Onboarding 1 bio content",
|
||||
logo: "onboarding1-logo.png",
|
||||
billingPeriod: "MONTHLY",
|
||||
pricePerSeat: 15,
|
||||
seats: 5,
|
||||
orgOwnerEmail: "owner@onboarding1.com",
|
||||
}))
|
||||
|
||||
// Load the store to set the initial state
|
||||
const { result: storeResultBefore } = renderUseOnboardingStore();
|
||||
|
||||
expect(storeResultBefore.current.name).toBe("Onboarding 1 Organization");
|
||||
expect(storeResultBefore.current.slug).toBe("onboarding1-org");
|
||||
|
||||
mockOrganizationOnboardingFromDB(createTestOrganizationOnboarding({
|
||||
id: "sync-test-id",
|
||||
name: "Synced Organization",
|
||||
slug: "synced-org",
|
||||
bio: "Synced bio content",
|
||||
logo: "synced-logo.png",
|
||||
billingPeriod: "ANNUALLY",
|
||||
pricePerSeat: 25,
|
||||
seats: 10,
|
||||
orgOwnerEmail: "owner@synced.com",
|
||||
}));
|
||||
|
||||
// Load the store to sync the new data
|
||||
const { result: storeResultAfter } = renderUseOnboardingStore();
|
||||
// Verify the data is synced
|
||||
expect(storeResultAfter.current.name).toBe("Synced Organization");
|
||||
expect(storeResultAfter.current.slug).toBe("synced-org");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,13 @@ import { useSession } from "next-auth/react";
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { persist, StorageValue } from "zustand/middleware";
|
||||
|
||||
import { WEBAPP_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { WEBAPP_URL, IS_TEAM_BILLING_ENABLED_CLIENT } from "@calcom/lib/constants";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { BillingPeriod, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
enum BillingPeriod {
|
||||
MONTHLY = "MONTHLY",
|
||||
ANNUALLY = "ANNUALLY",
|
||||
}
|
||||
|
||||
interface OnboardingAdminStoreState {
|
||||
billingPeriod?: BillingPeriod;
|
||||
pricePerSeat?: number | null;
|
||||
@@ -25,6 +21,8 @@ interface OnboardingUserStoreState {
|
||||
slug: string;
|
||||
logo?: string | null;
|
||||
bio?: string | null;
|
||||
brandColor?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
onboardingId: string | null;
|
||||
invitedMembers: { email: string; name?: string }[];
|
||||
teams: { id: number; name: string; slug: string | null; isBeingMigrated: boolean }[];
|
||||
@@ -42,6 +40,8 @@ interface OnboardingStoreState extends OnboardingAdminStoreState, OnboardingUser
|
||||
setSlug: (slug: string) => void;
|
||||
setLogo: (logo: string) => void;
|
||||
setBio: (bio: string) => void;
|
||||
setBrandColor: (brandColor: string) => void;
|
||||
setBannerUrl: (bannerUrl: string) => void;
|
||||
addInvitedMember: (member: { email: string; name?: string }) => void;
|
||||
removeInvitedMember: (email: string) => void;
|
||||
|
||||
@@ -59,6 +59,8 @@ const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = {
|
||||
slug: "",
|
||||
logo: "",
|
||||
bio: "",
|
||||
brandColor: "",
|
||||
bannerUrl: "",
|
||||
orgOwnerEmail: "",
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
@@ -70,7 +72,7 @@ const initialState: OnboardingAdminStoreState & OnboardingUserStoreState = {
|
||||
|
||||
export const useOnboardingStore = create<OnboardingStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Admin actions
|
||||
@@ -84,6 +86,8 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
|
||||
setSlug: (slug) => set({ slug }),
|
||||
setLogo: (logo) => set({ logo }),
|
||||
setBio: (bio) => set({ bio }),
|
||||
setBrandColor: (brandColor) => set({ brandColor }),
|
||||
setBannerUrl: (bannerUrl) => set({ bannerUrl }),
|
||||
setOnboardingId: (onboardingId) => set({ onboardingId }),
|
||||
addInvitedMember: (member) =>
|
||||
set((state) => ({
|
||||
@@ -97,7 +101,6 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
|
||||
// Team actions
|
||||
setTeams: (teams) => set({ teams }),
|
||||
|
||||
// Reset action
|
||||
reset: (state) => {
|
||||
if (state) {
|
||||
set(state);
|
||||
@@ -116,57 +119,59 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
|
||||
)
|
||||
);
|
||||
|
||||
export const useOnboarding = (params?: { step?: "start" | "status" | null }) => {
|
||||
export const useOnboarding = () => {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN;
|
||||
const isBillingEnabled = !(IS_SELF_HOSTED && isAdmin) || process.env.NEXT_PUBLIC_IS_E2E;
|
||||
|
||||
const isBillingEnabled = process.env.NEXT_PUBLIC_IS_E2E ? false : IS_TEAM_BILLING_ENABLED_CLIENT;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } =
|
||||
trpc.viewer.organizations.getOrganizationOnboarding.useQuery();
|
||||
const { reset, onboardingId } = useOnboardingStore();
|
||||
const step = params?.step ?? null;
|
||||
const { reset } = useOnboardingStore();
|
||||
useEffect(() => {
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin on handover page should never touch the store
|
||||
// The DB query returns admin's own onboarding, not the one being handed over
|
||||
if (isAdmin && path?.includes("/handover")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationOnboarding?.isComplete) {
|
||||
reset();
|
||||
}
|
||||
|
||||
if (organizationOnboarding) {
|
||||
// Must not keep resetting state on each step change as every step doesn't save at the moment and this would reset user's changes
|
||||
if (!window.isOrgOnboardingSynced) {
|
||||
window.isOrgOnboardingSynced = true;
|
||||
// Must reset with current state of onboarding in DB for the user
|
||||
reset({
|
||||
onboardingId: organizationOnboarding.id,
|
||||
billingPeriod: organizationOnboarding.billingPeriod as BillingPeriod,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
seats: organizationOnboarding.seats,
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
name: organizationOnboarding.name,
|
||||
slug: organizationOnboarding.slug,
|
||||
bio: organizationOnboarding.bio,
|
||||
logo: organizationOnboarding.logo,
|
||||
});
|
||||
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
|
||||
reset();
|
||||
}
|
||||
// Admin creating for someone else - don't sync from DB, let them proceed to handover
|
||||
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// First step doesn't require onboardingId
|
||||
const requireOnboardingId = step !== "start";
|
||||
|
||||
// Reset to first step if onboardingId isn't available
|
||||
if (!onboardingId && requireOnboardingId) {
|
||||
console.warn("No onboardingId found in store, redirecting to /settings/organizations/new");
|
||||
router.push("/settings/organizations/new");
|
||||
}
|
||||
// Always sync from DB to ensure UI reflects latest changes
|
||||
reset({
|
||||
onboardingId: organizationOnboarding.id,
|
||||
billingPeriod: organizationOnboarding.billingPeriod as BillingPeriod,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
seats: organizationOnboarding.seats,
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
name: organizationOnboarding.name,
|
||||
slug: organizationOnboarding.slug,
|
||||
bio: organizationOnboarding.bio,
|
||||
logo: organizationOnboarding.logo,
|
||||
brandColor: organizationOnboarding.brandColor,
|
||||
bannerUrl: organizationOnboarding.bannerUrl,
|
||||
invitedMembers: [],
|
||||
teams: [],
|
||||
});
|
||||
}
|
||||
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]);
|
||||
// Note: We no longer redirect if onboardingId is missing, as regular users
|
||||
// don't create the onboarding record until the final step
|
||||
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, reset, path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "loading") {
|
||||
|
||||
-540
@@ -1,540 +0,0 @@
|
||||
/**
|
||||
* Goal is to e2e test createOrganizationFromOnboarding except inviteMembersWithNoInviterPermissionCheck, createTeamsHandler and createDomain
|
||||
* Those are either already tested or should be tested out separately
|
||||
*/
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { createDomain } from "@calcom/lib/domainManager/organization";
|
||||
import type { OrganizationOnboarding } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
import { createOrganizationFromOnboarding } from "./createOrganizationFromOnboarding";
|
||||
|
||||
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
|
||||
LicenseKeySingleton: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the external functions
|
||||
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
|
||||
sendEmailVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/emails/email-manager", () => ({
|
||||
sendOrganizationCreationEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({
|
||||
inviteMembersWithNoInviterPermissionCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({
|
||||
createTeamsHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/domainManager/organization", () => ({
|
||||
createDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockOrganizationOnboarding = {
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
orgOwnerEmail: "test@example.com",
|
||||
billingPeriod: "MONTHLY",
|
||||
seats: 5,
|
||||
pricePerSeat: 20,
|
||||
isComplete: false,
|
||||
stripeCustomerId: "mock_stripe_customer_id",
|
||||
createdById: 1,
|
||||
invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }],
|
||||
teams: [
|
||||
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
|
||||
{ id: 2, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
logo: null,
|
||||
bio: null,
|
||||
organizationId: null,
|
||||
isDomainConfigured: false,
|
||||
isPlatform: false,
|
||||
};
|
||||
|
||||
// Helper functions for creating test scenarios
|
||||
async function createTestUser(data: {
|
||||
email: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
metadata?: any;
|
||||
onboardingCompleted?: boolean;
|
||||
emailVerified?: Date | null;
|
||||
}) {
|
||||
return prismock.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name || "Test User",
|
||||
username: data.username || "testuser",
|
||||
metadata: data.metadata || {},
|
||||
completedOnboarding: data.onboardingCompleted,
|
||||
emailVerified: data.emailVerified,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestOrganization(data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
isOrganization?: boolean;
|
||||
metadata?: any;
|
||||
}) {
|
||||
return prismock.team.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
isOrganization: data.isOrganization ?? true,
|
||||
metadata: data.metadata || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestTeam(data: { name: string; slug: string }) {
|
||||
return prismock.team.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
isOrganization: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestOrganizationOnboarding(
|
||||
data?: Partial<typeof mockOrganizationOnboarding> & { organizationId?: number | null }
|
||||
) {
|
||||
console.log("TEST: Creating organization onboarding", data);
|
||||
return prismock.organizationOnboarding.create({
|
||||
data: {
|
||||
...mockOrganizationOnboarding,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createOnboardingEligibleUserAndOnboarding(data: {
|
||||
user?: {
|
||||
email: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
};
|
||||
organizationOnboarding?: Partial<typeof mockOrganizationOnboarding> & { organizationId?: number | null };
|
||||
}) {
|
||||
const user = await createTestUser({
|
||||
...data.user,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding({
|
||||
...data.organizationOnboarding,
|
||||
createdById: user.id,
|
||||
});
|
||||
return { user, organizationOnboarding };
|
||||
}
|
||||
|
||||
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
|
||||
return prismock.membership.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
userId: data.userId,
|
||||
teamId: data.teamId,
|
||||
role: data.role || MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectOrganizationOnboardingToHaveDataContaining({
|
||||
onboardingId,
|
||||
expectedData,
|
||||
}: {
|
||||
onboardingId: string;
|
||||
expectedData: Partial<OrganizationOnboarding>;
|
||||
}) {
|
||||
const updatedOrganizationOnboarding = await prismock.organizationOnboarding.findUnique({
|
||||
where: { id: onboardingId },
|
||||
});
|
||||
expect(updatedOrganizationOnboarding).toEqual(expect.objectContaining(expectedData));
|
||||
}
|
||||
|
||||
describe("createOrganizationFromOnboarding", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
// @ts-expect-error reset is a method on Prismock
|
||||
await prismock.reset();
|
||||
});
|
||||
|
||||
describe("hosted", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Skipped because non-existend user creation support isn't there through onboarding now.
|
||||
it.skip("should create an organization with a non-existent user as owner", async () => {
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding();
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(organizationOnboarding.name);
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
expect(organization.metadata).toEqual({
|
||||
orgSeats: 5,
|
||||
orgPricePerSeat: 20,
|
||||
billingPeriod: "MONTHLY",
|
||||
subscriptionId: "mock_subscription_id",
|
||||
subscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify owner creation
|
||||
expect(owner).toBeDefined();
|
||||
expect(owner.email).toBe(organizationOnboarding.orgOwnerEmail);
|
||||
});
|
||||
|
||||
it("should create an organization with an existing user as owner", async () => {
|
||||
const orgOwnerEmail = "test@example.com";
|
||||
const existingUser = await createTestUser({
|
||||
email: orgOwnerEmail,
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding({
|
||||
orgOwnerEmail,
|
||||
});
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(organizationOnboarding.name);
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
|
||||
// Verify owner is the existing user
|
||||
expect(owner.id).toBe(existingUser.id);
|
||||
expect(owner.email).toBe(existingUser.email);
|
||||
|
||||
expect(createDomain).toHaveBeenCalledWith(organization.slug);
|
||||
// Verify team creation and member invites still work
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orgSlug: organization.slug,
|
||||
teamId: organization.id,
|
||||
invitations: organizationOnboarding.invitedMembers?.map((member) => ({
|
||||
usernameOrEmail: member.email,
|
||||
role: MembershipRole.MEMBER,
|
||||
})),
|
||||
})
|
||||
);
|
||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
orgId: organization.id,
|
||||
teamNames: ["New Team"],
|
||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "new-team-slug" })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should reuse existing organization if organizationId is already set in onboarding. Covers the retry scenario where org got created but then error occurred", async () => {
|
||||
const orgName = "Existing Org";
|
||||
const orgSlug = "existing-org";
|
||||
const existingOrg = await createTestOrganization({
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
});
|
||||
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
organizationId: existingOrg.id,
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
},
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify the existing organization was reused
|
||||
expect(organization.id).toBe(existingOrg.id);
|
||||
});
|
||||
|
||||
it("should throw error if organization with same slug exists", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createTestOrganization({
|
||||
name: "Conflicting Org with same slug",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
it("should create team with slugified slug", async () => {
|
||||
const teamToMove = await createTestTeam({
|
||||
name: "Sales",
|
||||
slug: "Company Sales",
|
||||
});
|
||||
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "UPPERCASE-SLUG",
|
||||
teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
orgId: organization.id,
|
||||
teamNames: [],
|
||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should invite members with isDirectUserAction set to false", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
username: "org-owner",
|
||||
email: "owner@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
// Call createOrganizationFromOnboarding
|
||||
const result = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "sub_123",
|
||||
paymentSubscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
// Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: result.organization.id,
|
||||
invitations: [
|
||||
{ usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
|
||||
{ usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
|
||||
],
|
||||
isDirectUserAction: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should update stripe customer ID for existing user", async () => {
|
||||
const { user: existingUser, organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify user's stripe customer ID was updated
|
||||
const updatedUser = await prismock.user.findUnique({
|
||||
where: { id: existingUser.id },
|
||||
});
|
||||
|
||||
expect(updatedUser?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization in with slug set even if there is a team with same slug of which orgOwner(to-be) is a member", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
const teamWithConflictingSlug = await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlug",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Make the orgOwner a member of the team
|
||||
await createTestMembership({
|
||||
userId: organizationOnboarding.createdById,
|
||||
teamId: teamWithConflictingSlug.id,
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
});
|
||||
|
||||
it("should not create organization if there is a team with same slug of which orgOwner(to-be) is not a member", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Don't make the orgOwner a member of the team
|
||||
// await createTestMembership({
|
||||
// userId: organizationOnboarding.createdById,
|
||||
// teamId: teamWithConflictingSlug.id,
|
||||
// role: MembershipRole.ADMIN,
|
||||
// });
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
it("should not mark the Onboarding as completed(isComplete=true)", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
await expectOrganizationOnboardingToHaveDataContaining({
|
||||
onboardingId: organizationOnboarding.id,
|
||||
expectedData: {
|
||||
isComplete: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should fail if the license is invalid", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance as ReturnType<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,567 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
assertCanCreateOrg,
|
||||
findUserToBeOrgOwner,
|
||||
setupDomain,
|
||||
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import type { OrganizationOnboarding } from "@calcom/prisma/client";
|
||||
import { MembershipRole, CreationSource } from "@calcom/prisma/enums";
|
||||
import {
|
||||
userMetadata,
|
||||
orgOnboardingInvitedMembersSchema,
|
||||
orgOnboardingTeamsSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
|
||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
// Onboarding can only be done from webapp currently and so we consider the source for User as WEBAPP
|
||||
const creationSource = CreationSource.WEBAPP;
|
||||
const log = logger.getSubLogger({ prefix: ["createOrganizationFromOnboarding"] });
|
||||
// Types for organization data
|
||||
type OrganizationData = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOrganizationConfigured: boolean;
|
||||
isOrganizationAdminReviewed: boolean;
|
||||
autoAcceptEmail: string;
|
||||
seats: number | null;
|
||||
pricePerSeat: number | null;
|
||||
isPlatform: boolean;
|
||||
logoUrl: string | null;
|
||||
bio: string | null;
|
||||
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
||||
};
|
||||
|
||||
type TeamData = {
|
||||
id: number;
|
||||
name: string;
|
||||
isBeingMigrated: boolean;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
type InvitedMember = {
|
||||
email: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type OrgOwner = Awaited<ReturnType<typeof findUserToBeOrgOwner>>;
|
||||
|
||||
type OrganizationOnboardingArg = Pick<
|
||||
OrganizationOnboarding,
|
||||
| "id"
|
||||
| "organizationId"
|
||||
| "name"
|
||||
| "slug"
|
||||
| "orgOwnerEmail"
|
||||
| "seats"
|
||||
| "pricePerSeat"
|
||||
| "billingPeriod"
|
||||
| "invitedMembers"
|
||||
| "teams"
|
||||
| "isPlatform"
|
||||
| "logo"
|
||||
| "bio"
|
||||
| "stripeCustomerId"
|
||||
| "isDomainConfigured"
|
||||
>;
|
||||
|
||||
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
|
||||
const teamsSchema = orgOnboardingTeamsSchema;
|
||||
|
||||
async function createOrganizationWithExistingUserAsOwner({
|
||||
owner,
|
||||
orgData,
|
||||
}: {
|
||||
owner: NonNullable<Awaited<ReturnType<typeof findUserToBeOrgOwner>>>;
|
||||
orgData: OrganizationData;
|
||||
}) {
|
||||
const orgOwnerTranslation = await getTranslation(owner.locale || "en", "common");
|
||||
// We prefer ID which would be available if it is a retry of createOrganizationFromOnboarding and earlier organization was created and connected with Onboarding
|
||||
let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null;
|
||||
|
||||
if (organization) {
|
||||
log.info(
|
||||
`Reusing existing organization:`,
|
||||
safeStringify({
|
||||
slug: orgData.slug,
|
||||
id: organization.id,
|
||||
})
|
||||
);
|
||||
return { organization };
|
||||
}
|
||||
|
||||
const { slugConflictType } = await assertCanCreateOrg({
|
||||
slug: orgData.slug,
|
||||
isPlatform: orgData.isPlatform,
|
||||
orgOwner: owner,
|
||||
errorOnUserAlreadyPartOfOrg: false,
|
||||
// If it would have been restricted, then it would have been done already in intentToCreateOrgHandler
|
||||
// This restriction doesn't apply for admin as we would have validated it already in intentToCreateOrgHandler, we just relax it here as we don't know who created the organization.
|
||||
// TODO: If needed, we could start tracking if the onboarding is being done by instance Admin and relax it then only.
|
||||
restrictBasedOnMinimumPublishedTeams: false,
|
||||
});
|
||||
|
||||
// In this case we create the organization with slug=null. Let the teams migrate and then the conflict would go away.
|
||||
const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true;
|
||||
|
||||
log.info(
|
||||
`Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}`
|
||||
);
|
||||
|
||||
try {
|
||||
const nonOrgUsername = owner.username || "";
|
||||
const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({
|
||||
orgData: {
|
||||
...orgData,
|
||||
...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }),
|
||||
},
|
||||
owner: {
|
||||
id: owner.id,
|
||||
email: owner.email,
|
||||
nonOrgUsername,
|
||||
},
|
||||
});
|
||||
organization = orgCreationResult.organization;
|
||||
const ownerProfile = orgCreationResult.ownerProfile;
|
||||
|
||||
// Send organization creation email
|
||||
if (!orgData.isPlatform) {
|
||||
await sendOrganizationCreationEmail({
|
||||
language: orgOwnerTranslation,
|
||||
from: `${organization.name}'s admin`,
|
||||
to: owner.email,
|
||||
ownerNewUsername: ownerProfile.username,
|
||||
ownerOldUsername: nonOrgUsername,
|
||||
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
|
||||
orgName: organization.name,
|
||||
prevLink: `${getOrgFullOrigin("", { protocol: true })}/${owner.username || ""}`,
|
||||
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Set up default availability
|
||||
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
|
||||
await prisma.availability.createMany({
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
userId: owner.id,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
// Catch and log so that redactError doesn't redact the log message
|
||||
log.error(
|
||||
`RecoverableError: Error creating organization for owner ${owner.email}.`,
|
||||
safeStringify(error)
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { organization };
|
||||
}
|
||||
|
||||
async function createOrganizationWithNonExistentUserAsOwner({
|
||||
email,
|
||||
orgData,
|
||||
}: {
|
||||
email: string;
|
||||
orgData: OrganizationData;
|
||||
}) {
|
||||
let organization = orgData.id
|
||||
? await OrganizationRepository.findById({ id: orgData.id })
|
||||
: await OrganizationRepository.findBySlug({ slug: orgData.slug });
|
||||
|
||||
if (organization) {
|
||||
log.info(
|
||||
`createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`,
|
||||
safeStringify({ slug: orgData.slug, id: organization.id })
|
||||
);
|
||||
const owner = await findUserToBeOrgOwner(email);
|
||||
if (!owner) {
|
||||
// Can happen when the organization was created earlier and the webhook had failed and when the webhook got fired again in next subscription update, then the email was deleted already
|
||||
// The fix would be to change the email in Onboarding record to new owner of the organization
|
||||
// TODO: Identify the owner of the organization from Membership table and use that email instead here.
|
||||
throw new Error(`Org exists but owner could not be found for email: ${email}`);
|
||||
}
|
||||
return { organization, owner };
|
||||
}
|
||||
|
||||
const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({
|
||||
orgData,
|
||||
owner: {
|
||||
email: email,
|
||||
},
|
||||
creationSource,
|
||||
});
|
||||
organization = orgCreationResult.organization;
|
||||
const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult;
|
||||
const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email);
|
||||
if (!orgOwner) {
|
||||
throw new Error(
|
||||
`Just created the owner ${orgOwnerFromCreation.email}, but couldn't find it in the database`
|
||||
);
|
||||
}
|
||||
|
||||
const translation = await getTranslation(orgOwner.locale ?? "en", "common");
|
||||
|
||||
await sendEmailVerification({
|
||||
email: orgOwner.email,
|
||||
language: orgOwner.locale ?? "en",
|
||||
username: ownerProfile.username || "",
|
||||
isPlatform: orgData.isPlatform,
|
||||
});
|
||||
|
||||
if (!orgData.isPlatform) {
|
||||
await sendOrganizationCreationEmail({
|
||||
language: translation,
|
||||
from: orgOwner.name ?? `${organization.name}'s admin`,
|
||||
to: orgOwner.email,
|
||||
ownerNewUsername: ownerProfile.username,
|
||||
ownerOldUsername: null,
|
||||
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
|
||||
orgName: organization.name,
|
||||
prevLink: null,
|
||||
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
organization,
|
||||
owner: orgOwner,
|
||||
};
|
||||
}
|
||||
|
||||
async function createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, organizationId: number) {
|
||||
if (teams.length === 0) return;
|
||||
|
||||
const teamsToCreate = teams.filter((team) => !team.isBeingMigrated).map((team) => team.name);
|
||||
const teamsToMove = teams
|
||||
.filter((team) => team.isBeingMigrated)
|
||||
.map((team) => ({
|
||||
id: team.id,
|
||||
newSlug: team.slug ? slugify(team.slug) : team.slug,
|
||||
shouldMove: true,
|
||||
}));
|
||||
|
||||
log.info(
|
||||
`Creating ${teamsToCreate} teams and moving ${teamsToMove.map(
|
||||
(team) => team.newSlug
|
||||
)} teams for organization ${organizationId}`
|
||||
);
|
||||
|
||||
await createTeamsHandler({
|
||||
ctx: {
|
||||
user: {
|
||||
...owner,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
teamNames: teamsToCreate,
|
||||
orgId: organizationId,
|
||||
moveTeams: teamsToMove,
|
||||
creationSource,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
`Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function inviteMembers(invitedMembers: InvitedMember[], organization: Team) {
|
||||
if (invitedMembers.length === 0) return;
|
||||
|
||||
log.info(`Inviting ${invitedMembers.length} members to organization ${organization.id}`);
|
||||
await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: null,
|
||||
teamId: organization.id,
|
||||
language: "en",
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
orgSlug: organization.slug || null,
|
||||
invitations: invitedMembers.map((member) => ({
|
||||
usernameOrEmail: member.email,
|
||||
role: MembershipRole.MEMBER,
|
||||
})),
|
||||
isDirectUserAction: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureStripeCustomerIdIsUpdated({
|
||||
owner,
|
||||
stripeCustomerId,
|
||||
}: {
|
||||
owner: User;
|
||||
stripeCustomerId: string;
|
||||
}) {
|
||||
const parsedMetadata = userMetadata.parse(owner.metadata);
|
||||
|
||||
await new UserRepository(prisma).updateStripeCustomerId({
|
||||
id: owner.id,
|
||||
stripeCustomerId: stripeCustomerId,
|
||||
existingMetadata: parsedMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary till we adapt all other code reading from metadata about stripeSubscriptionId and stripeSubscriptionItemId
|
||||
*/
|
||||
async function backwardCompatibilityForSubscriptionDetails({
|
||||
organization,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
}: {
|
||||
organization: {
|
||||
id: number;
|
||||
metadata: Prisma.JsonValue;
|
||||
};
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) {
|
||||
if (!paymentSubscriptionId || !paymentSubscriptionItemId) {
|
||||
return organization;
|
||||
}
|
||||
|
||||
const existingMetadata = teamMetadataStrictSchema.parse(organization.metadata);
|
||||
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
|
||||
id: organization.id,
|
||||
stripeSubscriptionId: paymentSubscriptionId,
|
||||
stripeSubscriptionItemId: paymentSubscriptionItemId,
|
||||
existingMetadata,
|
||||
});
|
||||
return updatedOrganization;
|
||||
}
|
||||
|
||||
async function hasConflictingOrganization({ slug, onboardingId }: { slug: string; onboardingId: string }) {
|
||||
const organization = await OrganizationRepository.findBySlugIncludeOnboarding({ slug });
|
||||
if (!organization?.organizationOnboarding) {
|
||||
// Old organizations created without onboarding,
|
||||
return false;
|
||||
}
|
||||
|
||||
// Different onboardingId means existing organization is owned by someone else
|
||||
return organization.organizationOnboarding.id !== onboardingId;
|
||||
}
|
||||
|
||||
async function handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
}: {
|
||||
organizationOnboarding: OrganizationOnboardingArg;
|
||||
orgOwnerTranslation: TFunction;
|
||||
}) {
|
||||
if (!organizationOnboarding.isDomainConfigured) {
|
||||
// Important: Setup Domain first before creating the organization as Vercel/Cloudflare might not allow the domain to be created and that could cause organization booking pages to not actually work
|
||||
await setupDomain({
|
||||
slug: organizationOnboarding.slug,
|
||||
isPlatform: organizationOnboarding.isPlatform,
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
}
|
||||
|
||||
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
||||
isDomainConfigured: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrganizationCreation({
|
||||
organizationOnboarding,
|
||||
owner,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
}: {
|
||||
organizationOnboarding: OrganizationOnboardingArg;
|
||||
owner: OrgOwner;
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) {
|
||||
if (IS_SELF_HOSTED) {
|
||||
const deploymentRepo = new DeploymentRepository(prisma);
|
||||
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
|
||||
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||
|
||||
if (!hasValidLicense) {
|
||||
throw new Error("Self hosted license not valid");
|
||||
}
|
||||
}
|
||||
|
||||
let organization;
|
||||
const orgData = {
|
||||
id: organizationOnboarding.organizationId,
|
||||
name: organizationOnboarding.name,
|
||||
// Create organization with slug=null, so that org slug doesn't conflict with existing team that is probably being migrated and thus would not be a conflict any more
|
||||
// Remember one can have a subteam with same slug as some organization, but regular team and organization can't have same slug
|
||||
slug: organizationOnboarding.slug,
|
||||
isOrganizationConfigured: true,
|
||||
// We believe that as the payment has been accepted first and we also restrict the emails to company emails, it is safe to set this to true.
|
||||
// We could easily set it to false if needed. In effect, it disables impersonations by non-admin reviewed organization's owner and also disables editing the member details
|
||||
isOrganizationAdminReviewed: true,
|
||||
autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1],
|
||||
seats: organizationOnboarding.seats,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
isPlatform: false,
|
||||
billingPeriod: organizationOnboarding.billingPeriod,
|
||||
logoUrl: organizationOnboarding.logo,
|
||||
bio: organizationOnboarding.bio,
|
||||
};
|
||||
|
||||
log.info(
|
||||
"handleOrganizationCreation",
|
||||
safeStringify({ orgId: organizationOnboarding.organizationId, orgSlug: organizationOnboarding.slug })
|
||||
);
|
||||
if (!owner) {
|
||||
const result = await createOrganizationWithNonExistentUserAsOwner({
|
||||
email: organizationOnboarding.orgOwnerEmail,
|
||||
orgData,
|
||||
});
|
||||
organization = result.organization;
|
||||
owner = result.owner;
|
||||
} else {
|
||||
const result = await createOrganizationWithExistingUserAsOwner({
|
||||
orgData,
|
||||
owner,
|
||||
});
|
||||
organization = result.organization;
|
||||
}
|
||||
|
||||
if (organizationOnboarding.stripeCustomerId) {
|
||||
// Mostly needed for newly created user through the flow, existing user would have it already when checkout was created
|
||||
// We need to set it there to ensure that for the same user new customerId is not created again
|
||||
await ensureStripeCustomerIdIsUpdated({
|
||||
owner,
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Connect the organization to the onboarding
|
||||
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const updatedOrganization = await backwardCompatibilityForSubscriptionDetails({
|
||||
organization,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
});
|
||||
|
||||
return {
|
||||
organization: {
|
||||
...organization,
|
||||
metadata: updatedOrganization.metadata,
|
||||
},
|
||||
owner,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used by stripe webhook, so it should expect to be called multiple times till the entire flow completes without any error.
|
||||
* So, it should be idempotent.
|
||||
*/
|
||||
export const createOrganizationFromOnboarding = async ({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
}: {
|
||||
organizationOnboarding: OrganizationOnboardingArg;
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) => {
|
||||
log.info(
|
||||
"createOrganizationFromOnboarding",
|
||||
safeStringify({
|
||||
orgId: organizationOnboarding.organizationId,
|
||||
orgSlug: organizationOnboarding.slug,
|
||||
})
|
||||
);
|
||||
|
||||
if (!IS_SELF_HOSTED && (!paymentSubscriptionId || !paymentSubscriptionItemId)) {
|
||||
throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required");
|
||||
}
|
||||
|
||||
if (
|
||||
await hasConflictingOrganization({
|
||||
slug: organizationOnboarding.slug,
|
||||
onboardingId: organizationOnboarding.id,
|
||||
})
|
||||
) {
|
||||
throw new Error("organization_already_exists_with_this_slug");
|
||||
}
|
||||
|
||||
// We know admin permissions have been validated in the above step so we can safely normalize the input
|
||||
const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
|
||||
const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common");
|
||||
|
||||
// TODO: We need to send emails to admin in the case of SELF_HOSTING where NEXT_PUBLIC_SINGLE_ORG_SLUG isn't set
|
||||
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
|
||||
await handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
}
|
||||
|
||||
const { organization, owner } = await handleOrganizationCreation({
|
||||
organizationOnboarding,
|
||||
owner: userFromEmail,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
});
|
||||
|
||||
await inviteMembers(invitedMembersSchema.parse(organizationOnboarding.invitedMembers), organization);
|
||||
|
||||
await createOrMoveTeamsToOrganization(
|
||||
teamsSchema.parse(organizationOnboarding.teams),
|
||||
owner,
|
||||
organization.id
|
||||
);
|
||||
|
||||
// If the organization was created with slug=null, then set the slug now, assuming that the team having the same slug is migrated now
|
||||
// If the team wasn't owned by the orgOwner, then org creation would have failed and we wouldn't be here
|
||||
if (!organization.slug) {
|
||||
try {
|
||||
const { slug } = await OrganizationRepository.setSlug({
|
||||
id: organization.id,
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
organization.slug = slug;
|
||||
} catch (error) {
|
||||
// Almost always the reason would be that the organization's slug conflicts with a team's slug
|
||||
// The owner might not have chosen the conflicting team for migration - Can be confirmed by checking `teams` column in the database.
|
||||
log.error(
|
||||
"RecoverableError: Error while setting slug for organization",
|
||||
safeStringify(error),
|
||||
safeStringify({
|
||||
attemptedSlug: organizationOnboarding.slug,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { organization, owner };
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isNotACompanyEmail } from "./orgCreationUtils";
|
||||
|
||||
describe("orgCreationUtils", () => {
|
||||
describe("isNotACompanyEmail", () => {
|
||||
it("should return true for gmail.com email", () => {
|
||||
expect(isNotACompanyEmail("user@gmail.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for yahoo.com email", () => {
|
||||
expect(isNotACompanyEmail("user@yahoo.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for outlook.com email", () => {
|
||||
expect(isNotACompanyEmail("user@outlook.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for hotmail.com email", () => {
|
||||
expect(isNotACompanyEmail("user@hotmail.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for protonmail.com email", () => {
|
||||
expect(isNotACompanyEmail("user@protonmail.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for proton.me email", () => {
|
||||
expect(isNotACompanyEmail("user@proton.me")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for icloud.com email", () => {
|
||||
expect(isNotACompanyEmail("user@icloud.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for live.com email", () => {
|
||||
expect(isNotACompanyEmail("user@live.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for company email", () => {
|
||||
expect(isNotACompanyEmail("user@company.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for company email with subdomain", () => {
|
||||
expect(isNotACompanyEmail("user@mail.company.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for company email with country code", () => {
|
||||
expect(isNotACompanyEmail("user@company.co.uk")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for invalid email without @", () => {
|
||||
expect(isNotACompanyEmail("invalidemail")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for empty email", () => {
|
||||
expect(isNotACompanyEmail("")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for email with multiple @ symbols", () => {
|
||||
expect(isNotACompanyEmail("user@@company.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for all popular personal email providers", () => {
|
||||
const personalProviders = [
|
||||
"gmail.com",
|
||||
"yahoo.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"aol.com",
|
||||
"icloud.com",
|
||||
"mail.com",
|
||||
"protonmail.com",
|
||||
"proton.me",
|
||||
"zoho.com",
|
||||
"yandex.com",
|
||||
"gmx.com",
|
||||
"fastmail.com",
|
||||
"inbox.com",
|
||||
"me.com",
|
||||
"hushmail.com",
|
||||
"live.com",
|
||||
"rediffmail.com",
|
||||
"tutanota.com",
|
||||
"mail.ru",
|
||||
"usa.com",
|
||||
"qq.com",
|
||||
"163.com",
|
||||
"web.de",
|
||||
"rocketmail.com",
|
||||
"excite.com",
|
||||
"lycos.com",
|
||||
"outlook.co",
|
||||
"hotmail.co.uk",
|
||||
];
|
||||
|
||||
personalProviders.forEach((provider) => {
|
||||
expect(isNotACompanyEmail(`user@${provider}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false for various company domains", () => {
|
||||
const companyDomains = [
|
||||
"acme.com",
|
||||
"techcorp.io",
|
||||
"business.co",
|
||||
"startup.ai",
|
||||
"enterprise.net",
|
||||
"cal.com",
|
||||
];
|
||||
|
||||
companyDomains.forEach((domain) => {
|
||||
expect(isNotACompanyEmail(`user@${domain}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { lookup } from "dns";
|
||||
import { type TFunction } from "i18next";
|
||||
|
||||
import { sendAdminOrganizationNotification } from "@calcom/emails";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import {
|
||||
RESERVED_SUBDOMAINS,
|
||||
ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE,
|
||||
@@ -20,7 +21,7 @@ const log = logger.getSubLogger({ prefix: ["orgCreationUtils"] });
|
||||
/**
|
||||
* We can only say for sure that the email is not a company email. We can't say for sure if it is a company email.
|
||||
*/
|
||||
function isNotACompanyEmail(email: string) {
|
||||
export function isNotACompanyEmail(email: string) {
|
||||
// A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization
|
||||
const emailProviders = [
|
||||
"gmail.com",
|
||||
@@ -185,7 +186,13 @@ export async function assertCanCreateOrg({
|
||||
restrictBasedOnMinimumPublishedTeams: boolean;
|
||||
errorOnUserAlreadyPartOfOrg?: boolean;
|
||||
}) {
|
||||
const verifiedUser = orgOwner.completedOnboarding && !!orgOwner.emailVerified;
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"email-verification"
|
||||
);
|
||||
|
||||
const verifiedUser =
|
||||
orgOwner.completedOnboarding && (emailVerificationEnabled ? !!orgOwner.emailVerified : true);
|
||||
if (!verifiedUser) {
|
||||
log.warn(
|
||||
"you_need_to_complete_user_onboarding_before_creating_an_organization",
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
assertCanCreateOrg,
|
||||
findUserToBeOrgOwner,
|
||||
setupDomain,
|
||||
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma, Team, User } from "@calcom/prisma/client";
|
||||
import { CreationSource, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import {
|
||||
userMetadata,
|
||||
orgOnboardingInvitedMembersSchema,
|
||||
orgOnboardingTeamsSchema,
|
||||
teamMetadataStrictSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
import { OrganizationPaymentService } from "../../OrganizationPaymentService";
|
||||
import { OrganizationPermissionService } from "../../OrganizationPermissionService";
|
||||
import type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService";
|
||||
import type {
|
||||
TeamInput,
|
||||
InvitedMemberInput,
|
||||
CreateOnboardingIntentInput,
|
||||
OnboardingUser,
|
||||
OrganizationData,
|
||||
TeamData,
|
||||
InvitedMember,
|
||||
OrganizationOnboardingData,
|
||||
OnboardingIntentResult,
|
||||
} from "./types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["BaseOnboardingService"] });
|
||||
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
|
||||
const teamsSchema = orgOnboardingTeamsSchema;
|
||||
|
||||
type OrgOwner = Awaited<ReturnType<typeof findUserToBeOrgOwner>>;
|
||||
|
||||
export abstract class BaseOnboardingService implements IOrganizationOnboardingService {
|
||||
protected user: OnboardingUser;
|
||||
protected paymentService: OrganizationPaymentService;
|
||||
protected permissionService: OrganizationPermissionService;
|
||||
|
||||
constructor(
|
||||
user: OnboardingUser,
|
||||
paymentService?: OrganizationPaymentService,
|
||||
permissionService?: OrganizationPermissionService
|
||||
) {
|
||||
this.user = user;
|
||||
this.paymentService = paymentService || new OrganizationPaymentService(user);
|
||||
this.permissionService = permissionService || new OrganizationPermissionService(user);
|
||||
}
|
||||
|
||||
abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any>;
|
||||
abstract createOrganization(
|
||||
organizationOnboarding: OrganizationOnboardingData,
|
||||
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
|
||||
): Promise<{ organization: Team; owner: User }>;
|
||||
|
||||
protected async createOnboardingRecord(input: CreateOnboardingIntentInput & { onboardingId?: string }) {
|
||||
// If onboardingId exists, update the existing record (resume flow)
|
||||
if (input.onboardingId) {
|
||||
log.debug(
|
||||
"Updating existing organization onboarding record (resume flow)",
|
||||
safeStringify({
|
||||
onboardingId: input.onboardingId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
})
|
||||
);
|
||||
|
||||
await OrganizationOnboardingRepository.update(input.onboardingId, {
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
teams: input.teams ?? [],
|
||||
invitedMembers: input.invitedMembers ?? [],
|
||||
});
|
||||
|
||||
const updatedOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId);
|
||||
if (!updatedOnboarding) {
|
||||
throw new Error(`Onboarding record ${input.onboardingId} not found after update`);
|
||||
}
|
||||
|
||||
log.debug("Organization onboarding updated", safeStringify({ onboardingId: updatedOnboarding.id }));
|
||||
return updatedOnboarding;
|
||||
}
|
||||
|
||||
// Create new onboarding record
|
||||
log.debug(
|
||||
"Creating organization onboarding record",
|
||||
safeStringify({
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
isPlatform: input.isPlatform,
|
||||
})
|
||||
);
|
||||
|
||||
const organizationOnboarding = await this.paymentService.createOrganizationOnboarding({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
seats: input.seats,
|
||||
pricePerSeat: input.pricePerSeat,
|
||||
billingPeriod: input.billingPeriod,
|
||||
createdByUserId: this.user.id,
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
teams: input.teams ?? [],
|
||||
invitedMembers: input.invitedMembers ?? [],
|
||||
});
|
||||
|
||||
log.debug("Organization onboarding created", safeStringify({ onboardingId: organizationOnboarding.id }));
|
||||
|
||||
return organizationOnboarding;
|
||||
}
|
||||
|
||||
protected filterTeamsAndInvites(teams: TeamInput[] = [], invitedMembers: InvitedMemberInput[] = []) {
|
||||
const teamsData = teams
|
||||
.filter((team) => team.name.trim().length > 0)
|
||||
.map((team) => ({
|
||||
id: team.id === -1 ? -1 : team.id,
|
||||
name: team.name,
|
||||
isBeingMigrated: team.isBeingMigrated,
|
||||
slug: team.slug,
|
||||
}));
|
||||
|
||||
const invitedMembersData = invitedMembers
|
||||
.filter((invite) => invite.email.trim().length > 0)
|
||||
.map((invite) => ({
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
teamId: invite.teamId,
|
||||
teamName: invite.teamName,
|
||||
role: invite.role,
|
||||
}));
|
||||
|
||||
return { teamsData, invitedMembersData };
|
||||
}
|
||||
|
||||
protected handleAdminHandoverIfNeeded(
|
||||
input: CreateOnboardingIntentInput,
|
||||
onboardingId: string
|
||||
): OnboardingIntentResult | null {
|
||||
const isAdminHandoverFlow =
|
||||
this.user.role === UserPermissionRole.ADMIN &&
|
||||
this.user.email !== input.orgOwnerEmail &&
|
||||
(!input.teams || input.teams.length === 0) &&
|
||||
(!input.invitedMembers || input.invitedMembers.length === 0);
|
||||
|
||||
log.debug(
|
||||
"Checking admin handover flow",
|
||||
safeStringify({
|
||||
userRole: this.user.role,
|
||||
isAdmin: this.user.role === UserPermissionRole.ADMIN,
|
||||
userEmail: this.user.email,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
emailsDifferent: this.user.email !== input.orgOwnerEmail,
|
||||
teamsEmpty: !input.teams || input.teams.length === 0,
|
||||
membersEmpty: !input.invitedMembers || input.invitedMembers.length === 0,
|
||||
isAdminHandoverFlow,
|
||||
})
|
||||
);
|
||||
|
||||
if (!isAdminHandoverFlow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Admin handover flow detected",
|
||||
safeStringify({ adminEmail: this.user.email, orgOwnerEmail: input.orgOwnerEmail })
|
||||
);
|
||||
|
||||
return {
|
||||
userId: this.user.id,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
seats: input.seats ?? null,
|
||||
pricePerSeat: input.pricePerSeat ?? null,
|
||||
billingPeriod: input.billingPeriod,
|
||||
isPlatform: input.isPlatform,
|
||||
organizationOnboardingId: onboardingId,
|
||||
checkoutUrl: null,
|
||||
organizationId: null,
|
||||
handoverUrl: `${WEBAPP_URL}/settings/organizations/new/resume?onboardingId=${onboardingId}`,
|
||||
};
|
||||
}
|
||||
|
||||
protected isAdminCreatingForSelf(input: { orgOwnerEmail: string }): boolean {
|
||||
return this.user.role === UserPermissionRole.ADMIN && this.user.email === input.orgOwnerEmail;
|
||||
}
|
||||
|
||||
protected async createOrganizationWithExistingUserAsOwner({
|
||||
owner,
|
||||
orgData,
|
||||
}: {
|
||||
owner: NonNullable<Awaited<ReturnType<typeof findUserToBeOrgOwner>>>;
|
||||
orgData: OrganizationData;
|
||||
}) {
|
||||
const orgOwnerTranslation = await getTranslation(owner.locale || "en", "common");
|
||||
let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null;
|
||||
|
||||
if (organization) {
|
||||
log.info(
|
||||
`Reusing existing organization:`,
|
||||
safeStringify({
|
||||
slug: orgData.slug,
|
||||
id: organization.id,
|
||||
})
|
||||
);
|
||||
return { organization };
|
||||
}
|
||||
|
||||
const { slugConflictType } = await assertCanCreateOrg({
|
||||
slug: orgData.slug,
|
||||
isPlatform: orgData.isPlatform,
|
||||
orgOwner: owner,
|
||||
errorOnUserAlreadyPartOfOrg: false,
|
||||
restrictBasedOnMinimumPublishedTeams: false,
|
||||
});
|
||||
|
||||
const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true;
|
||||
|
||||
log.info(
|
||||
`Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}`
|
||||
);
|
||||
|
||||
try {
|
||||
const nonOrgUsername = owner.username || "";
|
||||
const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({
|
||||
orgData: {
|
||||
...orgData,
|
||||
...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }),
|
||||
},
|
||||
owner: {
|
||||
id: owner.id,
|
||||
email: owner.email,
|
||||
nonOrgUsername,
|
||||
},
|
||||
});
|
||||
organization = orgCreationResult.organization;
|
||||
const ownerProfile = orgCreationResult.ownerProfile;
|
||||
|
||||
if (!orgData.isPlatform) {
|
||||
await sendOrganizationCreationEmail({
|
||||
language: orgOwnerTranslation,
|
||||
from: `${organization.name}'s admin`,
|
||||
to: owner.email,
|
||||
ownerNewUsername: ownerProfile.username,
|
||||
ownerOldUsername: nonOrgUsername,
|
||||
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
|
||||
orgName: organization.name,
|
||||
prevLink: `${getOrgFullOrigin("", { protocol: true })}/${owner.username || ""}`,
|
||||
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
|
||||
});
|
||||
}
|
||||
|
||||
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
|
||||
await prisma.availability.createMany({
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
userId: owner.id,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`RecoverableError: Error creating organization for owner ${owner.email}.`,
|
||||
safeStringify(error)
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { organization };
|
||||
}
|
||||
|
||||
protected async createOrganizationWithNonExistentUserAsOwner({
|
||||
email,
|
||||
orgData,
|
||||
}: {
|
||||
email: string;
|
||||
orgData: OrganizationData;
|
||||
}) {
|
||||
let organization = orgData.id
|
||||
? await OrganizationRepository.findById({ id: orgData.id })
|
||||
: await OrganizationRepository.findBySlug({ slug: orgData.slug });
|
||||
|
||||
if (organization) {
|
||||
log.info(
|
||||
`createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`,
|
||||
safeStringify({ slug: orgData.slug, id: organization.id })
|
||||
);
|
||||
const owner = await findUserToBeOrgOwner(email);
|
||||
if (!owner) {
|
||||
throw new Error(`Org exists but owner could not be found for email: ${email}`);
|
||||
}
|
||||
return { organization, owner };
|
||||
}
|
||||
|
||||
const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({
|
||||
orgData,
|
||||
owner: {
|
||||
email: email,
|
||||
},
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
});
|
||||
organization = orgCreationResult.organization;
|
||||
const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult;
|
||||
const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email);
|
||||
if (!orgOwner) {
|
||||
throw new Error(
|
||||
`Just created the owner ${orgOwnerFromCreation.email}, but couldn't find it in the database`
|
||||
);
|
||||
}
|
||||
|
||||
const translation = await getTranslation(orgOwner.locale ?? "en", "common");
|
||||
|
||||
await sendEmailVerification({
|
||||
email: orgOwner.email,
|
||||
language: orgOwner.locale ?? "en",
|
||||
username: ownerProfile.username || "",
|
||||
isPlatform: orgData.isPlatform,
|
||||
});
|
||||
|
||||
if (!orgData.isPlatform) {
|
||||
await sendOrganizationCreationEmail({
|
||||
language: translation,
|
||||
from: orgOwner.name ?? `${organization.name}'s admin`,
|
||||
to: orgOwner.email,
|
||||
ownerNewUsername: ownerProfile.username,
|
||||
ownerOldUsername: null,
|
||||
orgDomain: getOrgFullOrigin(orgData.slug, { protocol: false }),
|
||||
orgName: organization.name,
|
||||
prevLink: null,
|
||||
newLink: `${getOrgFullOrigin(orgData.slug, { protocol: true })}/${ownerProfile.username}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
organization,
|
||||
owner: orgOwner,
|
||||
};
|
||||
}
|
||||
|
||||
protected async createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, organizationId: number) {
|
||||
if (teams.length === 0) return;
|
||||
|
||||
const teamsToCreate = teams.filter((team) => !team.isBeingMigrated).map((team) => team.name);
|
||||
const teamsToMove = teams
|
||||
.filter((team) => team.isBeingMigrated)
|
||||
.map((team) => ({
|
||||
id: team.id,
|
||||
newSlug: team.slug ? slugify(team.slug) : team.slug,
|
||||
shouldMove: true,
|
||||
}));
|
||||
|
||||
log.info(
|
||||
`Creating ${teamsToCreate} teams and moving ${teamsToMove.map(
|
||||
(team) => team.newSlug
|
||||
)} teams for organization ${organizationId}`
|
||||
);
|
||||
|
||||
await createTeamsHandler({
|
||||
ctx: {
|
||||
user: {
|
||||
...owner,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
teamNames: teamsToCreate,
|
||||
orgId: organizationId,
|
||||
moveTeams: teamsToMove,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
`Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}`
|
||||
);
|
||||
}
|
||||
|
||||
protected async inviteMembers(invitedMembers: InvitedMember[], organization: Team, teamsData: TeamData[]) {
|
||||
if (invitedMembers.length === 0) return;
|
||||
|
||||
log.info(
|
||||
`Processing ${invitedMembers.length} member invites for organization ${organization.id}`,
|
||||
safeStringify({
|
||||
invitedMembers: invitedMembers.map((m) => ({
|
||||
email: m.email,
|
||||
teamId: m.teamId,
|
||||
teamName: m.teamName,
|
||||
role: m.role,
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
const createdTeams = await prisma.team.findMany({
|
||||
where: {
|
||||
parentId: organization.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
`Found ${createdTeams.length} teams in organization`,
|
||||
safeStringify({
|
||||
teams: createdTeams.map((t) => ({ id: t.id, name: t.name })),
|
||||
})
|
||||
);
|
||||
|
||||
const teamNameToId = new Map<string, number>();
|
||||
createdTeams.forEach((team) => {
|
||||
teamNameToId.set(team.name.toLowerCase(), team.id);
|
||||
});
|
||||
|
||||
const teamIdMap = new Map<number, number>();
|
||||
teamsData.forEach((teamData) => {
|
||||
if (teamData.isBeingMigrated) {
|
||||
teamIdMap.set(teamData.id, teamData.id);
|
||||
} else {
|
||||
const createdTeam = createdTeams.find((t) => t.name === teamData.name);
|
||||
if (createdTeam) {
|
||||
teamIdMap.set(teamData.id, createdTeam.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const invitesToOrg: InvitedMember[] = [];
|
||||
const invitesToTeams = new Map<number, InvitedMember[]>();
|
||||
|
||||
for (const member of invitedMembers) {
|
||||
let targetTeamId: number | undefined;
|
||||
|
||||
if (member.teamId !== undefined) {
|
||||
targetTeamId = teamIdMap.get(member.teamId) || member.teamId;
|
||||
log.debug(`Member ${member.email}: teamId ${member.teamId} -> resolved to ${targetTeamId}`);
|
||||
} else if (member.teamName) {
|
||||
targetTeamId = teamNameToId.get(member.teamName.toLowerCase());
|
||||
log.debug(
|
||||
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${
|
||||
targetTeamId || "not found"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
if (targetTeamId && createdTeams.some((t) => t.id === targetTeamId)) {
|
||||
if (!invitesToTeams.has(targetTeamId)) {
|
||||
invitesToTeams.set(targetTeamId, []);
|
||||
}
|
||||
invitesToTeams.get(targetTeamId)!.push(member);
|
||||
log.debug(`Member ${member.email} will be invited to team ${targetTeamId}`);
|
||||
} else {
|
||||
invitesToOrg.push(member);
|
||||
log.debug(
|
||||
`Member ${member.email} will be invited to organization (no team specified or team not found)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Invite categorization complete",
|
||||
safeStringify({
|
||||
orgInvites: invitesToOrg.length,
|
||||
teamInvites: invitesToTeams.size,
|
||||
teamBreakdown: Array.from(invitesToTeams.entries()).map(([teamId, members]) => ({
|
||||
teamId,
|
||||
count: members.length,
|
||||
emails: members.map((m) => m.email),
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
if (invitesToOrg.length > 0) {
|
||||
log.info(`Inviting ${invitesToOrg.length} members to organization ${organization.id}`);
|
||||
await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: null,
|
||||
teamId: organization.id,
|
||||
language: "en",
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
orgSlug: organization.slug || null,
|
||||
invitations: invitesToOrg.map((member) => ({
|
||||
usernameOrEmail: member.email,
|
||||
role: (member.role as MembershipRole) || MembershipRole.MEMBER,
|
||||
})),
|
||||
isDirectUserAction: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [teamId, members] of Array.from(invitesToTeams.entries())) {
|
||||
const teamName = createdTeams.find((t) => t.id === teamId)?.name || `team ${teamId}`;
|
||||
log.info(`Inviting ${members.length} members to team "${teamName}" (${teamId})`);
|
||||
await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: null,
|
||||
teamId: teamId,
|
||||
language: "en",
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
orgSlug: organization.slug || null,
|
||||
invitations: members.map((member: InvitedMember) => ({
|
||||
usernameOrEmail: member.email,
|
||||
role: (member.role as MembershipRole) || MembershipRole.MEMBER,
|
||||
})),
|
||||
isDirectUserAction: false,
|
||||
});
|
||||
}
|
||||
|
||||
log.info("All member invites processed successfully");
|
||||
}
|
||||
|
||||
protected async ensureStripeCustomerIdIsUpdated({
|
||||
owner,
|
||||
stripeCustomerId,
|
||||
}: {
|
||||
owner: User;
|
||||
stripeCustomerId: string;
|
||||
}) {
|
||||
const parsedMetadata = userMetadata.parse(owner.metadata);
|
||||
|
||||
await new UserRepository(prisma).updateStripeCustomerId({
|
||||
id: owner.id,
|
||||
stripeCustomerId: stripeCustomerId,
|
||||
existingMetadata: parsedMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
protected async backwardCompatibilityForSubscriptionDetails({
|
||||
organization,
|
||||
paymentSubscriptionId,
|
||||
paymentSubscriptionItemId,
|
||||
}: {
|
||||
organization: {
|
||||
id: number;
|
||||
metadata: Prisma.JsonValue;
|
||||
};
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) {
|
||||
if (!paymentSubscriptionId || !paymentSubscriptionItemId) {
|
||||
return organization;
|
||||
}
|
||||
|
||||
const existingMetadata = teamMetadataStrictSchema.parse(organization.metadata);
|
||||
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
|
||||
id: organization.id,
|
||||
stripeSubscriptionId: paymentSubscriptionId,
|
||||
stripeSubscriptionItemId: paymentSubscriptionItemId,
|
||||
existingMetadata,
|
||||
});
|
||||
return updatedOrganization;
|
||||
}
|
||||
|
||||
protected async hasConflictingOrganization({ slug, onboardingId }: { slug: string; onboardingId: string }) {
|
||||
const organization = await OrganizationRepository.findBySlugIncludeOnboarding({ slug });
|
||||
if (!organization?.organizationOnboarding) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return organization.organizationOnboarding.id !== onboardingId;
|
||||
}
|
||||
|
||||
protected async handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
}: {
|
||||
organizationOnboarding: OrganizationOnboardingData;
|
||||
orgOwnerTranslation: TFunction;
|
||||
}) {
|
||||
if (!organizationOnboarding.isDomainConfigured) {
|
||||
await setupDomain({
|
||||
slug: organizationOnboarding.slug,
|
||||
isPlatform: organizationOnboarding.isPlatform,
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
}
|
||||
|
||||
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
||||
isDomainConfigured: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { BaseOnboardingService } from "../onboarding/BaseOnboardingService";
|
||||
import type {
|
||||
CreateOnboardingIntentInput,
|
||||
OnboardingIntentResult,
|
||||
OrganizationOnboardingData,
|
||||
OrganizationData,
|
||||
} from "../onboarding/types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["BillingEnabledOrgOnboardingService"] });
|
||||
|
||||
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
|
||||
const teamsSchema = orgOnboardingTeamsSchema;
|
||||
|
||||
/**
|
||||
* Handles organization onboarding when billing is enabled (Stripe flow).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create onboarding record
|
||||
* 2. Store teams/invites in database
|
||||
* 3. Create Stripe checkout session
|
||||
* 4. Return checkout URL
|
||||
* 5. Organization created later via Stripe webhook
|
||||
*
|
||||
* Exception: Admin creating org for self - immediately creates organization (no payment required)
|
||||
*/
|
||||
export class BillingEnabledOrgOnboardingService extends BaseOnboardingService {
|
||||
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult> {
|
||||
log.debug(
|
||||
"BillingEnabledOrgOnboardingService.createOnboardingIntent - INPUT",
|
||||
safeStringify({
|
||||
invitedMembers: input.invitedMembers,
|
||||
teams: input.teams,
|
||||
})
|
||||
);
|
||||
|
||||
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
|
||||
|
||||
log.debug(
|
||||
"BillingEnabledOrgOnboardingService - After filterTeamsAndInvites",
|
||||
safeStringify({
|
||||
teamsData,
|
||||
invitedMembersData,
|
||||
})
|
||||
);
|
||||
|
||||
// Create onboarding record with ALL data at once
|
||||
const organizationOnboarding = await this.createOnboardingRecord({
|
||||
...input,
|
||||
teams: teamsData,
|
||||
invitedMembers: invitedMembersData,
|
||||
});
|
||||
const onboardingId = organizationOnboarding.id;
|
||||
|
||||
// Check if this is an admin handover flow
|
||||
const handoverResult = this.handleAdminHandoverIfNeeded(input, onboardingId);
|
||||
if (handoverResult) {
|
||||
return handoverResult;
|
||||
}
|
||||
|
||||
// Check if admin is creating org for themselves - skip payment, create immediately
|
||||
if (
|
||||
this.isAdminCreatingForSelf({
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
})
|
||||
) {
|
||||
log.debug(
|
||||
"Admin creating org for self - skipping payment and creating organization immediately",
|
||||
safeStringify({ adminEmail: this.user.email, onboardingId })
|
||||
);
|
||||
|
||||
const { organization } = await this.createOrganization(organizationOnboarding);
|
||||
|
||||
await OrganizationOnboardingRepository.markAsComplete(onboardingId);
|
||||
|
||||
log.debug(
|
||||
"Organization created successfully for admin",
|
||||
safeStringify({ onboardingId, organizationId: organization.id })
|
||||
);
|
||||
|
||||
return {
|
||||
userId: this.user.id,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
seats: input.seats ?? null,
|
||||
pricePerSeat: input.pricePerSeat ?? null,
|
||||
billingPeriod: input.billingPeriod,
|
||||
isPlatform: input.isPlatform,
|
||||
organizationOnboardingId: onboardingId,
|
||||
checkoutUrl: null,
|
||||
organizationId: organization.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular flow - create payment intent
|
||||
const paymentIntent = await this.paymentService.createPaymentIntent(
|
||||
{
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
teams: teamsData,
|
||||
invitedMembers: invitedMembersData,
|
||||
},
|
||||
{
|
||||
id: organizationOnboarding.id,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
billingPeriod: organizationOnboarding.billingPeriod,
|
||||
seats: organizationOnboarding.seats,
|
||||
isComplete: organizationOnboarding.isComplete,
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
slug: organizationOnboarding.slug,
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
userId: this.user.id,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
seats: input.seats ?? null,
|
||||
pricePerSeat: input.pricePerSeat ?? null,
|
||||
billingPeriod: input.billingPeriod,
|
||||
isPlatform: input.isPlatform,
|
||||
organizationOnboardingId: onboardingId,
|
||||
checkoutUrl: paymentIntent.checkoutUrl,
|
||||
organizationId: null, // Organization not created yet (pending payment)
|
||||
};
|
||||
}
|
||||
|
||||
async createOrganization(
|
||||
organizationOnboarding: OrganizationOnboardingData,
|
||||
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
|
||||
): Promise<{ organization: Team; owner: User }> {
|
||||
log.info(
|
||||
"createOrganization (billing-enabled)",
|
||||
safeStringify({
|
||||
orgId: organizationOnboarding.organizationId,
|
||||
orgSlug: organizationOnboarding.slug,
|
||||
})
|
||||
);
|
||||
|
||||
const isAdminForSelf = this.isAdminCreatingForSelf({
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
});
|
||||
|
||||
if (
|
||||
!IS_SELF_HOSTED &&
|
||||
!isAdminForSelf &&
|
||||
(!paymentDetails?.subscriptionId || !paymentDetails?.subscriptionItemId)
|
||||
) {
|
||||
throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required");
|
||||
}
|
||||
|
||||
if (
|
||||
await this.hasConflictingOrganization({
|
||||
slug: organizationOnboarding.slug,
|
||||
onboardingId: organizationOnboarding.id,
|
||||
})
|
||||
) {
|
||||
throw new Error("organization_already_exists_with_this_slug");
|
||||
}
|
||||
|
||||
let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
|
||||
const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common");
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
|
||||
await this.handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
}
|
||||
|
||||
const orgData: OrganizationData = {
|
||||
id: organizationOnboarding.organizationId,
|
||||
name: organizationOnboarding.name,
|
||||
slug: organizationOnboarding.slug,
|
||||
isOrganizationConfigured: true,
|
||||
isOrganizationAdminReviewed: true,
|
||||
autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1],
|
||||
seats: organizationOnboarding.seats,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
isPlatform: false,
|
||||
billingPeriod: organizationOnboarding.billingPeriod,
|
||||
logoUrl: organizationOnboarding.logo,
|
||||
bio: organizationOnboarding.bio,
|
||||
brandColor: organizationOnboarding.brandColor,
|
||||
bannerUrl: organizationOnboarding.bannerUrl,
|
||||
};
|
||||
|
||||
let organization: Team;
|
||||
if (!owner) {
|
||||
const result = await this.createOrganizationWithNonExistentUserAsOwner({
|
||||
email: organizationOnboarding.orgOwnerEmail,
|
||||
orgData,
|
||||
});
|
||||
organization = result.organization;
|
||||
owner = result.owner;
|
||||
} else {
|
||||
const result = await this.createOrganizationWithExistingUserAsOwner({
|
||||
orgData,
|
||||
owner,
|
||||
});
|
||||
organization = result.organization;
|
||||
}
|
||||
|
||||
if (organizationOnboarding.stripeCustomerId) {
|
||||
await this.ensureStripeCustomerIdIsUpdated({
|
||||
owner,
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
});
|
||||
}
|
||||
|
||||
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const updatedOrganization = await this.backwardCompatibilityForSubscriptionDetails({
|
||||
organization,
|
||||
paymentSubscriptionId: paymentDetails?.subscriptionId,
|
||||
paymentSubscriptionItemId: paymentDetails?.subscriptionItemId,
|
||||
});
|
||||
|
||||
organization.metadata = updatedOrganization.metadata;
|
||||
|
||||
const teamsData = teamsSchema.parse(organizationOnboarding.teams);
|
||||
await this.createOrMoveTeamsToOrganization(teamsData, owner, organization.id);
|
||||
|
||||
await this.inviteMembers(
|
||||
invitedMembersSchema.parse(organizationOnboarding.invitedMembers),
|
||||
organization,
|
||||
teamsData
|
||||
);
|
||||
|
||||
if (!organization.slug) {
|
||||
try {
|
||||
const { slug } = await OrganizationRepository.setSlug({
|
||||
id: organization.id,
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
organization.slug = slug;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
"RecoverableError: Error while setting slug for organization",
|
||||
safeStringify(error),
|
||||
safeStringify({
|
||||
attemptedSlug: organizationOnboarding.slug,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { organization, owner };
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
|
||||
import type {
|
||||
CreateOnboardingIntentInput,
|
||||
OnboardingIntentResult,
|
||||
OrganizationOnboardingData,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Interface for organization onboarding services.
|
||||
* Implementations handle different onboarding flows (billing-enabled vs self-hosted).
|
||||
*/
|
||||
export interface IOrganizationOnboardingService {
|
||||
/**
|
||||
* Creates an organization onboarding intent.
|
||||
* Depending on the implementation:
|
||||
* - BillingEnabled: Creates Stripe checkout session, returns checkoutUrl
|
||||
* - SelfHosted: Creates organization immediately, returns organizationId
|
||||
*/
|
||||
createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult>;
|
||||
|
||||
/**
|
||||
* Creates the actual organization from onboarding data.
|
||||
* Depending on the implementation:
|
||||
* - SelfHosted: Validates license, creates organization without payment
|
||||
* - BillingEnabled: Requires payment details, handles subscription
|
||||
*/
|
||||
createOrganization(
|
||||
organizationOnboarding: OrganizationOnboardingData,
|
||||
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
|
||||
): Promise<{ organization: Team; owner: User }>;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import { BillingEnabledOrgOnboardingService } from "./BillingEnabledOrgOnboardingService";
|
||||
import type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService";
|
||||
import { SelfHostedOrganizationOnboardingService } from "./SelfHostedOnboardingService";
|
||||
import type { OnboardingUser } from "./types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["OrganizationOnboardingFactory"] });
|
||||
|
||||
/**
|
||||
* Factory that selects the appropriate onboarding service based on configuration:
|
||||
* - Self-hosted flow (billing disabled) when: IS_TEAM_BILLING_ENABLED=false
|
||||
* - Exception: E2E tests always use self-hosted flow
|
||||
* Otherwise, billing is enabled (Stripe flow).
|
||||
*/
|
||||
export class OrganizationOnboardingFactory {
|
||||
static create(user: OnboardingUser): IOrganizationOnboardingService {
|
||||
const isBillingEnabled = this.isBillingEnabled();
|
||||
|
||||
log.debug(
|
||||
"Creating onboarding service",
|
||||
safeStringify({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
IS_TEAM_BILLING_ENABLED: IS_TEAM_BILLING_ENABLED,
|
||||
isBillingEnabled,
|
||||
serviceType: isBillingEnabled ? "BillingEnabled" : "SelfHosted",
|
||||
})
|
||||
);
|
||||
|
||||
if (isBillingEnabled) {
|
||||
return new BillingEnabledOrgOnboardingService(user);
|
||||
} else {
|
||||
return new SelfHostedOrganizationOnboardingService(user);
|
||||
}
|
||||
}
|
||||
|
||||
private static isBillingEnabled(): boolean {
|
||||
// E2E tests always skip billing (use self-hosted flow)
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If billing is enabled globally, use it
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Organization Onboarding Setup
|
||||
|
||||
This document explains how the organization onboarding system works and how to configure it for different deployment scenarios.
|
||||
|
||||
## Overview
|
||||
|
||||
The organization onboarding system supports two different flows:
|
||||
|
||||
1. **Billing-Enabled Flow** (BillingEnabledOrgOnboardingService): Used on hosted Cal.com with Stripe integration
|
||||
2. **Self-Hosted Flow** (SelfHostedOrganizationOnboardingService): Used on self-hosted instances where admins can create organizations without payment
|
||||
|
||||
The system automatically selects the appropriate flow based on environment variables and user permissions.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Factory Pattern
|
||||
|
||||
The `OrganizationOnboardingFactory` class determines which onboarding service to use:
|
||||
|
||||
```typescript
|
||||
OrganizationOnboardingFactory.create(user)
|
||||
→ BillingEnabledOrgOnboardingService | SelfHostedOrganizationOnboardingService
|
||||
```
|
||||
|
||||
### Decision Logic
|
||||
|
||||
The factory uses the following logic to determine which service to instantiate:
|
||||
|
||||
```
|
||||
IF process.env.NEXT_PUBLIC_IS_E2E is set:
|
||||
→ Use SelfHostedOrganizationOnboardingService (E2E tests always skip billing)
|
||||
|
||||
ELSE IF IS_TEAM_BILLING_ENABLED is true:
|
||||
→ Use BillingEnabledOrgOnboardingService (hosted environment with Stripe)
|
||||
|
||||
ELSE IF user.role is ADMIN:
|
||||
→ Use SelfHostedOrganizationOnboardingService (self-hosted admins skip billing)
|
||||
|
||||
ELSE:
|
||||
→ Use BillingEnabledOrgOnboardingService (non-admins need billing even on self-hosted)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required for Billing-Enabled Flow
|
||||
|
||||
```bash
|
||||
# Stripe Configuration
|
||||
STRIPE_CLIENT_ID=your_stripe_client_id
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=your_stripe_public_key
|
||||
STRIPE_PRIVATE_KEY=your_stripe_private_key
|
||||
|
||||
# These enable IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED
|
||||
```
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
```bash
|
||||
# Force hosted features on self-hosted instances
|
||||
NEXT_PUBLIC_HOSTED_CAL_FEATURES=true
|
||||
|
||||
# E2E testing (disables billing flow)
|
||||
NEXT_PUBLIC_IS_E2E=1
|
||||
|
||||
# Organization pricing
|
||||
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=5
|
||||
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW=37
|
||||
```
|
||||
|
||||
## Constants
|
||||
|
||||
The system relies on several computed constants from `@calcom/lib/constants`:
|
||||
|
||||
### Server-Side Constants
|
||||
|
||||
- **`IS_TEAM_BILLING_ENABLED`**: `IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES`
|
||||
- Determines if billing is globally enabled
|
||||
- Used in: `OrganizationOnboardingFactory`
|
||||
|
||||
- **`IS_STRIPE_ENABLED`**: `!!(STRIPE_CLIENT_ID && NEXT_PUBLIC_STRIPE_PUBLIC_KEY && STRIPE_PRIVATE_KEY)`
|
||||
- Checks if Stripe is properly configured
|
||||
|
||||
- **`HOSTED_CAL_FEATURES`**: `process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED`
|
||||
- Enables hosted features (including billing)
|
||||
|
||||
- **`IS_SELF_HOSTED`**: `!CAL_DOMAINS.some(domain => WEBAPP_HOSTNAME.endsWith(domain))`
|
||||
- Detects if running on official Cal.com domains
|
||||
|
||||
### Client-Side Constants
|
||||
|
||||
- **`IS_TEAM_BILLING_ENABLED_CLIENT`**: `!!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES`
|
||||
- Client-safe version of billing check
|
||||
- Used in: `onboardingStore.ts`
|
||||
|
||||
## Configuration Scenarios
|
||||
|
||||
### Scenario 1: Hosted Cal.com (Official)
|
||||
|
||||
**Environment:**
|
||||
```bash
|
||||
# Domain: *.cal.com, *.cal.dev, etc.
|
||||
STRIPE_CLIENT_ID=ca_xxx
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_xxx
|
||||
STRIPE_PRIVATE_KEY=sk_live_xxx
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- `IS_SELF_HOSTED` = `false`
|
||||
- `HOSTED_CAL_FEATURES` = `true`
|
||||
- `IS_TEAM_BILLING_ENABLED` = `true`
|
||||
- All users → **BillingEnabledOrgOnboardingService**
|
||||
|
||||
### Scenario 2: Self-Hosted with Billing (Optional)
|
||||
|
||||
**Environment:**
|
||||
```bash
|
||||
# Domain: custom.example.com
|
||||
NEXT_PUBLIC_HOSTED_CAL_FEATURES=true
|
||||
STRIPE_CLIENT_ID=ca_xxx
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_live_xxx
|
||||
STRIPE_PRIVATE_KEY=sk_live_xxx
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- `IS_SELF_HOSTED` = `true`
|
||||
- `HOSTED_CAL_FEATURES` = `true` (forced)
|
||||
- `IS_TEAM_BILLING_ENABLED` = `true`
|
||||
- All users → **BillingEnabledOrgOnboardingService**
|
||||
|
||||
### Scenario 3: Self-Hosted without Billing (Default)
|
||||
|
||||
**Environment:**
|
||||
```bash
|
||||
# Domain: custom.example.com
|
||||
# No Stripe configuration
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- `IS_SELF_HOSTED` = `true`
|
||||
- `HOSTED_CAL_FEATURES` = `false`
|
||||
- `IS_TEAM_BILLING_ENABLED` = `false`
|
||||
- Admin users → **SelfHostedOrganizationOnboardingService**
|
||||
- Regular users → **BillingEnabledOrgOnboardingService** (will fail without Stripe)
|
||||
|
||||
**Recommendation:** In this scenario, only admins should be allowed to create organizations.
|
||||
|
||||
### Scenario 4: E2E Testing
|
||||
|
||||
**Environment:**
|
||||
```bash
|
||||
NEXT_PUBLIC_IS_E2E=1
|
||||
# Any other configuration
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- All users → **SelfHostedOrganizationOnboardingService**
|
||||
- Billing flow is completely bypassed
|
||||
|
||||
## User Roles
|
||||
|
||||
The system differentiates between two user roles:
|
||||
|
||||
### Admin Users (`UserPermissionRole.ADMIN`)
|
||||
- Can create organizations without billing on self-hosted instances
|
||||
- Can access self-hosted flow when `IS_TEAM_BILLING_ENABLED` is false
|
||||
|
||||
### Regular Users (`UserPermissionRole.USER`)
|
||||
- Always require billing flow
|
||||
- Cannot create organizations on self-hosted without Stripe configured
|
||||
```
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { BillingPeriod } from "@calcom/prisma/enums";
|
||||
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { BaseOnboardingService } from "./BaseOnboardingService";
|
||||
import type {
|
||||
CreateOnboardingIntentInput,
|
||||
OnboardingIntentResult,
|
||||
OrganizationOnboardingData,
|
||||
OrganizationData,
|
||||
} from "./types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["SelfHostedOrganizationOnboardingService"] });
|
||||
|
||||
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
|
||||
const teamsSchema = orgOnboardingTeamsSchema;
|
||||
|
||||
/**
|
||||
* Handles organization onboarding when billing is disabled (self-hosted admin flow).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create onboarding record
|
||||
* 2. Store teams/invites in database
|
||||
* 3. Immediately create organization, teams, and invite members
|
||||
* 4. Mark onboarding as complete
|
||||
* 5. Return organization ID
|
||||
*/
|
||||
export class SelfHostedOrganizationOnboardingService extends BaseOnboardingService {
|
||||
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult> {
|
||||
log.debug(
|
||||
"Starting self-hosted onboarding flow (immediate organization creation)",
|
||||
safeStringify({
|
||||
slug: input.slug,
|
||||
teamsCount: input.teams?.length ?? 0,
|
||||
invitesCount: input.invitedMembers?.length ?? 0,
|
||||
})
|
||||
);
|
||||
|
||||
// Step 1: Filter and normalize teams/invites
|
||||
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
|
||||
|
||||
// Step 2: Create onboarding record with ALL data at once
|
||||
const organizationOnboarding = await this.createOnboardingRecord({
|
||||
...input,
|
||||
teams: teamsData,
|
||||
invitedMembers: invitedMembersData,
|
||||
});
|
||||
const onboardingId = organizationOnboarding.id;
|
||||
|
||||
// Check if this is an admin handover flow
|
||||
const handoverResult = this.handleAdminHandoverIfNeeded(input, onboardingId);
|
||||
if (handoverResult) {
|
||||
return handoverResult;
|
||||
}
|
||||
|
||||
// Step 3: Create organization immediately (regular self-hosted flow)
|
||||
log.debug("Creating organization immediately (no payment required)", safeStringify({ onboardingId }));
|
||||
|
||||
const { organization } = await this.createOrganization({
|
||||
id: onboardingId,
|
||||
organizationId: null,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
seats: input.seats ?? null,
|
||||
pricePerSeat: input.pricePerSeat ?? null,
|
||||
billingPeriod: input.billingPeriod ?? BillingPeriod.MONTHLY,
|
||||
invitedMembers: invitedMembersData,
|
||||
teams: teamsData,
|
||||
isPlatform: input.isPlatform,
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
stripeCustomerId: null,
|
||||
isDomainConfigured: false,
|
||||
});
|
||||
|
||||
// Step 4: Mark onboarding as complete
|
||||
await OrganizationOnboardingRepository.markAsComplete(onboardingId);
|
||||
|
||||
log.debug(
|
||||
"Organization created successfully",
|
||||
safeStringify({ onboardingId, organizationId: organization.id })
|
||||
);
|
||||
|
||||
// Step 5: Return result with organization ID
|
||||
return {
|
||||
userId: this.user.id,
|
||||
orgOwnerEmail: input.orgOwnerEmail,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
seats: input.seats ?? null,
|
||||
pricePerSeat: input.pricePerSeat ?? null,
|
||||
billingPeriod: input.billingPeriod,
|
||||
isPlatform: input.isPlatform,
|
||||
organizationOnboardingId: onboardingId,
|
||||
checkoutUrl: null, // No checkout required
|
||||
organizationId: organization.id, // Organization created immediately
|
||||
};
|
||||
}
|
||||
|
||||
async createOrganization(
|
||||
organizationOnboarding: OrganizationOnboardingData
|
||||
): Promise<{ organization: Team; owner: User }> {
|
||||
log.info(
|
||||
"createOrganization (self-hosted)",
|
||||
safeStringify({
|
||||
orgId: organizationOnboarding.organizationId,
|
||||
orgSlug: organizationOnboarding.slug,
|
||||
})
|
||||
);
|
||||
|
||||
if (IS_SELF_HOSTED) {
|
||||
const deploymentRepo = new DeploymentRepository(prisma);
|
||||
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
|
||||
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||
|
||||
if (!hasValidLicense) {
|
||||
throw new Error("Self hosted license not valid");
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
await this.hasConflictingOrganization({
|
||||
slug: organizationOnboarding.slug,
|
||||
onboardingId: organizationOnboarding.id,
|
||||
})
|
||||
) {
|
||||
throw new Error("organization_already_exists_with_this_slug");
|
||||
}
|
||||
|
||||
let owner = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
|
||||
const orgOwnerTranslation = await getTranslation(owner?.locale || "en", "common");
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
|
||||
await this.handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
}
|
||||
|
||||
const orgData: OrganizationData = {
|
||||
id: organizationOnboarding.organizationId,
|
||||
name: organizationOnboarding.name,
|
||||
slug: organizationOnboarding.slug,
|
||||
isOrganizationConfigured: true,
|
||||
isOrganizationAdminReviewed: true,
|
||||
autoAcceptEmail: organizationOnboarding.orgOwnerEmail.split("@")[1],
|
||||
seats: organizationOnboarding.seats,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
isPlatform: false,
|
||||
billingPeriod: organizationOnboarding.billingPeriod,
|
||||
logoUrl: organizationOnboarding.logo,
|
||||
bio: organizationOnboarding.bio,
|
||||
brandColor: organizationOnboarding.brandColor,
|
||||
bannerUrl: organizationOnboarding.bannerUrl,
|
||||
};
|
||||
|
||||
let organization: Team;
|
||||
if (!owner) {
|
||||
const result = await this.createOrganizationWithNonExistentUserAsOwner({
|
||||
email: organizationOnboarding.orgOwnerEmail,
|
||||
orgData,
|
||||
});
|
||||
organization = result.organization;
|
||||
owner = result.owner;
|
||||
} else {
|
||||
const result = await this.createOrganizationWithExistingUserAsOwner({
|
||||
orgData,
|
||||
owner,
|
||||
});
|
||||
organization = result.organization;
|
||||
}
|
||||
|
||||
if (organizationOnboarding.stripeCustomerId) {
|
||||
await this.ensureStripeCustomerIdIsUpdated({
|
||||
owner,
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
});
|
||||
}
|
||||
|
||||
await OrganizationOnboardingRepository.update(organizationOnboarding.id, {
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const teamsData = teamsSchema.parse(organizationOnboarding.teams);
|
||||
await this.createOrMoveTeamsToOrganization(teamsData, owner, organization.id);
|
||||
|
||||
await this.inviteMembers(
|
||||
invitedMembersSchema.parse(organizationOnboarding.invitedMembers),
|
||||
organization,
|
||||
teamsData
|
||||
);
|
||||
|
||||
if (!organization.slug) {
|
||||
try {
|
||||
const { slug } = await OrganizationRepository.setSlug({
|
||||
id: organization.id,
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
organization.slug = slug;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
"RecoverableError: Error while setting slug for organization",
|
||||
safeStringify(error),
|
||||
safeStringify({
|
||||
attemptedSlug: organizationOnboarding.slug,
|
||||
organizationId: organization.id,
|
||||
})
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { organization, owner };
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { BaseOnboardingService } from "../BaseOnboardingService";
|
||||
import type { CreateOnboardingIntentInput } from "../types";
|
||||
|
||||
class TestableBaseOnboardingService extends BaseOnboardingService {
|
||||
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
public testFilterTeamsAndInvites(
|
||||
teams: CreateOnboardingIntentInput["teams"],
|
||||
invitedMembers: CreateOnboardingIntentInput["invitedMembers"]
|
||||
) {
|
||||
return this.filterTeamsAndInvites(teams, invitedMembers);
|
||||
}
|
||||
}
|
||||
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: "user@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
describe("BaseOnboardingService", () => {
|
||||
describe("filterTeamsAndInvites", () => {
|
||||
it("should filter out invites with empty emails", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const invites = [
|
||||
{ email: "valid@example.com", teamName: "Marketing", role: "MEMBER" },
|
||||
{ email: "", teamName: "Sales", role: "ADMIN" },
|
||||
{ email: " ", teamName: "Engineering", role: "MEMBER" },
|
||||
{ email: "another@example.com", teamName: "Design", role: "MEMBER" },
|
||||
];
|
||||
|
||||
const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
|
||||
|
||||
expect(invitedMembersData).toHaveLength(2);
|
||||
expect(invitedMembersData).toEqual([
|
||||
{
|
||||
email: "valid@example.com",
|
||||
name: undefined,
|
||||
teamId: undefined,
|
||||
teamName: "Marketing",
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
email: "another@example.com",
|
||||
name: undefined,
|
||||
teamId: undefined,
|
||||
teamName: "Design",
|
||||
role: "MEMBER",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve all fields from invites including role", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const invites = [
|
||||
{
|
||||
email: "member@example.com",
|
||||
name: "John Doe",
|
||||
teamId: 5,
|
||||
teamName: "Marketing",
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
email: "admin@example.com",
|
||||
name: "Jane Smith",
|
||||
teamId: 10,
|
||||
teamName: "Engineering",
|
||||
role: "ADMIN",
|
||||
},
|
||||
];
|
||||
|
||||
const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
|
||||
|
||||
expect(invitedMembersData).toEqual([
|
||||
{
|
||||
email: "member@example.com",
|
||||
name: "John Doe",
|
||||
teamId: 5,
|
||||
teamName: "Marketing",
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
email: "admin@example.com",
|
||||
name: "Jane Smith",
|
||||
teamId: 10,
|
||||
teamName: "Engineering",
|
||||
role: "ADMIN",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle invites without optional fields", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const invites = [
|
||||
{ email: "minimal@example.com" },
|
||||
{ email: "withteam@example.com", teamName: "Sales" },
|
||||
];
|
||||
|
||||
const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
|
||||
|
||||
expect(invitedMembersData).toEqual([
|
||||
{
|
||||
email: "minimal@example.com",
|
||||
name: undefined,
|
||||
teamId: undefined,
|
||||
teamName: undefined,
|
||||
role: undefined,
|
||||
},
|
||||
{
|
||||
email: "withteam@example.com",
|
||||
name: undefined,
|
||||
teamId: undefined,
|
||||
teamName: "Sales",
|
||||
role: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out teams with empty names", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const teams = [
|
||||
{ id: 1, name: "Marketing", isBeingMigrated: false, slug: null },
|
||||
{ id: 2, name: "", isBeingMigrated: false, slug: null },
|
||||
{ id: 3, name: " ", isBeingMigrated: false, slug: null },
|
||||
{ id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" },
|
||||
];
|
||||
|
||||
const { teamsData } = service.testFilterTeamsAndInvites(teams, []);
|
||||
|
||||
expect(teamsData).toHaveLength(2);
|
||||
expect(teamsData).toEqual([
|
||||
{ id: 1, name: "Marketing", isBeingMigrated: false, slug: null },
|
||||
{ id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve team properties including migration status", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const teams = [
|
||||
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
{ id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
|
||||
];
|
||||
|
||||
const { teamsData } = service.testFilterTeamsAndInvites(teams, []);
|
||||
|
||||
expect(teamsData).toEqual([
|
||||
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
{ id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle empty teams and invites arrays", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites([], []);
|
||||
|
||||
expect(teamsData).toEqual([]);
|
||||
expect(invitedMembersData).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle undefined teams and invites", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(undefined, undefined);
|
||||
|
||||
expect(teamsData).toEqual([]);
|
||||
expect(invitedMembersData).toEqual([]);
|
||||
});
|
||||
|
||||
it("should preserve invites with teamId=-1 for new teams", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const teams = [
|
||||
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
];
|
||||
|
||||
const invites = [
|
||||
{ email: "user1@example.com", teamId: -1, teamName: "Marketing", role: "MEMBER" },
|
||||
{ email: "user2@example.com", teamId: -1, teamName: "Sales", role: "ADMIN" },
|
||||
];
|
||||
|
||||
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites);
|
||||
|
||||
expect(teamsData).toHaveLength(2);
|
||||
expect(invitedMembersData).toHaveLength(2);
|
||||
expect(invitedMembersData[0].teamId).toBe(-1);
|
||||
expect(invitedMembersData[1].teamId).toBe(-1);
|
||||
expect(invitedMembersData[0].role).toBe("MEMBER");
|
||||
expect(invitedMembersData[1].role).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("should handle mixed scenarios with both org-level and team-specific invites", () => {
|
||||
const service = new TestableBaseOnboardingService(mockUser as any);
|
||||
|
||||
const teams = [
|
||||
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
|
||||
{ id: 42, name: "Engineering", isBeingMigrated: true, slug: "eng" },
|
||||
];
|
||||
|
||||
const invites = [
|
||||
{ email: "orguser@example.com", role: "MEMBER" },
|
||||
{ email: "marketing@example.com", teamName: "Marketing", teamId: -1, role: "MEMBER" },
|
||||
{ email: "eng@example.com", teamName: "Engineering", teamId: 42, role: "ADMIN" },
|
||||
];
|
||||
|
||||
const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites);
|
||||
|
||||
expect(teamsData).toHaveLength(2);
|
||||
expect(invitedMembersData).toHaveLength(3);
|
||||
|
||||
expect(invitedMembersData[0]).toEqual({
|
||||
email: "orguser@example.com",
|
||||
name: undefined,
|
||||
teamId: undefined,
|
||||
teamName: undefined,
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
expect(invitedMembersData[1]).toEqual({
|
||||
email: "marketing@example.com",
|
||||
name: undefined,
|
||||
teamId: -1,
|
||||
teamName: "Marketing",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
expect(invitedMembersData[2]).toEqual({
|
||||
email: "eng@example.com",
|
||||
name: undefined,
|
||||
teamId: 42,
|
||||
teamName: "Engineering",
|
||||
role: "ADMIN",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+814
@@ -0,0 +1,814 @@
|
||||
import prismock from "../../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { createDomain } from "@calcom/lib/domainManager/organization";
|
||||
import { UserPermissionRole, CreationSource, MembershipRole, BillingPeriod } from "@calcom/prisma/enums";
|
||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
import type { CreateOnboardingIntentInput } from "../../onboarding/types";
|
||||
import { BillingEnabledOrgOnboardingService } from "../BillingEnabledOrgOnboardingService";
|
||||
|
||||
vi.mock("../../OrganizationPaymentService");
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
|
||||
sendEmailVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/emails/email-manager", () => ({
|
||||
sendOrganizationCreationEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({
|
||||
inviteMembersWithNoInviterPermissionCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({
|
||||
createTeamsHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/domainManager/organization", () => ({
|
||||
createDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: "user@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const mockInput: CreateOnboardingIntentInput = {
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
orgOwnerEmail: "owner@example.com",
|
||||
seats: 10,
|
||||
pricePerSeat: 20,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
logo: "https://example.com/logo.png",
|
||||
bio: "Test bio",
|
||||
brandColor: "#000000",
|
||||
bannerUrl: "https://example.com/banner.png",
|
||||
teams: [
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: "member2@example.com", name: "Member 2" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("BillingEnabledOrgOnboardingService", () => {
|
||||
let service: BillingEnabledOrgOnboardingService;
|
||||
let mockPaymentService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
// @ts-expect-error reset is a method on Prismock
|
||||
await prismock.reset();
|
||||
|
||||
mockPaymentService = {
|
||||
createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => {
|
||||
// Actually create the record in prismock so it can be updated later
|
||||
return await prismock.organizationOnboarding.create({
|
||||
data: {
|
||||
id: "onboarding-123",
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
orgOwnerEmail: data.orgOwnerEmail,
|
||||
seats: data.seats ?? null,
|
||||
pricePerSeat: data.pricePerSeat ?? null,
|
||||
billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY,
|
||||
isComplete: false,
|
||||
stripeCustomerId: null,
|
||||
createdById: data.createdByUserId,
|
||||
teams: [],
|
||||
invitedMembers: [],
|
||||
isPlatform: data.isPlatform ?? false,
|
||||
logo: data.logo ?? null,
|
||||
bio: data.bio ?? null,
|
||||
brandColor: data.brandColor ?? null,
|
||||
bannerUrl: data.bannerUrl ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
createPaymentIntent: vi.fn().mockResolvedValue({
|
||||
checkoutUrl: "https://stripe.com/checkout/session",
|
||||
organizationOnboarding: {},
|
||||
subscription: {},
|
||||
sessionId: "session-123",
|
||||
}),
|
||||
};
|
||||
|
||||
service = new BillingEnabledOrgOnboardingService(mockUser as any, mockPaymentService);
|
||||
});
|
||||
|
||||
describe("createOnboardingIntent", () => {
|
||||
it("should create onboarding record", async () => {
|
||||
await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: mockInput.name,
|
||||
slug: mockInput.slug,
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
createdByUserId: mockUser.id,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create Stripe payment intent with teams and invites", async () => {
|
||||
await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: expect.arrayContaining([
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
]),
|
||||
invitedMembers: expect.arrayContaining([
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: "member2@example.com", name: "Member 2" },
|
||||
]),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should return checkout URL", async () => {
|
||||
const result = await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(result.checkoutUrl).toBe("https://stripe.com/checkout/session");
|
||||
expect(result.organizationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should filter out empty team names", async () => {
|
||||
const inputWithEmptyTeams = {
|
||||
...mockInput,
|
||||
teams: [
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: " ", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithEmptyTeams);
|
||||
|
||||
expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }],
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out empty invite emails", async () => {
|
||||
const inputWithEmptyInvites = {
|
||||
...mockInput,
|
||||
invitedMembers: [
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: " ", name: "Empty" },
|
||||
{ email: "", name: "Empty 2" },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithEmptyInvites);
|
||||
|
||||
expect(mockPaymentService.createPaymentIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
invitedMembers: [{ email: "member1@example.com", name: "Member 1" }],
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT create organization immediately", async () => {
|
||||
const result = await service.createOnboardingIntent(mockInput);
|
||||
|
||||
// Organization will be created later via Stripe webhook
|
||||
expect(result.organizationId).toBeNull();
|
||||
expect(result.checkoutUrl).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should return all required fields", async () => {
|
||||
const result = await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
userId: mockUser.id,
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
name: mockInput.name,
|
||||
slug: mockInput.slug,
|
||||
seats: mockInput.seats,
|
||||
pricePerSeat: mockInput.pricePerSeat,
|
||||
billingPeriod: mockInput.billingPeriod,
|
||||
isPlatform: mockInput.isPlatform,
|
||||
organizationOnboardingId: "onboarding-123",
|
||||
checkoutUrl: "https://stripe.com/checkout/session",
|
||||
organizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should immediately create organization when admin creates org for self", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const adminUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
name: "Admin User",
|
||||
};
|
||||
|
||||
const adminInput = {
|
||||
...mockInput,
|
||||
orgOwnerEmail: adminUser.email,
|
||||
};
|
||||
|
||||
await prismock.user.create({
|
||||
data: {
|
||||
id: adminUser.id,
|
||||
email: adminUser.email,
|
||||
name: adminUser.name,
|
||||
username: "admin",
|
||||
completedOnboarding: true,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const adminService = new BillingEnabledOrgOnboardingService(adminUser as any, mockPaymentService);
|
||||
|
||||
const result = await adminService.createOnboardingIntent(adminInput);
|
||||
|
||||
expect(result.organizationId).not.toBeNull();
|
||||
expect(result.checkoutUrl).toBeNull();
|
||||
expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled();
|
||||
|
||||
const onboarding = await prismock.organizationOnboarding.findUnique({
|
||||
where: { id: result.organizationOnboardingId },
|
||||
});
|
||||
expect(onboarding?.isComplete).toBe(true);
|
||||
});
|
||||
|
||||
it("should use checkout flow when admin creates org for someone else with no teams/invites (handover)", async () => {
|
||||
const adminUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
name: "Admin User",
|
||||
};
|
||||
|
||||
const handoverInput = {
|
||||
...mockInput,
|
||||
orgOwnerEmail: "other@example.com",
|
||||
teams: [],
|
||||
invitedMembers: [],
|
||||
};
|
||||
|
||||
const adminService = new BillingEnabledOrgOnboardingService(adminUser as any, mockPaymentService);
|
||||
|
||||
const result = await adminService.createOnboardingIntent(handoverInput);
|
||||
|
||||
expect(result.organizationId).toBeNull();
|
||||
expect(result.checkoutUrl).toBeNull();
|
||||
expect(result.handoverUrl).toContain("/settings/organizations/new/resume");
|
||||
expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrganization", () => {
|
||||
// Helper functions for creating test data
|
||||
async function createTestUser(data: {
|
||||
email: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
metadata?: any;
|
||||
onboardingCompleted?: boolean;
|
||||
emailVerified?: Date | null;
|
||||
}) {
|
||||
return prismock.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name || "Test User",
|
||||
username: data.username || "testuser",
|
||||
metadata: data.metadata || {},
|
||||
completedOnboarding: data.onboardingCompleted,
|
||||
emailVerified: data.emailVerified,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestOrganization(data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
isOrganization?: boolean;
|
||||
metadata?: any;
|
||||
}) {
|
||||
return prismock.team.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
isOrganization: data.isOrganization ?? true,
|
||||
metadata: data.metadata || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestTeam(data: { name: string; slug: string }) {
|
||||
return prismock.team.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
isOrganization: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
|
||||
return prismock.membership.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
userId: data.userId,
|
||||
teamId: data.teamId,
|
||||
role: data.role || MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestOnboarding(overrides?: Partial<typeof mockOrganizationOnboardingData>) {
|
||||
return await prismock.organizationOnboarding.create({
|
||||
data: {
|
||||
...mockOrganizationOnboardingData,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mockOrganizationOnboardingData = {
|
||||
id: "onboarding-123",
|
||||
organizationId: null,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
orgOwnerEmail: "owner@example.com",
|
||||
seats: 5,
|
||||
pricePerSeat: 20,
|
||||
billingPeriod: BillingPeriod.MONTHLY,
|
||||
invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }],
|
||||
teams: [
|
||||
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
|
||||
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
isPlatform: false,
|
||||
logo: null,
|
||||
bio: null,
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
stripeCustomerId: "cus_123",
|
||||
isDomainConfigured: false,
|
||||
isComplete: false,
|
||||
createdById: null,
|
||||
};
|
||||
|
||||
it("should require payment details for billing-enabled flow", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
|
||||
"payment_subscription_id_and_payment_subscription_item_id_are_required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization with existing user as owner", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(mockOrganizationOnboarding.name);
|
||||
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
|
||||
|
||||
// Verify owner is the existing user
|
||||
expect(owner.id).toBe(existingUser.id);
|
||||
expect(owner.email).toBe(existingUser.email);
|
||||
|
||||
expect(createDomain).toHaveBeenCalledWith(organization.slug);
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalled();
|
||||
expect(createTeamsHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reuse existing organization if organizationId is already set (idempotency)", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
const existingOrg = await createTestOrganization({
|
||||
name: mockOrganizationOnboarding.name,
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
});
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const onboardingWithOrgId = {
|
||||
...mockOrganizationOnboarding,
|
||||
organizationId: existingOrg.id,
|
||||
};
|
||||
|
||||
const { organization } = await service.createOrganization(onboardingWithOrgId, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
// Verify the existing organization was reused
|
||||
expect(organization.id).toBe(existingOrg.id);
|
||||
});
|
||||
|
||||
it("should throw error if organization with same slug exists", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
await createTestOrganization({
|
||||
name: "Conflicting Org",
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createOrganization(mockOrganizationOnboarding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
it("should update stripe customer ID for existing user", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
await service.createOrganization(mockOrganizationOnboarding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
// Verify user's stripe customer ID was updated
|
||||
const updatedUser = await prismock.user.findUnique({
|
||||
where: { id: existingUser.id },
|
||||
});
|
||||
|
||||
expect(updatedUser?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
stripeCustomerId: mockOrganizationOnboarding.stripeCustomerId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization even if there is a team with same slug that orgOwner is a member of", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const teamWithConflictingSlug = await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlug",
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Make the orgOwner a member of the team
|
||||
await createTestMembership({
|
||||
userId: existingUser.id,
|
||||
teamId: teamWithConflictingSlug.id,
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const { organization } = await service.createOrganization(mockOrganizationOnboarding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
|
||||
});
|
||||
|
||||
it("should not create organization if there is a team with same slug that orgOwner is NOT a member of", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createOrganization(mockOrganizationOnboarding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
it("should invite members with isDirectUserAction set to false", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
username: "org-owner",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.createOrganization(mockOrganizationOnboarding, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
// Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: result.organization.id,
|
||||
invitations: [
|
||||
{ usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
|
||||
{ usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
|
||||
],
|
||||
isDirectUserAction: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should invite members to specific teams based on teamName", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const onboardingWithTeamInvites = {
|
||||
...mockOrganizationOnboarding,
|
||||
teams: [
|
||||
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "marketer@example.com", teamName: "marketing", role: MembershipRole.MEMBER },
|
||||
{ email: "engineer@example.com", teamName: "engineering", role: MembershipRole.ADMIN },
|
||||
{ email: "orguser@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
// Setup: createTeamsHandler will be called and we need teams to exist for inviteMembers
|
||||
vi.mocked(createTeamsHandler).mockImplementation(async (options) => {
|
||||
const marketing = await prismock.team.create({
|
||||
data: {
|
||||
name: "Marketing",
|
||||
slug: "marketing",
|
||||
parentId: options.input.orgId,
|
||||
},
|
||||
});
|
||||
const engineering = await prismock.team.create({
|
||||
data: {
|
||||
name: "Engineering",
|
||||
slug: "engineering",
|
||||
parentId: options.input.orgId,
|
||||
},
|
||||
});
|
||||
return { teams: [marketing, engineering] } as any;
|
||||
});
|
||||
|
||||
const result = await service.createOrganization(onboardingWithTeamInvites, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
// Verify invites were sent to 3 different targets (org + 2 teams)
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Check org-level invite
|
||||
const orgInviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === result.organization.id);
|
||||
expect(orgInviteCall).toBeDefined();
|
||||
expect(orgInviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "orguser@example.com", role: MembershipRole.MEMBER },
|
||||
]);
|
||||
|
||||
// Check Marketing team invite
|
||||
const teams = await prismock.team.findMany({ where: { parentId: result.organization.id } });
|
||||
const marketingTeam = teams.find((t) => t.name === "Marketing");
|
||||
const marketingInviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === marketingTeam!.id);
|
||||
expect(marketingInviteCall).toBeDefined();
|
||||
expect(marketingInviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "marketer@example.com", role: MembershipRole.MEMBER },
|
||||
]);
|
||||
|
||||
// Check Engineering team invite
|
||||
const engineeringTeam = teams.find((t) => t.name === "Engineering");
|
||||
const engineeringInviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === engineeringTeam!.id);
|
||||
expect(engineeringInviteCall).toBeDefined();
|
||||
expect(engineeringInviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "engineer@example.com", role: MembershipRole.ADMIN },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve custom roles for invited members", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const onboardingWithRoles = {
|
||||
...mockOrganizationOnboarding,
|
||||
invitedMembers: [
|
||||
{ email: "admin@example.com", role: MembershipRole.ADMIN },
|
||||
{ email: "member@example.com", role: MembershipRole.MEMBER },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.createOrganization(onboardingWithRoles, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
const inviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === result.organization.id);
|
||||
|
||||
expect(inviteCall).toBeDefined();
|
||||
expect(inviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "admin@example.com", role: MembershipRole.ADMIN },
|
||||
{ usernameOrEmail: "member@example.com", role: MembershipRole.MEMBER },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle mixed org-level and team-specific invites", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const onboardingWithMixedInvites = {
|
||||
...mockOrganizationOnboarding,
|
||||
teams: [{ id: -1, name: "Sales", isBeingMigrated: false, slug: null }],
|
||||
invitedMembers: [
|
||||
{ email: "sales1@example.com", teamName: "sales", role: MembershipRole.MEMBER },
|
||||
{ email: "sales2@example.com", teamName: "sales", role: MembershipRole.ADMIN },
|
||||
{ email: "org1@example.com", role: MembershipRole.MEMBER },
|
||||
{ email: "org2@example.com", role: MembershipRole.MEMBER },
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(createTeamsHandler).mockImplementation(async (options) => {
|
||||
const salesTeam = await prismock.team.create({
|
||||
data: {
|
||||
name: "Sales",
|
||||
slug: "sales",
|
||||
parentId: options.input.orgId,
|
||||
},
|
||||
});
|
||||
return { teams: [salesTeam] } as any;
|
||||
});
|
||||
|
||||
const result = await service.createOrganization(onboardingWithMixedInvites, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Find org-level invites
|
||||
const orgInviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === result.organization.id);
|
||||
|
||||
expect(orgInviteCall).toBeDefined();
|
||||
expect(orgInviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "org1@example.com", role: MembershipRole.MEMBER },
|
||||
{ usernameOrEmail: "org2@example.com", role: MembershipRole.MEMBER },
|
||||
]);
|
||||
|
||||
// Find Sales team invites
|
||||
const teams = await prismock.team.findMany({ where: { parentId: result.organization.id } });
|
||||
const salesTeam = teams.find((t) => t.name === "Sales");
|
||||
const salesInviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === salesTeam!.id);
|
||||
|
||||
expect(salesInviteCall).toBeDefined();
|
||||
expect(salesInviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "sales1@example.com", role: MembershipRole.MEMBER },
|
||||
{ usernameOrEmail: "sales2@example.com", role: MembershipRole.ADMIN },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fall back to org-level invite if teamName does not match any created team", async () => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
|
||||
const mockOrganizationOnboarding = await createTestOnboarding();
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const onboardingWithNonMatchingTeam = {
|
||||
...mockOrganizationOnboarding,
|
||||
teams: [{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null }],
|
||||
invitedMembers: [
|
||||
{ email: "user@example.com", teamName: "NonExistentTeam", role: MembershipRole.MEMBER },
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(createTeamsHandler).mockResolvedValue({
|
||||
teams: [{ id: 300, name: "Marketing" }],
|
||||
} as any);
|
||||
|
||||
const result = await service.createOrganization(onboardingWithNonMatchingTeam, {
|
||||
subscriptionId: "sub_123",
|
||||
subscriptionItemId: "si_123",
|
||||
});
|
||||
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: result.organization.id,
|
||||
invitations: [{ usernameOrEmail: "user@example.com", role: MembershipRole.MEMBER }],
|
||||
isDirectUserAction: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
const mockRegularUser = {
|
||||
id: 1,
|
||||
email: "user@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
name: "Regular User",
|
||||
};
|
||||
|
||||
const mockAdminUser = {
|
||||
id: 2,
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
name: "Admin User",
|
||||
};
|
||||
|
||||
describe("OrganizationOnboardingFactory", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should return BillingEnabledOrgOnboardingService for regular user when billing is enabled", async () => {
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(mockRegularUser as any);
|
||||
|
||||
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
|
||||
});
|
||||
|
||||
it("should return BillingEnabledOrgOnboardingService for admin on hosted (billing enabled)", async () => {
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(mockAdminUser as any);
|
||||
|
||||
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
|
||||
});
|
||||
|
||||
it("should return SelfHostedOrganizationOnboardingService for admin when IS_TEAM_BILLING_ENABLED is false", async () => {
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: false,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(mockAdminUser as any);
|
||||
|
||||
// Self-hosted admins skip billing
|
||||
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
|
||||
});
|
||||
|
||||
it("should return SelfHostedOrganizationOnboardingService for regular user when IS_TEAM_BILLING_ENABLED is false", async () => {
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: false,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(mockRegularUser as any);
|
||||
|
||||
// When billing is disabled, everyone uses self-hosted flow
|
||||
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
|
||||
});
|
||||
|
||||
it("should return SelfHostedOrganizationOnboardingService in E2E mode", async () => {
|
||||
process.env.NEXT_PUBLIC_IS_E2E = "1";
|
||||
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(mockAdminUser as any);
|
||||
|
||||
// E2E mode returns false from isBillingEnabled, meaning SelfHosted flow
|
||||
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
|
||||
});
|
||||
|
||||
it("should return BillingEnabledOrgOnboardingService when billing is enabled for regular user", async () => {
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(mockRegularUser as any);
|
||||
|
||||
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Factory Decision Matrix", () => {
|
||||
const testCases = [
|
||||
{
|
||||
scenario: "Hosted (billing enabled) + Regular User",
|
||||
user: mockRegularUser,
|
||||
isBillingEnabled: true,
|
||||
isE2E: false,
|
||||
expected: "BillingEnabled",
|
||||
},
|
||||
{
|
||||
scenario: "Hosted (billing enabled) + Admin User",
|
||||
user: mockAdminUser,
|
||||
isBillingEnabled: true,
|
||||
isE2E: false,
|
||||
expected: "BillingEnabled",
|
||||
},
|
||||
{
|
||||
scenario: "Self-hosted (billing disabled) + Regular User",
|
||||
user: mockRegularUser,
|
||||
isBillingEnabled: false,
|
||||
isE2E: false,
|
||||
expected: "SelfHosted", // Everyone uses self-hosted when billing disabled
|
||||
},
|
||||
{
|
||||
scenario: "Self-hosted (billing disabled) + Admin User",
|
||||
user: mockAdminUser,
|
||||
isBillingEnabled: false,
|
||||
isE2E: false,
|
||||
expected: "SelfHosted", // Everyone uses self-hosted when billing disabled
|
||||
},
|
||||
{
|
||||
scenario: "E2E mode + Admin User",
|
||||
user: mockAdminUser,
|
||||
isBillingEnabled: true,
|
||||
isE2E: true,
|
||||
expected: "SelfHosted", // E2E always uses self-hosted flow
|
||||
},
|
||||
{
|
||||
scenario: "E2E mode + Regular User",
|
||||
user: mockRegularUser,
|
||||
isBillingEnabled: true,
|
||||
isE2E: true,
|
||||
expected: "SelfHosted", // E2E always uses self-hosted flow
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ scenario, user, isBillingEnabled, isE2E, expected }) => {
|
||||
it(`${scenario} → ${expected}`, async () => {
|
||||
// Setup environment
|
||||
if (isE2E) {
|
||||
process.env.NEXT_PUBLIC_IS_E2E = "1";
|
||||
} else {
|
||||
delete process.env.NEXT_PUBLIC_IS_E2E;
|
||||
}
|
||||
|
||||
vi.doMock("@calcom/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
IS_TEAM_BILLING_ENABLED: isBillingEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
const { OrganizationOnboardingFactory: Factory } = await import("../OrganizationOnboardingFactory");
|
||||
const service = Factory.create(user as any);
|
||||
|
||||
if (expected === "BillingEnabled") {
|
||||
expect(service.constructor.name).toBe("BillingEnabledOrgOnboardingService");
|
||||
} else {
|
||||
expect(service.constructor.name).toBe("SelfHostedOrganizationOnboardingService");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
import prismock from "../../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import { UserPermissionRole, CreationSource, MembershipRole, BillingPeriod } from "@calcom/prisma/enums";
|
||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
import { SelfHostedOrganizationOnboardingService } from "../SelfHostedOnboardingService";
|
||||
import type { CreateOnboardingIntentInput, OrganizationOnboardingData } from "../types";
|
||||
|
||||
vi.mock("../../OrganizationPaymentService");
|
||||
vi.mock("@calcom/lib/server/repository/organizationOnboarding");
|
||||
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
|
||||
LicenseKeySingleton: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
|
||||
sendEmailVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/emails/email-manager", () => ({
|
||||
sendOrganizationCreationEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler", () => ({
|
||||
inviteMembersWithNoInviterPermissionCheck: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/organizations/createTeams.handler", () => ({
|
||||
createTeamsHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/domainManager/organization", () => ({
|
||||
createDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockAdminUser = {
|
||||
id: 2,
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
name: "Admin User",
|
||||
};
|
||||
|
||||
const mockInput: CreateOnboardingIntentInput = {
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
orgOwnerEmail: "owner@example.com",
|
||||
seats: 10,
|
||||
pricePerSeat: 20,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
logo: "https://example.com/logo.png",
|
||||
bio: "Test bio",
|
||||
brandColor: "#000000",
|
||||
bannerUrl: "https://example.com/banner.png",
|
||||
teams: [
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: "member2@example.com", name: "Member 2" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("SelfHostedOrganizationOnboardingService", () => {
|
||||
let service: SelfHostedOrganizationOnboardingService;
|
||||
let mockPaymentService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
// @ts-expect-error reset is a method on Prismock
|
||||
await prismock.reset();
|
||||
|
||||
// Mock license check
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
mockPaymentService = {
|
||||
createOrganizationOnboarding: vi.fn().mockResolvedValue({
|
||||
id: "onboarding-123",
|
||||
name: mockInput.name,
|
||||
slug: mockInput.slug,
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
seats: mockInput.seats,
|
||||
pricePerSeat: mockInput.pricePerSeat,
|
||||
billingPeriod: "MONTHLY",
|
||||
isComplete: false,
|
||||
stripeCustomerId: null,
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(OrganizationOnboardingRepository.update).mockResolvedValue({
|
||||
id: "onboarding-123",
|
||||
teams: [],
|
||||
invitedMembers: [],
|
||||
} as any);
|
||||
|
||||
vi.mocked(OrganizationOnboardingRepository.markAsComplete).mockResolvedValue({} as any);
|
||||
|
||||
service = new SelfHostedOrganizationOnboardingService(mockAdminUser as any, mockPaymentService);
|
||||
});
|
||||
|
||||
describe("createOnboardingIntent", () => {
|
||||
it("should create onboarding record", async () => {
|
||||
await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: mockInput.name,
|
||||
slug: mockInput.slug,
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
createdByUserId: mockAdminUser.id,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should store teams and invites in onboarding record", async () => {
|
||||
await service.createOnboardingIntent(mockInput);
|
||||
|
||||
// Teams and invites are now passed during creation, not via update
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: expect.arrayContaining([
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
]),
|
||||
invitedMembers: expect.arrayContaining([
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: "member2@example.com", name: "Member 2" },
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should mark onboarding as complete", async () => {
|
||||
await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(OrganizationOnboardingRepository.markAsComplete).toHaveBeenCalledWith("onboarding-123");
|
||||
});
|
||||
|
||||
it("should return organization ID (not checkout URL)", async () => {
|
||||
const result = await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(result.organizationId).toBe(1);
|
||||
expect(result.checkoutUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("should filter out empty team names", async () => {
|
||||
const inputWithEmptyTeams = {
|
||||
...mockInput,
|
||||
teams: [
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: " ", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithEmptyTeams);
|
||||
|
||||
// Teams are now passed during creation with empty names filtered out
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out empty invite emails", async () => {
|
||||
const inputWithEmptyInvites = {
|
||||
...mockInput,
|
||||
invitedMembers: [
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: " ", name: "Empty" },
|
||||
{ email: "", name: "Empty 2" },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithEmptyInvites);
|
||||
|
||||
// Invites are now passed during creation with empty emails filtered out
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
invitedMembers: [{ email: "member1@example.com", name: "Member 1" }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization immediately", async () => {
|
||||
const result = await service.createOnboardingIntent(mockInput);
|
||||
|
||||
// Organization created immediately, not pending payment
|
||||
expect(result.organizationId).toBe(1);
|
||||
expect(result.checkoutUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("should return all required fields", async () => {
|
||||
const result = await service.createOnboardingIntent(mockInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
userId: mockAdminUser.id,
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
name: mockInput.name,
|
||||
slug: mockInput.slug,
|
||||
seats: mockInput.seats,
|
||||
pricePerSeat: mockInput.pricePerSeat,
|
||||
billingPeriod: mockInput.billingPeriod,
|
||||
isPlatform: mockInput.isPlatform,
|
||||
organizationOnboardingId: "onboarding-123",
|
||||
checkoutUrl: null,
|
||||
organizationId: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrganization", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true);
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
async function createTestUser(data: {
|
||||
email: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
metadata?: any;
|
||||
onboardingCompleted?: boolean;
|
||||
emailVerified?: Date | null;
|
||||
}) {
|
||||
return prismock.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name || "Test User",
|
||||
username: data.username || "testuser",
|
||||
metadata: data.metadata || {},
|
||||
completedOnboarding: data.onboardingCompleted,
|
||||
emailVerified: data.emailVerified,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestTeam(data: { name: string; slug: string }) {
|
||||
return prismock.team.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
isOrganization: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
|
||||
return prismock.membership.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
userId: data.userId,
|
||||
teamId: data.teamId,
|
||||
role: data.role || MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mockOrganizationOnboarding: OrganizationOnboardingData = {
|
||||
id: "onboarding-123",
|
||||
organizationId: null,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
orgOwnerEmail: "test@example.com",
|
||||
billingPeriod: BillingPeriod.MONTHLY,
|
||||
seats: 5,
|
||||
pricePerSeat: 20,
|
||||
stripeCustomerId: null,
|
||||
invitedMembers: [{ email: "member1@example.com" }, { email: "member2@example.com" }],
|
||||
teams: [
|
||||
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
|
||||
{ id: 2, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
logo: null,
|
||||
bio: null,
|
||||
brandColor: null,
|
||||
bannerUrl: null,
|
||||
isDomainConfigured: false,
|
||||
isPlatform: false,
|
||||
};
|
||||
|
||||
it("should fail if the license is invalid", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(false),
|
||||
} as any);
|
||||
|
||||
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
|
||||
"Self hosted license not valid"
|
||||
);
|
||||
});
|
||||
|
||||
it("should create an organization with a valid license", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding);
|
||||
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(mockOrganizationOnboarding.name);
|
||||
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
|
||||
expect(owner).toBeDefined();
|
||||
expect(owner.id).toBe(existingUser.id);
|
||||
});
|
||||
|
||||
it("should create organization with existing user as owner", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const { organization, owner } = await service.createOrganization(mockOrganizationOnboarding);
|
||||
|
||||
expect(organization).toBeDefined();
|
||||
expect(owner.id).toBe(existingUser.id);
|
||||
expect(owner.email).toBe(existingUser.email);
|
||||
|
||||
// Verify teams and members
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalled();
|
||||
expect(createTeamsHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error if organization with same slug exists", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
// Create existing organization with same slug
|
||||
await prismock.team.create({
|
||||
data: {
|
||||
name: "Conflicting Org",
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
|
||||
"organization_url_taken"
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization with slug set even if there is a team with same slug owned by orgOwner", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
const existingUser = await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const teamWithConflictingSlug = await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlug",
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Make the orgOwner a member of the team
|
||||
await createTestMembership({
|
||||
userId: existingUser.id,
|
||||
teamId: teamWithConflictingSlug.id,
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const { organization } = await service.createOrganization(mockOrganizationOnboarding);
|
||||
|
||||
expect(organization.slug).toBe(mockOrganizationOnboarding.slug);
|
||||
});
|
||||
|
||||
it("should not create organization if there is a team with same slug not owned by orgOwner", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
|
||||
slug: mockOrganizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Don't make the orgOwner a member of the team
|
||||
|
||||
await expect(service.createOrganization(mockOrganizationOnboarding)).rejects.toThrow(
|
||||
"organization_url_taken"
|
||||
);
|
||||
});
|
||||
|
||||
it("should invite members with isDirectUserAction set to false", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
await createTestUser({
|
||||
email: mockOrganizationOnboarding.orgOwnerEmail,
|
||||
username: "org-owner",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.createOrganization(mockOrganizationOnboarding);
|
||||
|
||||
// Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: result.organization.id,
|
||||
invitations: [
|
||||
{ usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
|
||||
{ usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
|
||||
],
|
||||
isDirectUserAction: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should invite members to specific teams based on teamName", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
} as any);
|
||||
|
||||
await createTestUser({
|
||||
email: "owner@example.com",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const onboardingWithTeamInvites: OrganizationOnboardingData = {
|
||||
...mockOrganizationOnboarding,
|
||||
orgOwnerEmail: "owner@example.com",
|
||||
teams: [
|
||||
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "marketer@example.com", teamName: "marketing", role: "MEMBER" },
|
||||
{ email: "engineer@example.com", teamName: "engineering", role: "ADMIN" },
|
||||
{ email: "orguser@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
// Setup: createTeamsHandler will be called and we need teams to exist for inviteMembers
|
||||
vi.mocked(createTeamsHandler).mockImplementation(async (options) => {
|
||||
const marketing = await prismock.team.create({
|
||||
data: {
|
||||
name: "Marketing",
|
||||
slug: "marketing",
|
||||
parentId: options.input.orgId,
|
||||
},
|
||||
});
|
||||
const engineering = await prismock.team.create({
|
||||
data: {
|
||||
name: "Engineering",
|
||||
slug: "engineering",
|
||||
parentId: options.input.orgId,
|
||||
},
|
||||
});
|
||||
return { teams: [marketing, engineering] } as any;
|
||||
});
|
||||
|
||||
const result = await service.createOrganization(onboardingWithTeamInvites);
|
||||
|
||||
// Verify invites were sent to 3 different targets (org + 2 teams)
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Check org-level invite
|
||||
const orgInviteCall = vi
|
||||
.mocked(inviteMembersWithNoInviterPermissionCheck)
|
||||
.mock.calls.find((call) => call[0].teamId === result.organization.id);
|
||||
expect(orgInviteCall).toBeDefined();
|
||||
expect(orgInviteCall![0].invitations).toEqual([
|
||||
{ usernameOrEmail: "orguser@example.com", role: MembershipRole.MEMBER },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export { OrganizationOnboardingFactory } from "./OrganizationOnboardingFactory";
|
||||
export type { IOrganizationOnboardingService } from "./IOrganizationOnboardingService";
|
||||
export type { CreateOnboardingIntentInput, OnboardingIntentResult } from "./types";
|
||||
export { BillingEnabledOrgOnboardingService } from "../service/BillingEnabledOrgOnboardingService";
|
||||
export { SelfHostedOrganizationOnboardingService } from "./SelfHostedOnboardingService";
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { BillingPeriod, CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
export type TeamInput = {
|
||||
id: number;
|
||||
name: string;
|
||||
isBeingMigrated: boolean;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type InvitedMemberInput = {
|
||||
email: string;
|
||||
name?: string;
|
||||
teamId?: number;
|
||||
teamName?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
export type CreateOnboardingIntentInput = {
|
||||
name: string;
|
||||
slug: string;
|
||||
orgOwnerEmail: string;
|
||||
seats?: number | null;
|
||||
pricePerSeat?: number | null;
|
||||
billingPeriod?: BillingPeriod;
|
||||
isPlatform: boolean;
|
||||
creationSource: CreationSource;
|
||||
logo?: string | null;
|
||||
bio?: string | null;
|
||||
brandColor?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
teams?: TeamInput[];
|
||||
invitedMembers?: InvitedMemberInput[];
|
||||
onboardingId?: string;
|
||||
};
|
||||
|
||||
export type OnboardingIntentResult = {
|
||||
userId: number;
|
||||
orgOwnerEmail: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
seats: number | null;
|
||||
pricePerSeat: number | null;
|
||||
billingPeriod?: BillingPeriod;
|
||||
isPlatform: boolean;
|
||||
organizationOnboardingId: string;
|
||||
checkoutUrl: string | null;
|
||||
organizationId?: number | null;
|
||||
handoverUrl?: string | null;
|
||||
};
|
||||
|
||||
export interface OnboardingUser {
|
||||
id: number;
|
||||
email: string;
|
||||
role: "ADMIN" | "USER";
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type OrganizationOnboardingData = {
|
||||
id: string;
|
||||
organizationId: number | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
orgOwnerEmail: string;
|
||||
seats: number | null;
|
||||
pricePerSeat: number | null;
|
||||
billingPeriod: BillingPeriod;
|
||||
invitedMembers: Prisma.JsonValue;
|
||||
teams: Prisma.JsonValue;
|
||||
isPlatform: boolean;
|
||||
logo: string | null;
|
||||
bio: string | null;
|
||||
brandColor: string | null;
|
||||
bannerUrl: string | null;
|
||||
stripeCustomerId: string | null;
|
||||
isDomainConfigured: boolean;
|
||||
};
|
||||
|
||||
export type OrganizationData = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOrganizationConfigured: boolean;
|
||||
isOrganizationAdminReviewed: boolean;
|
||||
autoAcceptEmail: string;
|
||||
seats: number | null;
|
||||
pricePerSeat: number | null;
|
||||
isPlatform: boolean;
|
||||
logoUrl: string | null;
|
||||
bio: string | null;
|
||||
brandColor: string | null;
|
||||
bannerUrl: string | null;
|
||||
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
||||
};
|
||||
|
||||
export type TeamData = {
|
||||
id: number;
|
||||
name: string;
|
||||
isBeingMigrated: boolean;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type InvitedMember = {
|
||||
email: string;
|
||||
name?: string;
|
||||
teamId?: number;
|
||||
teamName?: string;
|
||||
role?: string;
|
||||
};
|
||||
@@ -6,3 +6,45 @@ export function extractDomainFromEmail(email: string) {
|
||||
} catch (ignore) {}
|
||||
return out.split(".")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email is a company email (not a personal email provider)
|
||||
*/
|
||||
export function isCompanyEmail(email: string): boolean {
|
||||
// A list of popular @domains that are personal email providers
|
||||
const personalEmailProviders = [
|
||||
"gmail.com",
|
||||
"yahoo.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"aol.com",
|
||||
"icloud.com",
|
||||
"mail.com",
|
||||
"protonmail.com",
|
||||
"proton.me",
|
||||
"zoho.com",
|
||||
"yandex.com",
|
||||
"gmx.com",
|
||||
"fastmail.com",
|
||||
"inbox.com",
|
||||
"me.com",
|
||||
"hushmail.com",
|
||||
"live.com",
|
||||
"rediffmail.com",
|
||||
"tutanota.com",
|
||||
"mail.ru",
|
||||
"usa.com",
|
||||
"qq.com",
|
||||
"163.com",
|
||||
"web.de",
|
||||
"rocketmail.com",
|
||||
"excite.com",
|
||||
"lycos.com",
|
||||
"outlook.co",
|
||||
"hotmail.co.uk",
|
||||
];
|
||||
|
||||
const emailParts = email.split("@");
|
||||
if (emailParts.length < 2) return false;
|
||||
return !personalEmailProviders.includes(emailParts[1].toLowerCase());
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export const createOrganizationSchema = z.object({
|
||||
language: z.string().optional(),
|
||||
logo: z.string().nullish(),
|
||||
bio: z.string().nullish(),
|
||||
brandColor: z.string().nullish(),
|
||||
bannerUrl: z.string().nullish(),
|
||||
onboardingId: z.string(),
|
||||
invitedMembers: orgOnboardingInvitedMembersSchema.optional(),
|
||||
teams: orgOnboardingTeamsSchema.optional(),
|
||||
|
||||
@@ -28,6 +28,7 @@ export type AppFlags = {
|
||||
"tiered-support-chat": boolean;
|
||||
"calendar-subscription-cache": boolean;
|
||||
"calendar-subscription-sync": boolean;
|
||||
"onboarding-v3": boolean;
|
||||
"booker-botid": boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const initialData: AppFlags = {
|
||||
"tiered-support-chat": false,
|
||||
"calendar-subscription-cache": false,
|
||||
"calendar-subscription-sync": false,
|
||||
"onboarding-v3": false,
|
||||
"booker-botid": false,
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export type CreateOrganizationOnboardingInput = {
|
||||
slug: string;
|
||||
logo?: string | null;
|
||||
bio?: string | null;
|
||||
brandColor?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
stripeCustomerId?: string;
|
||||
stripeSubscriptionId?: string;
|
||||
stripeSubscriptionItemId?: string;
|
||||
@@ -41,6 +43,8 @@ export class OrganizationOnboardingRepository {
|
||||
slug: data.slug,
|
||||
logo: data.logo,
|
||||
bio: data.bio,
|
||||
brandColor: data.brandColor,
|
||||
bannerUrl: data.bannerUrl,
|
||||
stripeCustomerId: data.stripeCustomerId,
|
||||
stripeSubscriptionId: data.stripeSubscriptionId,
|
||||
invitedMembers: data.invitedMembers || [],
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
INSERT INTO
|
||||
"Feature" (slug, enabled, description, "type")
|
||||
VALUES
|
||||
(
|
||||
'onboarding-v3',
|
||||
false,
|
||||
'Enable onboarding v3 for all users',
|
||||
'EXPERIMENT'
|
||||
) ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganizationOnboarding" ADD COLUMN "bannerUrl" TEXT,
|
||||
ADD COLUMN "brandColor" TEXT;
|
||||
@@ -2424,6 +2424,8 @@ model OrganizationOnboarding {
|
||||
slug String
|
||||
logo String?
|
||||
bio String?
|
||||
brandColor String?
|
||||
bannerUrl String?
|
||||
isDomainConfigured Boolean @default(false)
|
||||
|
||||
// Set when payment intent is there.
|
||||
|
||||
@@ -58,7 +58,13 @@ export const bookerLayouts = z
|
||||
.nullable();
|
||||
|
||||
export const orgOnboardingInvitedMembersSchema = z.array(
|
||||
z.object({ email: z.string().email(), name: z.string().optional() })
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
teamId: z.number().optional(),
|
||||
teamName: z.string().optional(),
|
||||
role: z.enum(["MEMBER", "ADMIN"]).optional().default("MEMBER"),
|
||||
})
|
||||
);
|
||||
|
||||
export const orgOnboardingTeamsSchema = z.array(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lookup } from "dns";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { isNotACompanyEmail } from "@calcom/ee/organizations/lib/server/orgCreationUtils";
|
||||
import { sendAdminOrganizationNotification, sendOrganizationCreationEmail } from "@calcom/emails";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
@@ -23,47 +24,6 @@ import type { TrpcSessionUser } from "../../../types";
|
||||
import { BillingPeriod } from "./create.schema";
|
||||
import type { TCreateInputSchema } from "./create.schema";
|
||||
|
||||
/**
|
||||
* We can only say for sure that the email is not a company email. We can't say for sure if it is a company email.
|
||||
*/
|
||||
function isNotACompanyEmail(email: string) {
|
||||
// A list of popular @domains that can't be used to allow automatic acceptance of memberships to organization
|
||||
const emailProviders = [
|
||||
"gmail.com",
|
||||
"yahoo.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"aol.com",
|
||||
"icloud.com",
|
||||
"mail.com",
|
||||
"protonmail.com",
|
||||
"zoho.com",
|
||||
"yandex.com",
|
||||
"gmx.com",
|
||||
"fastmail.com",
|
||||
"inbox.com",
|
||||
"me.com",
|
||||
"hushmail.com",
|
||||
"live.com",
|
||||
"rediffmail.com",
|
||||
"tutanota.com",
|
||||
"mail.ru",
|
||||
"usa.com",
|
||||
"qq.com",
|
||||
"163.com",
|
||||
"web.de",
|
||||
"rocketmail.com",
|
||||
"excite.com",
|
||||
"lycos.com",
|
||||
"outlook.co",
|
||||
"hotmail.co.uk",
|
||||
];
|
||||
|
||||
const emailParts = email.split("@");
|
||||
if (emailParts.length < 2) return true;
|
||||
return emailProviders.includes(emailParts[1]);
|
||||
}
|
||||
|
||||
type CreateOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
|
||||
import { SelfHostedOrganizationOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
|
||||
@@ -45,24 +45,32 @@ export const createSelfHostedHandler = async ({ input, ctx }: CreateSelfHostedOp
|
||||
});
|
||||
}
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding: {
|
||||
id: input.onboardingId,
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
invitedMembers: input.invitedMembers ?? [],
|
||||
teams: input.teams ?? [],
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
slug: organizationOnboarding.slug,
|
||||
name: organizationOnboarding.name,
|
||||
billingPeriod: organizationOnboarding.billingPeriod,
|
||||
seats: organizationOnboarding.seats,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
isPlatform: organizationOnboarding.isPlatform,
|
||||
isDomainConfigured: organizationOnboarding.isDomainConfigured,
|
||||
organizationId: organizationOnboarding.organizationId,
|
||||
},
|
||||
const userContext = {
|
||||
id: ctx.user.id,
|
||||
email: ctx.user.email,
|
||||
role: ctx.user.role as "ADMIN" | "USER",
|
||||
name: ctx.user.name || undefined,
|
||||
};
|
||||
|
||||
const onboardingService = new SelfHostedOrganizationOnboardingService(userContext);
|
||||
const { organization } = await onboardingService.createOrganization({
|
||||
id: input.onboardingId,
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
invitedMembers: input.invitedMembers ?? [],
|
||||
teams: input.teams ?? [],
|
||||
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||
slug: organizationOnboarding.slug,
|
||||
name: organizationOnboarding.name,
|
||||
billingPeriod: organizationOnboarding.billingPeriod,
|
||||
seats: organizationOnboarding.seats,
|
||||
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
isPlatform: organizationOnboarding.isPlatform,
|
||||
isDomainConfigured: organizationOnboarding.isDomainConfigured,
|
||||
organizationId: organizationOnboarding.organizationId,
|
||||
});
|
||||
|
||||
await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id);
|
||||
|
||||
+24
-1
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* @deprecated This endpoint is deprecated. Use `intentToCreateOrg` instead, which now handles
|
||||
* teams and invites in a single mutation call. This endpoint will be removed in a future release.
|
||||
*
|
||||
* Migration guide:
|
||||
* - Instead of calling `intentToCreateOrg` followed by `createWithPaymentIntent`,
|
||||
* pass teams and invitedMembers directly to `intentToCreateOrg`
|
||||
* - The new endpoint returns checkoutUrl directly in the response
|
||||
*/
|
||||
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
@@ -15,8 +24,20 @@ type CreateOptions = {
|
||||
input: TCreateWithPaymentIntentInputSchema;
|
||||
};
|
||||
const log = logger.getSubLogger({ prefix: ["viewer", "organizations", "createWithPaymentIntent"] });
|
||||
|
||||
/**
|
||||
* @deprecated Use intentToCreateOrg with teams and invitedMembers instead
|
||||
*/
|
||||
export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||
const paymentService = new OrganizationPaymentService(ctx.user);
|
||||
log.warn(
|
||||
"DEPRECATED: createWithPaymentIntent is deprecated. Use intentToCreateOrg with teams and invitedMembers instead."
|
||||
);
|
||||
const paymentService = new OrganizationPaymentService({
|
||||
id: ctx.user.id,
|
||||
email: ctx.user.email,
|
||||
role: ctx.user.role,
|
||||
name: ctx.user.name ?? undefined,
|
||||
});
|
||||
const isAdmin = ctx.user.role === "ADMIN";
|
||||
// Regular user can send onboardingId if the onboarding was started by ADMIN/someone else and they shared the link with them.
|
||||
// ADMIN flow doesn't send onboardingId
|
||||
@@ -57,6 +78,8 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
||||
...input,
|
||||
logo: input.logo ?? null,
|
||||
bio: input.bio ?? null,
|
||||
brandColor: input.brandColor ?? null,
|
||||
bannerUrl: input.bannerUrl ?? null,
|
||||
},
|
||||
organizationOnboarding
|
||||
);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @deprecated This schema is deprecated. Use ZIntentToCreateOrgInputSchema instead.
|
||||
* See createWithPaymentIntent.handler.ts for migration guide.
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
|
||||
import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas";
|
||||
@@ -7,6 +12,9 @@ export enum BillingPeriod {
|
||||
ANNUALLY = "ANNUALLY",
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use ZIntentToCreateOrgInputSchema instead
|
||||
*/
|
||||
export const ZCreateWithPaymentIntentInputSchema = createOrganizationSchema;
|
||||
|
||||
export type TCreateWithPaymentIntentInputSchema = z.infer<typeof ZCreateWithPaymentIntentInputSchema>;
|
||||
|
||||
+165
-6
@@ -3,7 +3,8 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
|
||||
import { BillingPeriod, UserPermissionRole, CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -15,6 +16,8 @@ vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/ee/organizations/lib/OrganizationPaymentService");
|
||||
|
||||
const mockInput = {
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
@@ -23,6 +26,7 @@ const mockInput = {
|
||||
seats: 5,
|
||||
pricePerSeat: 20,
|
||||
isPlatform: false,
|
||||
creationSource: "WEBAPP",
|
||||
};
|
||||
|
||||
// Helper functions for creating test data
|
||||
@@ -49,8 +53,37 @@ async function createTestUser(data: {
|
||||
describe("intentToCreateOrgHandler", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
// @ts-expect-error reset is a method on Prismock
|
||||
await prismock.reset();
|
||||
|
||||
vi.mocked(OrganizationPaymentService).mockImplementation(() => {
|
||||
return {
|
||||
createOrganizationOnboarding: vi.fn().mockImplementation(async (data: any) => {
|
||||
return await prismock.organizationOnboarding.create({
|
||||
data: {
|
||||
id: "onboarding-123",
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
orgOwnerEmail: data.orgOwnerEmail,
|
||||
seats: data.seats ?? 10,
|
||||
pricePerSeat: data.pricePerSeat ?? 15,
|
||||
billingPeriod: data.billingPeriod ?? BillingPeriod.MONTHLY,
|
||||
isComplete: false,
|
||||
stripeCustomerId: null,
|
||||
createdById: data.createdByUserId,
|
||||
teams: data.teams ?? [],
|
||||
invitedMembers: data.invitedMembers ?? [],
|
||||
isPlatform: data.isPlatform ?? false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
createPaymentIntent: vi.fn().mockResolvedValue({
|
||||
checkoutUrl: "https://stripe.com/checkout/session",
|
||||
organizationOnboarding: {},
|
||||
subscription: {},
|
||||
sessionId: "session-123",
|
||||
}),
|
||||
} as any;
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosted", () => {
|
||||
@@ -64,7 +97,10 @@ describe("intentToCreateOrgHandler", () => {
|
||||
});
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
incrementUsage: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
process.env.STRIPE_ORG_PRODUCT_ID = "prod_test123";
|
||||
process.env.STRIPE_ORG_MONTHLY_PRICE_ID = "price_test123";
|
||||
});
|
||||
|
||||
it("should allow admin user to create org for another user", async () => {
|
||||
@@ -88,7 +124,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the result shape
|
||||
// Admin creating for different user with no teams/invites triggers handover
|
||||
expect(result).toEqual({
|
||||
userId: expect.any(Number),
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
@@ -99,6 +135,9 @@ describe("intentToCreateOrgHandler", () => {
|
||||
billingPeriod: mockInput.billingPeriod,
|
||||
isPlatform: mockInput.isPlatform,
|
||||
organizationOnboardingId: expect.any(String),
|
||||
checkoutUrl: null,
|
||||
organizationId: null, // Not created yet - handover flow
|
||||
handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="),
|
||||
});
|
||||
|
||||
// Verify organization onboarding was created
|
||||
@@ -193,7 +232,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when organization onboarding already exists", async () => {
|
||||
it("should throw error when organization onboarding already exists and is complete", async () => {
|
||||
// Create admin user
|
||||
const adminUser = await createTestUser({
|
||||
email: "admin@example.com",
|
||||
@@ -207,7 +246,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
// Create existing organization onboarding
|
||||
// Create existing COMPLETE organization onboarding
|
||||
await prismock.organizationOnboarding.create({
|
||||
data: {
|
||||
name: mockInput.name,
|
||||
@@ -217,6 +256,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
seats: mockInput.seats,
|
||||
pricePerSeat: mockInput.pricePerSeat,
|
||||
createdById: adminUser.id,
|
||||
isComplete: true, // Must be complete to throw error
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,6 +289,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
it("should throw error when license is not valid", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(false),
|
||||
incrementUsage: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const adminUser = await createTestUser({
|
||||
email: "admin@example.com",
|
||||
@@ -268,6 +309,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
it("should allow admin user to create org for another user", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
incrementUsage: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
// Create admin user
|
||||
const adminUser = await createTestUser({
|
||||
@@ -289,7 +331,7 @@ describe("intentToCreateOrgHandler", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the result shape
|
||||
// Admin creating for different user with no teams/invites triggers handover (same as hosted)
|
||||
expect(result).toEqual({
|
||||
userId: expect.any(Number),
|
||||
orgOwnerEmail: mockInput.orgOwnerEmail,
|
||||
@@ -300,6 +342,9 @@ describe("intentToCreateOrgHandler", () => {
|
||||
billingPeriod: mockInput.billingPeriod,
|
||||
isPlatform: mockInput.isPlatform,
|
||||
organizationOnboardingId: expect.any(String),
|
||||
checkoutUrl: null,
|
||||
organizationId: null, // Not created yet - handover flow
|
||||
handoverUrl: expect.stringContaining("/settings/organizations/new/resume?onboardingId="),
|
||||
});
|
||||
|
||||
// Verify organization onboarding was created
|
||||
@@ -314,5 +359,119 @@ describe("intentToCreateOrgHandler", () => {
|
||||
expect(organizationOnboarding?.slug).toBe(mockInput.slug);
|
||||
expect(organizationOnboarding?.orgOwnerEmail).toBe(mockInput.orgOwnerEmail);
|
||||
});
|
||||
|
||||
it("should handle teams and invites in the request", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
incrementUsage: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const adminUser = await createTestUser({
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
});
|
||||
|
||||
await createTestUser({
|
||||
email: mockInput.orgOwnerEmail,
|
||||
completedOnboarding: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const inputWithTeamsAndInvites = {
|
||||
...mockInput,
|
||||
teams: [
|
||||
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "member1@example.com", name: "Member 1" },
|
||||
{ email: "member2@example.com", name: "Member 2" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await intentToCreateOrgHandler({
|
||||
input: inputWithTeamsAndInvites,
|
||||
ctx: {
|
||||
user: adminUser,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.organizationOnboardingId).toBeDefined();
|
||||
|
||||
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
|
||||
where: {
|
||||
slug: mockInput.slug,
|
||||
},
|
||||
});
|
||||
|
||||
expect(organizationOnboarding).toBeDefined();
|
||||
expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams);
|
||||
expect(organizationOnboarding?.invitedMembers).toEqual(inputWithTeamsAndInvites.invitedMembers);
|
||||
});
|
||||
|
||||
it("should preserve teamName, teamId, and role in invites payload", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
incrementUsage: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const adminUser = await createTestUser({
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
});
|
||||
|
||||
await createTestUser({
|
||||
email: mockInput.orgOwnerEmail,
|
||||
completedOnboarding: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
// This matches the exact payload from the frontend
|
||||
const inputWithTeamsAndInvites = {
|
||||
...mockInput,
|
||||
teams: [
|
||||
{ id: -1, name: "New", isBeingMigrated: false, slug: null },
|
||||
{ id: -1, name: "team", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "new@new.com", teamName: "new", teamId: -1, role: "ADMIN" },
|
||||
{ email: "team@new.com", teamName: "team", teamId: -1, role: "ADMIN" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await intentToCreateOrgHandler({
|
||||
input: inputWithTeamsAndInvites,
|
||||
ctx: {
|
||||
user: adminUser,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.organizationOnboardingId).toBeDefined();
|
||||
|
||||
const organizationOnboarding = await prismock.organizationOnboarding.findFirst({
|
||||
where: {
|
||||
slug: mockInput.slug,
|
||||
},
|
||||
});
|
||||
|
||||
expect(organizationOnboarding).toBeDefined();
|
||||
expect(organizationOnboarding?.teams).toEqual(inputWithTeamsAndInvites.teams);
|
||||
|
||||
// Verify invitedMembers are stored with all fields including teamName, teamId, and role
|
||||
expect(organizationOnboarding?.invitedMembers).toBeDefined();
|
||||
expect(organizationOnboarding?.invitedMembers).toHaveLength(2);
|
||||
|
||||
const invitedMembers = organizationOnboarding?.invitedMembers as any[];
|
||||
expect(invitedMembers[0]).toMatchObject({
|
||||
email: "new@new.com",
|
||||
teamName: "new",
|
||||
teamId: -1,
|
||||
role: "ADMIN",
|
||||
});
|
||||
expect(invitedMembers[1]).toMatchObject({
|
||||
email: "team@new.com",
|
||||
teamName: "team",
|
||||
teamId: -1,
|
||||
role: "ADMIN",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { OrganizationPaymentService } from "@calcom/features/ee/organizations/lib/OrganizationPaymentService";
|
||||
import { OrganizationOnboardingFactory } from "@calcom/ee/organizations/lib/service/onboarding/OrganizationOnboardingFactory";
|
||||
import {
|
||||
assertCanCreateOrg,
|
||||
findUserToBeOrgOwner,
|
||||
@@ -27,7 +27,7 @@ type CreateOptions = {
|
||||
};
|
||||
|
||||
export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) => {
|
||||
const { slug, name, orgOwnerEmail, seats, pricePerSeat, billingPeriod, isPlatform } = input;
|
||||
const { slug, name, orgOwnerEmail, isPlatform } = input;
|
||||
log.debug(
|
||||
"Starting organization creation intent",
|
||||
safeStringify({ slug, name, orgOwnerEmail, isPlatform })
|
||||
@@ -41,7 +41,6 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) =>
|
||||
if (!hasValidLicense) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
// TODO: We need to send translation keys from here and frontend should translate it
|
||||
message: "License is not valid",
|
||||
});
|
||||
}
|
||||
@@ -75,9 +74,24 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) =>
|
||||
}
|
||||
log.debug("Found organization owner", safeStringify({ orgOwnerId: orgOwner.id, email: orgOwner.email }));
|
||||
|
||||
let organizationOnboarding = await OrganizationOnboardingRepository.findByOrgOwnerEmail(orgOwner.email);
|
||||
const organizationOnboarding = await OrganizationOnboardingRepository.findByOrgOwnerEmail(orgOwner.email);
|
||||
|
||||
// If onboarding exists and is incomplete, this is a resume flow (e.g., admin handover)
|
||||
// Allow proceeding with the existing onboarding record
|
||||
if (organizationOnboarding) {
|
||||
throw new Error("organization_onboarding_already_exists");
|
||||
if (organizationOnboarding.isComplete) {
|
||||
// Organization already created - shouldn't create another one
|
||||
throw new Error("organization_onboarding_already_exists");
|
||||
}
|
||||
|
||||
// Incomplete onboarding exists - this is expected for resume/handover flows
|
||||
log.debug(
|
||||
"Found incomplete onboarding record - proceeding with resume flow",
|
||||
safeStringify({ onboardingId: organizationOnboarding.id, slug })
|
||||
);
|
||||
|
||||
// Use existing onboarding ID for the resume flow
|
||||
input.onboardingId = organizationOnboarding.id;
|
||||
}
|
||||
|
||||
await assertCanCreateOrg({
|
||||
@@ -87,24 +101,16 @@ export const intentToCreateOrgHandler = async ({ input, ctx }: CreateOptions) =>
|
||||
restrictBasedOnMinimumPublishedTeams: !IS_USER_ADMIN,
|
||||
});
|
||||
|
||||
const paymentService = new OrganizationPaymentService(ctx.user);
|
||||
organizationOnboarding = await paymentService.createOrganizationOnboarding({
|
||||
...input,
|
||||
createdByUserId: loggedInUser.id,
|
||||
const onboardingService = OrganizationOnboardingFactory.create({
|
||||
id: ctx.user.id,
|
||||
email: ctx.user.email,
|
||||
role: ctx.user.role,
|
||||
});
|
||||
const result = await onboardingService.createOnboardingIntent(input);
|
||||
|
||||
log.debug("Organization creation intent successful", safeStringify({ slug, orgOwnerId: orgOwner.id }));
|
||||
return {
|
||||
userId: orgOwner.id,
|
||||
orgOwnerEmail,
|
||||
name,
|
||||
slug,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
billingPeriod,
|
||||
isPlatform,
|
||||
organizationOnboardingId: organizationOnboarding.id,
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default intentToCreateOrgHandler;
|
||||
|
||||
@@ -2,12 +2,8 @@ import { z } from "zod";
|
||||
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
export enum BillingPeriod {
|
||||
MONTHLY = "MONTHLY",
|
||||
ANNUALLY = "ANNUALLY",
|
||||
}
|
||||
import { BillingPeriod, CreationSource } from "@calcom/prisma/enums";
|
||||
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const ZIntentToCreateOrgInputSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -19,6 +15,16 @@ export const ZIntentToCreateOrgInputSchema = z.object({
|
||||
isPlatform: z.boolean().default(false),
|
||||
billingPeriod: z.nativeEnum(BillingPeriod).default(BillingPeriod.MONTHLY),
|
||||
creationSource: z.nativeEnum(CreationSource),
|
||||
// Brand fields
|
||||
logo: z.string().nullish(),
|
||||
bio: z.string().nullish(),
|
||||
brandColor: z.string().nullish(),
|
||||
bannerUrl: z.string().nullish(),
|
||||
// Teams and invites (new)
|
||||
teams: orgOnboardingTeamsSchema.optional(),
|
||||
invitedMembers: orgOnboardingInvitedMembersSchema.optional(),
|
||||
// Optional onboarding ID for resume flows (admin handover)
|
||||
onboardingId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TIntentToCreateOrgInputSchema = z.infer<typeof ZIntentToCreateOrgInputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user