Files
cal-diy-oidc/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts
T

236 lines
8.1 KiB
TypeScript

import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { findUserToBeOrgOwner } from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/i18n/server";
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: Build and validate teams/invites (includes conflict slug detection)
const { teamsData, invitedMembersData } = await this.buildTeamsAndInvites(
input.slug,
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: organizationOnboarding.logo,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: organizationOnboarding.bannerUrl,
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 }> {
const organizationRepository = getOrganizationRepository();
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 };
}
}