Files
calendar/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts
T

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 };
};