* refactor: Split EmailManager into focused service files - Created separate service files for different email categories: - auth-email-service.ts: Authentication and verification emails - organization-email-service.ts: Organization and team emails - billing-email-service.ts: Payment and credit-related emails - integration-email-service.ts: Integration and app-related emails - workflow-email-service.ts: Workflow and custom emails - recording-email-service.ts: Recording and transcript emails - Refactored email-manager.ts to keep only core booking lifecycle functions - Removed unused imports from email-manager.ts - Updated index.ts to export from all new service files - Updated all imports across the codebase to use package root (@calcom/emails) - Fixed lint warnings in handleChildrenEventTypes.ts This reduces the import cost of EmailManager by allowing consumers to import only the specific email services they need. Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: Update all imports to use direct service file paths - Update 49 files to import directly from service files instead of barrel file - Update packages/emails/index.ts to keep only email-manager and renderEmail exports - Fix dynamic import in passwordResetRequest.ts - Update renderEmail imports to use direct path - Update test file to import from specific service module - Fix ESLint warnings in modified files (unused variables, unused expressions) This ensures consumers only import the specific email services they need, reducing import cost by avoiding the barrel file pattern for service files. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: Use default import for renderEmail renderEmail is exported as a default export, not a named export. Changed from 'import { renderEmail }' to 'import renderEmail'. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: Update test mocks to use direct service file imports - Update handleNoShowFee.test.ts to mock @calcom/emails/billing-email-service - Update credit-service.test.ts to mock @calcom/emails/billing-email-service - These tests were failing because they were mocking the barrel file @calcom/emails which no longer exports service functions after the refactoring Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: unit test spy * fix: unit test mock * address cubic comments * fix: type error sendMonthlyDigestEmail * remove barrel file and sendEmail unused task * fixup! remove barrel file and sendEmail unused task * fixup! fixup! remove barrel file and sendEmail unused task * fix: integration test mock emails --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: hbjORbj <sldisek783@gmail.com>
297 lines
9.7 KiB
TypeScript
297 lines
9.7 KiB
TypeScript
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/organization-email-service";
|
|
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
|
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
|
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
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 { prisma } from "@calcom/prisma";
|
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import type { TrpcSessionUser } from "../../../types";
|
|
import { BillingPeriod } from "./create.schema";
|
|
import type { TCreateInputSchema } from "./create.schema";
|
|
|
|
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);
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* TODO: To be removed. We need to reuse the logic from orgCreationUtils like in intentToCreateOrgHandler
|
|
*/
|
|
export const createHandler = async ({ input, ctx }: CreateOptions) => {
|
|
const organizationRepository = getOrganizationRepository();
|
|
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,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!loggedInUser) throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized." });
|
|
|
|
const IS_USER_ADMIN = loggedInUser.role === UserPermissionRole.ADMIN;
|
|
|
|
// 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 (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: `You can't create a new team because you are already a part of ${hasExistingPlatformOrOrgTeam.team.name}`,
|
|
});
|
|
}
|
|
|
|
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 = isPlatform ? "UNUSED_FOR_PLATFORM" : orgOwnerEmail.split("@")[1];
|
|
|
|
const orgData = {
|
|
name,
|
|
slug,
|
|
isOrganizationConfigured,
|
|
isOrganizationAdminReviewed: IS_USER_ADMIN,
|
|
autoAcceptEmail,
|
|
seats: seats ?? null,
|
|
pricePerSeat: pricePerSeat ?? null,
|
|
isPlatform,
|
|
billingPeriod,
|
|
logoUrl: null,
|
|
bio: null,
|
|
paymentSubscriptionId: null,
|
|
brandColor: null,
|
|
bannerUrl: null,
|
|
};
|
|
|
|
// 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 new UserRepository(prisma).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 new UserRepository(prisma).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;
|