Files
calendar/packages/trpc/server/routers/viewer/organizations/create.handler.ts
T
41f63582b2 feat: Add booking and user creation source (#18768)
* migration and init to accept creationSource for new bookings

* V1 create booking

* V1 user creation

* webapp booking + V1 user

* user creation in V1, V2 and webapp

* booking source V2 and fix for v1 user

* fit type

* --fix type

* add test -- WIP

* fix type

* fix type

* ^

* Need more sleep zzz

* -_-

* bump libraries platform

* adds for v2 recurring booking

* fix lint

* instant meetings

* fix: api v2 creation source

* fixup! fix: api v2 creation source

* bump libraries

* add user

* fix test

* fixup! fix test

* add more source

* more source...

* fix type & test --1

* fix type & test --2

* typefix

* fixup test

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-01-27 11:01:33 +01:00

332 lines
10 KiB
TypeScript

import { lookup } from "dns";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { sendAdminOrganizationNotification, sendOrganizationCreationEmail } from "@calcom/emails";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import {
RESERVED_SUBDOMAINS,
ORG_SELF_SERVE_ENABLED,
ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
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>;
};
input: TCreateInputSchema;
};
const getIPAddress = async (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
lookup(url, (err, address) => {
if (err) reject(err);
resolve(address);
});
});
};
export const createHandler = async ({ input, ctx }: CreateOptions) => {
const {
slug,
name,
orgOwnerEmail,
seats,
pricePerSeat,
isPlatform,
billingPeriod: billingPeriodRaw,
creationSource,
} = input;
const loggedInUser = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
role: true,
email: true,
completedOnboarding: true,
emailVerified: true,
teams: {
select: {
team: {
select: {
slug: true,
isOrganization: true,
isPlatform: true,
},
},
},
},
},
});
if (!loggedInUser) throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." });
const IS_USER_ADMIN = loggedInUser.role === UserPermissionRole.ADMIN;
const verifiedUser = loggedInUser.completedOnboarding && !!loggedInUser.emailVerified;
// We only allow creating an annual billing period if you are a system admin
const billingPeriod = (IS_USER_ADMIN ? billingPeriodRaw : BillingPeriod.MONTHLY) ?? BillingPeriod.MONTHLY;
if (!ORG_SELF_SERVE_ENABLED && !IS_USER_ADMIN && !isPlatform) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only admins can create organizations" });
}
if (!IS_USER_ADMIN && loggedInUser.email !== orgOwnerEmail && !isPlatform) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only create organization where you are the owner",
});
}
if (isPlatform && !verifiedUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You need to complete onboarding before creating a platform team",
});
}
if (isNotACompanyEmail(orgOwnerEmail) && !isPlatform) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Use company email to create an organization" });
}
const publishedTeams = loggedInUser.teams.filter((team) => !!team.team.slug);
if (!IS_USER_ADMIN && publishedTeams.length < ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE && !isPlatform) {
throw new TRPCError({ code: "FORBIDDEN", message: "You need to have minimum published teams." });
}
let orgOwner = await prisma.user.findUnique({
where: {
email: orgOwnerEmail,
},
});
const hasAnOrgWithSameSlug = await prisma.team.findFirst({
where: {
slug: slug,
parentId: null,
isOrganization: true,
},
});
// Allow creating an organization with same requestedSlug as a non-org Team's slug
// It is needed so that later we can migrate the non-org Team(with the conflicting slug) to the newly created org
// Publishing the organization would fail if the team with the same slug is not migrated first
if (hasAnOrgWithSameSlug || RESERVED_SUBDOMAINS.includes(slug))
throw new TRPCError({ code: "BAD_REQUEST", message: "organization_url_taken" });
const hasExistingPlatformOrOrgTeam = loggedInUser?.teams.find((team) => {
return team.team.isPlatform || team.team.isOrganization;
});
if (!!hasExistingPlatformOrOrgTeam?.team && isPlatform) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already part of a team" });
}
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
const isOrganizationConfigured = isPlatform ? true : await createDomain(slug);
const loggedInUserTranslation = await getTranslation(ctx.user.locale, "common");
const inputLanguageTranslation = await getTranslation(input.language ?? "en", "common");
if (!isOrganizationConfigured) {
// Otherwise, we proceed to send an administrative email to admins regarding
// the need to configure DNS registry to support the newly created org
const instanceAdmins = await prisma.user.findMany({
where: { role: UserPermissionRole.ADMIN },
select: { email: true },
});
if (instanceAdmins.length) {
await sendAdminOrganizationNotification({
instanceAdmins,
orgSlug: slug,
ownerEmail: orgOwnerEmail,
webappIPAddress: await getIPAddress(
WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "")
),
t: loggedInUserTranslation,
});
} else {
console.warn("Organization created: subdomain not configured and couldn't notify adminnistrators");
}
}
const autoAcceptEmail = orgOwnerEmail.split("@")[1];
const orgData = {
name,
slug,
isOrganizationConfigured,
isOrganizationAdminReviewed: IS_USER_ADMIN,
autoAcceptEmail,
seats: seats ?? null,
pricePerSeat: pricePerSeat ?? null,
isPlatform,
billingPeriod,
};
// Create a new user and invite them as the owner of the organization
if (!orgOwner) {
const data = await OrganizationRepository.createWithNonExistentOwner({
orgData,
owner: {
email: orgOwnerEmail,
},
creationSource,
});
orgOwner = data.orgOwner;
const { organization, ownerProfile } = data;
const translation = await getTranslation(input.language ?? "en", "common");
await sendEmailVerification({
email: orgOwnerEmail,
language: ctx.user.locale,
username: ownerProfile.username || "",
isPlatform: isPlatform,
});
if (!isPlatform) {
await sendOrganizationCreationEmail({
language: translation,
from: ctx.user.name ?? `${organization.name}'s admin`,
to: orgOwnerEmail,
ownerNewUsername: ownerProfile.username,
ownerOldUsername: null,
orgDomain: getOrgFullOrigin(slug, { protocol: false }),
orgName: organization.name,
prevLink: null,
newLink: `${getOrgFullOrigin(slug, { protocol: true })}/${ownerProfile.username}`,
});
}
const user = await UserRepository.enrichUserWithItsProfile({
user: { ...orgOwner, organizationId: organization.id },
});
return {
userId: user.id,
email: user.email,
organizationId: user.organizationId,
upId: user.profile.upId,
};
} else {
// If we are making the loggedIn user the owner of the organization and he is already a part of an organization, we don't allow it because multi-org is not supported yet
const isLoggedInUserOrgOwner = orgOwner.id === loggedInUser.id;
if (ctx.user.profile.organizationId && isLoggedInUserOrgOwner) {
throw new TRPCError({ code: "FORBIDDEN", message: "You are part of an organization already" });
}
if (!orgOwner.emailVerified) {
throw new TRPCError({ code: "FORBIDDEN", message: "You need to verify your email first" });
}
const nonOrgUsernameForOwner = orgOwner.username || "";
const { organization, ownerProfile } = await OrganizationRepository.createWithExistingUserAsOwner({
orgData,
owner: {
id: orgOwner.id,
email: orgOwnerEmail,
nonOrgUsername: nonOrgUsernameForOwner,
},
});
if (!isPlatform) {
await sendOrganizationCreationEmail({
language: inputLanguageTranslation,
from: ctx.user.name ?? `${organization.name}'s admin`,
to: orgOwnerEmail,
ownerNewUsername: ownerProfile.username,
ownerOldUsername: nonOrgUsernameForOwner,
orgDomain: getOrgFullOrigin(slug, { protocol: false }),
orgName: organization.name,
prevLink: `${getOrgFullOrigin("", { protocol: true })}/${nonOrgUsernameForOwner}`,
newLink: `${getOrgFullOrigin(slug, { protocol: true })}/${ownerProfile.username}`,
});
}
if (!organization.id) throw Error("User not created");
const user = await UserRepository.enrichUserWithItsProfile({
user: { ...orgOwner, organizationId: organization.id },
});
await prisma.availability.createMany({
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
userId: user.id,
})),
});
return {
userId: user.id,
email: user.email,
organizationId: user.organizationId,
upId: user.profile.upId,
};
}
};
export default createHandler;