568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
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/lib/server/repository/organization";
|
|
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
|
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 };
|
|
};
|