2
0
Files
cal/calcom/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts
2024-08-09 00:39:27 +02:00

333 lines
8.5 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import stripe from "@calcom/features/ee/payments/server/stripe";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import { MembershipRole, RedirectType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import inviteMemberHandler from "../teams/inviteMember/inviteMember.handler";
import type { TCreateTeamsSchema } from "./createTeams.schema";
const log = logger.getSubLogger({ prefix: ["viewer/organizations/createTeams.handler"] });
type CreateTeamsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCreateTeamsSchema;
};
export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => {
// Whether self-serve or not, createTeams endpoint is accessed by Org Owner only.
// Even when instance admin creates an org, then by the time he reaches team creation steps, he has impersonated the org owner.
const organizationOwner = ctx.user;
if (!organizationOwner) {
throw new NoUserError();
}
const { orgId, moveTeams } = input;
// Remove empty team names that could be there due to the default empty team name
const teamNames = input.teamNames.filter((name) => name.trim().length > 0);
if (orgId !== organizationOwner.organizationId) {
throw new NotAuthorizedError();
}
// Validate user membership role
const userMembershipRole = await prisma.membership.findFirst({
where: {
userId: organizationOwner.id,
teamId: orgId,
role: {
in: ["OWNER", "ADMIN"],
},
// @TODO: not sure if this already setup earlier
// accepted: true,
},
select: {
role: true,
},
});
if (!userMembershipRole) {
throw new NotAuthorizedError();
}
const organization = await prisma.team.findFirst({
where: { id: orgId },
select: { slug: true, id: true, metadata: true },
});
if (!organization) throw new NoOrganizationError();
const parseTeams = teamMetadataSchema.safeParse(organization?.metadata);
if (!parseTeams.success) {
throw new InvalidMetadataError();
}
const metadata = parseTeams.success ? parseTeams.data : undefined;
if (!metadata?.requestedSlug && !organization?.slug) {
throw new NoOrganizationSlugError();
}
const [teamSlugs, userSlugs] = [
await prisma.team.findMany({ where: { parentId: orgId }, select: { slug: true } }),
await UserRepository.findManyByOrganization({ organizationId: orgId }),
];
const existingSlugs = teamSlugs
.flatMap((ts) => ts.slug ?? [])
.concat(userSlugs.flatMap((us) => us.username ?? []));
const duplicatedSlugs = existingSlugs.filter((slug) =>
teamNames.map((item) => slugify(item)).includes(slug)
);
await Promise.all(
moveTeams
.filter((team) => team.shouldMove)
.map(async ({ id: teamId, newSlug }) => {
await moveTeam({
teamId,
newSlug,
org: {
...organization,
ownerId: organizationOwner.id,
},
ctx,
});
})
);
if (duplicatedSlugs.length === teamNames.length) {
return { duplicatedSlugs };
}
await prisma.$transaction(
teamNames.flatMap((name) => {
if (!duplicatedSlugs.includes(slugify(name))) {
return prisma.team.create({
data: {
name,
parentId: orgId,
slug: slugify(name),
members: {
create: { userId: ctx.user.id, role: MembershipRole.OWNER, accepted: true },
},
},
});
} else {
return [];
}
})
);
return { duplicatedSlugs };
};
class NoUserError extends TRPCError {
constructor() {
super({ code: "BAD_REQUEST", message: "no_user" });
}
}
class NotAuthorizedError extends TRPCError {
constructor() {
super({ code: "FORBIDDEN", message: "not_authorized" });
}
}
class InvalidMetadataError extends TRPCError {
constructor() {
super({ code: "BAD_REQUEST", message: "invalid_organization_metadata" });
}
}
class NoOrganizationError extends TRPCError {
constructor() {
super({ code: "BAD_REQUEST", message: "no_organization_found" });
}
}
class NoOrganizationSlugError extends TRPCError {
constructor() {
super({ code: "BAD_REQUEST", message: "no_organization_slug" });
}
}
export default createTeamsHandler;
async function moveTeam({
teamId,
newSlug,
org,
ctx,
}: {
teamId: number;
newSlug?: string | null;
org: {
id: number;
slug: string | null;
ownerId: number;
metadata: Prisma.JsonValue;
};
ctx: CreateTeamsOptions["ctx"];
}) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
slug: true,
metadata: true,
members: {
select: {
role: true,
userId: true,
user: {
select: {
email: true,
},
},
},
},
},
});
if (!team) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Team with id: ${teamId} not found`,
});
}
log.debug("Moving team", safeStringify({ teamId, newSlug, org, oldSlug: team.slug }));
newSlug = newSlug ?? team.slug;
const orgMetadata = teamMetadataSchema.parse(org.metadata);
await prisma.team.update({
where: {
id: teamId,
},
data: {
slug: newSlug,
parentId: org.id,
},
});
// Owner is already a member of the team. Inviting an existing member can throw error
const invitableMembers = team.members.filter(isMembershipNotWithOwner).map((membership) => ({
email: membership.user.email,
role: membership.role,
}));
if (invitableMembers.length) {
// Invite team members to the new org. They are already members of the team.
await inviteMemberHandler({
ctx,
input: {
teamId: org.id,
language: "en",
usernameOrEmail: invitableMembers,
},
});
}
await addTeamRedirect({
oldTeamSlug: team.slug,
teamSlug: newSlug,
orgSlug: org.slug || (orgMetadata?.requestedSlug ?? null),
});
function isMembershipNotWithOwner(membership: { userId: number }) {
// Org owner is already a member of the team
return membership.userId !== org.ownerId;
}
// Cancel existing stripe subscriptions once the team is migrated
const subscriptionId = getSubscriptionId(team.metadata);
if (subscriptionId) {
await tryToCancelSubscription(subscriptionId);
}
}
async function tryToCancelSubscription(subscriptionId: string) {
try {
log.debug("Canceling stripe subscription", safeStringify({ subscriptionId }));
return await stripe.subscriptions.cancel(subscriptionId);
} catch (error) {
log.error("Error while cancelling stripe subscription", error);
}
}
function getSubscriptionId(metadata: Prisma.JsonValue) {
const parsedMetadata = teamMetadataSchema.safeParse(metadata);
if (parsedMetadata.success) {
const subscriptionId = parsedMetadata.data?.subscriptionId;
if (!subscriptionId) {
log.warn("No subscriptionId found in team metadata", safeStringify({ metadata, parsedMetadata }));
}
return subscriptionId;
} else {
log.warn(`There has been an error`, parsedMetadata.error);
}
}
async function addTeamRedirect({
oldTeamSlug,
teamSlug,
orgSlug,
}: {
oldTeamSlug: string | null;
teamSlug: string | null;
orgSlug: string | null;
}) {
logger.info(`Adding redirect for team: ${oldTeamSlug} -> ${teamSlug}`);
if (!oldTeamSlug) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No oldSlug for team. Not adding the redirect",
});
}
if (!teamSlug) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No slug for team. Not adding the redirect",
});
}
if (!orgSlug) {
logger.warn(`No slug for org. Not adding the redirect`);
return;
}
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
await prisma.tempOrgRedirect.upsert({
where: {
from_type_fromOrgId: {
type: RedirectType.Team,
from: oldTeamSlug,
fromOrgId: 0,
},
},
create: {
type: RedirectType.Team,
from: oldTeamSlug,
fromOrgId: 0,
toUrl: `${orgUrlPrefix}/${teamSlug}`,
},
update: {
toUrl: `${orgUrlPrefix}/${teamSlug}`,
},
});
}