674 lines
17 KiB
TypeScript
674 lines
17 KiB
TypeScript
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
|
import { HttpError } from "@calcom/lib/http-error";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
import type { Team, User } from "@calcom/prisma/client";
|
|
import { Prisma } from "@calcom/prisma/client";
|
|
import type { MembershipRole } from "@calcom/prisma/enums";
|
|
import { RedirectType } from "@calcom/prisma/enums";
|
|
import { teamMetadataSchema, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["orgMigration"] });
|
|
|
|
type UserMetadata = {
|
|
migratedToOrgFrom?: {
|
|
username: string;
|
|
reverted: boolean;
|
|
revertTime: string;
|
|
lastMigrationTime: string;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Make sure that the migration is idempotent
|
|
*/
|
|
export async function moveUserToOrg({
|
|
user: { id: userId, userName },
|
|
targetOrg: {
|
|
id: targetOrgId,
|
|
username: targetOrgUsername,
|
|
membership: { role: targetOrgRole, accepted: targetOrgMembershipAccepted = true },
|
|
},
|
|
shouldMoveTeams,
|
|
}: {
|
|
user: { id?: number; userName?: string };
|
|
targetOrg: {
|
|
id: number;
|
|
username?: string;
|
|
membership: { role: MembershipRole; accepted?: boolean };
|
|
};
|
|
shouldMoveTeams: boolean;
|
|
}) {
|
|
assertUserIdOrUserName(userId, userName);
|
|
const team = await getTeamOrThrowError(targetOrgId);
|
|
|
|
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
|
|
|
if (!team.isOrganization) {
|
|
throw new Error(`Team with ID:${targetOrgId} is not an Org`);
|
|
}
|
|
|
|
const targetOrganization = {
|
|
...team,
|
|
metadata: teamMetadata,
|
|
};
|
|
const userToMoveToOrg = await getUniqueUserThatDoesntBelongToOrg(userName, userId, targetOrgId);
|
|
assertUserPartOfOtherOrg(userToMoveToOrg, userName, userId, targetOrgId);
|
|
|
|
if (!targetOrgUsername) {
|
|
targetOrgUsername = getOrgUsernameFromEmail(
|
|
userToMoveToOrg.email,
|
|
targetOrganization.organizationSettings?.orgAutoAcceptEmail || ""
|
|
);
|
|
}
|
|
|
|
const userWithSameUsernameInOrg = await prisma.user.findUnique({
|
|
where: {
|
|
username_organizationId: {
|
|
username: targetOrgUsername,
|
|
organizationId: targetOrgId,
|
|
},
|
|
},
|
|
});
|
|
|
|
log.debug({
|
|
userWithSameUsernameInOrg,
|
|
targetOrgUsername,
|
|
targetOrgId,
|
|
userId,
|
|
});
|
|
|
|
if (userWithSameUsernameInOrg && userWithSameUsernameInOrg.id !== userId) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `Username ${targetOrgUsername} already exists for orgId: ${targetOrgId} for some other user`,
|
|
});
|
|
}
|
|
|
|
assertUserPartOfOrgAndRemigrationAllowed(userToMoveToOrg, targetOrgId, targetOrgUsername, userId);
|
|
|
|
const orgMetadata = teamMetadata;
|
|
|
|
const userToMoveToOrgMetadata = (userToMoveToOrg.metadata || {}) as UserMetadata;
|
|
|
|
const nonOrgUserName =
|
|
(userToMoveToOrgMetadata.migratedToOrgFrom?.username as string) || userToMoveToOrg.username;
|
|
if (!nonOrgUserName) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `User with id: ${userId} doesn't have a non-org username`,
|
|
});
|
|
}
|
|
|
|
await dbMoveUserToOrg({ userToMoveToOrg, targetOrgId, targetOrgUsername, nonOrgUserName });
|
|
|
|
let teamsToBeMovedToOrg;
|
|
if (shouldMoveTeams) {
|
|
teamsToBeMovedToOrg = await moveTeamsWithoutMembersToOrg({ targetOrgId, userToMoveToOrg });
|
|
}
|
|
|
|
await updateMembership({ targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted });
|
|
|
|
await addRedirect({
|
|
nonOrgUserName,
|
|
teamsToBeMovedToOrg: teamsToBeMovedToOrg || [],
|
|
organization: targetOrganization,
|
|
targetOrgUsername,
|
|
});
|
|
|
|
await setOrgSlugIfNotSet(targetOrganization, orgMetadata, targetOrgId);
|
|
|
|
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
|
|
}
|
|
|
|
/**
|
|
* Make sure that the migration is idempotent
|
|
*/
|
|
export async function removeUserFromOrg({ targetOrgId, userId }: { targetOrgId: number; userId: number }) {
|
|
const userToRemoveFromOrg = await prisma.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
});
|
|
|
|
if (!userToRemoveFromOrg) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `User with id: ${userId} not found`,
|
|
});
|
|
}
|
|
|
|
if (userToRemoveFromOrg.organizationId !== targetOrgId) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `User with id: ${userId} is not part of orgId: ${targetOrgId}`,
|
|
});
|
|
}
|
|
|
|
const userToRemoveFromOrgMetadata = (userToRemoveFromOrg.metadata || {}) as {
|
|
migratedToOrgFrom?: {
|
|
username: string;
|
|
reverted: boolean;
|
|
revertTime: string;
|
|
lastMigrationTime: string;
|
|
};
|
|
};
|
|
|
|
if (!userToRemoveFromOrgMetadata.migratedToOrgFrom) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `User with id: ${userId} wasn't migrated. So, there is nothing to revert`,
|
|
});
|
|
}
|
|
|
|
const nonOrgUserName = userToRemoveFromOrgMetadata.migratedToOrgFrom.username as string;
|
|
if (!nonOrgUserName) {
|
|
throw new HttpError({
|
|
statusCode: 500,
|
|
message: `User with id: ${userId} doesn't have a non-org username`,
|
|
});
|
|
}
|
|
|
|
const teamsToBeRemovedFromOrg = await removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg });
|
|
await dbRemoveUserFromOrg({ userToRemoveFromOrg, nonOrgUserName });
|
|
|
|
await removeUserAlongWithItsTeamsRedirects({ nonOrgUserName, teamsToBeRemovedFromOrg });
|
|
await removeMembership({ targetOrgId, userToRemoveFromOrg });
|
|
|
|
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
|
|
}
|
|
|
|
async function getUniqueUserThatDoesntBelongToOrg(
|
|
userName: string | undefined,
|
|
userId: number | undefined,
|
|
excludeOrgId: number
|
|
) {
|
|
log.debug("getUniqueUserThatDoesntBelongToOrg", { userName, userId, excludeOrgId });
|
|
if (userName) {
|
|
const matchingUsers = await prisma.user.findMany({
|
|
where: {
|
|
username: userName,
|
|
},
|
|
});
|
|
const foundUsers = matchingUsers.filter(
|
|
(user) => user.organizationId === excludeOrgId || user.organizationId === null
|
|
);
|
|
if (foundUsers.length > 1) {
|
|
throw new Error(`More than one user found with username: ${userName}`);
|
|
}
|
|
return foundUsers[0];
|
|
} else {
|
|
return await prisma.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async function setOrgSlugIfNotSet(
|
|
targetOrganization: {
|
|
slug: string | null;
|
|
},
|
|
orgMetadata: {
|
|
requestedSlug?: string | null | undefined;
|
|
} | null,
|
|
targetOrgId: number
|
|
) {
|
|
if (targetOrganization.slug) {
|
|
return;
|
|
}
|
|
|
|
if (!orgMetadata?.requestedSlug) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `Org with id: ${targetOrgId} doesn't have a slug. Tried using requestedSlug but that's also not present. So, all migration done but failed to set the Organization slug. Please set it manually`,
|
|
});
|
|
}
|
|
await setOrgSlug({
|
|
targetOrgId,
|
|
targetSlug: orgMetadata.requestedSlug,
|
|
});
|
|
}
|
|
|
|
function assertUserPartOfOrgAndRemigrationAllowed(
|
|
userToMoveToOrg: {
|
|
organizationId: number | null;
|
|
},
|
|
targetOrgId: number,
|
|
targetOrgUsername: string,
|
|
userId: number | undefined
|
|
) {
|
|
if (userToMoveToOrg.organizationId) {
|
|
if (userToMoveToOrg.organizationId !== targetOrgId) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `User ${targetOrgUsername} already exists for different Org with orgId: ${targetOrgId}`,
|
|
});
|
|
} else {
|
|
log.debug(`Redoing migration for userId: ${userId} to orgId:${targetOrgId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getTeamOrThrowError(targetOrgId: number) {
|
|
const team = await prisma.team.findUnique({
|
|
where: {
|
|
id: targetOrgId,
|
|
},
|
|
include: {
|
|
organizationSettings: true,
|
|
},
|
|
});
|
|
|
|
if (!team) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: `Org with id: ${targetOrgId} not found`,
|
|
});
|
|
}
|
|
return team;
|
|
}
|
|
|
|
function assertUserPartOfOtherOrg(
|
|
userToMoveToOrg: {
|
|
organizationId: number | null;
|
|
} | null,
|
|
userName: string | undefined,
|
|
userId: number | undefined,
|
|
targetOrgId: number
|
|
): asserts userToMoveToOrg {
|
|
if (!userToMoveToOrg) {
|
|
throw new HttpError({
|
|
message: `User ${userName ? userName : `ID:${userId}`} is part of an org already`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (userToMoveToOrg.organizationId && userToMoveToOrg.organizationId !== targetOrgId) {
|
|
throw new HttpError({
|
|
message: `User is already a part of different organization ID: ${userToMoveToOrg.organizationId}`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
}
|
|
|
|
function assertUserIdOrUserName(userId: number | undefined, userName: string | undefined) {
|
|
if (!userId && !userName) {
|
|
throw new HttpError({ statusCode: 400, message: "userId or userName is required" });
|
|
}
|
|
if (userId && userName) {
|
|
throw new HttpError({ statusCode: 400, message: "Provide either userId or userName" });
|
|
}
|
|
}
|
|
|
|
async function addRedirect({
|
|
nonOrgUserName,
|
|
organization,
|
|
targetOrgUsername,
|
|
teamsToBeMovedToOrg,
|
|
}: {
|
|
nonOrgUserName: string | null;
|
|
organization: Team;
|
|
targetOrgUsername: string;
|
|
teamsToBeMovedToOrg: { slug: string | null }[];
|
|
}) {
|
|
if (!nonOrgUserName) {
|
|
return;
|
|
}
|
|
const orgSlug = organization.slug || (organization.metadata as { requestedSlug?: string })?.requestedSlug;
|
|
if (!orgSlug) {
|
|
log.debug("No slug for org. Not adding the redirect", safeStringify({ organization, nonOrgUserName }));
|
|
return;
|
|
}
|
|
// If the user had a username earlier, we need to redirect it to the new org username
|
|
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
|
|
log.debug({
|
|
orgUrlPrefix,
|
|
nonOrgUserName,
|
|
targetOrgUsername,
|
|
});
|
|
|
|
await prisma.tempOrgRedirect.upsert({
|
|
where: {
|
|
from_type_fromOrgId: {
|
|
type: RedirectType.User,
|
|
from: nonOrgUserName,
|
|
fromOrgId: 0,
|
|
},
|
|
},
|
|
create: {
|
|
type: RedirectType.User,
|
|
from: nonOrgUserName,
|
|
fromOrgId: 0,
|
|
toUrl: `${orgUrlPrefix}/${targetOrgUsername}`,
|
|
},
|
|
update: {
|
|
toUrl: `${orgUrlPrefix}/${targetOrgUsername}`,
|
|
},
|
|
});
|
|
|
|
for (const [, team] of Object.entries(teamsToBeMovedToOrg)) {
|
|
if (!team.slug) {
|
|
log.debug("No slug for team. Not adding the redirect", safeStringify({ team }));
|
|
continue;
|
|
}
|
|
await prisma.tempOrgRedirect.upsert({
|
|
where: {
|
|
from_type_fromOrgId: {
|
|
type: RedirectType.Team,
|
|
from: team.slug,
|
|
fromOrgId: 0,
|
|
},
|
|
},
|
|
create: {
|
|
type: RedirectType.Team,
|
|
from: team.slug,
|
|
fromOrgId: 0,
|
|
toUrl: `${orgUrlPrefix}/team/${team.slug}`,
|
|
},
|
|
update: {
|
|
toUrl: `${orgUrlPrefix}/team/${team.slug}`,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async function updateMembership({
|
|
targetOrgId,
|
|
userToMoveToOrg,
|
|
targetOrgRole,
|
|
targetOrgMembershipAccepted,
|
|
}: {
|
|
targetOrgId: number;
|
|
userToMoveToOrg: User;
|
|
targetOrgRole: MembershipRole;
|
|
targetOrgMembershipAccepted: boolean;
|
|
}) {
|
|
log.debug("updateMembership", { targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted });
|
|
await prisma.membership.upsert({
|
|
where: {
|
|
userId_teamId: {
|
|
teamId: targetOrgId,
|
|
userId: userToMoveToOrg.id,
|
|
},
|
|
},
|
|
create: {
|
|
teamId: targetOrgId,
|
|
userId: userToMoveToOrg.id,
|
|
role: targetOrgRole,
|
|
accepted: targetOrgMembershipAccepted,
|
|
},
|
|
update: {
|
|
role: targetOrgRole,
|
|
accepted: targetOrgMembershipAccepted,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function dbMoveUserToOrg({
|
|
userToMoveToOrg,
|
|
targetOrgId,
|
|
targetOrgUsername,
|
|
nonOrgUserName,
|
|
}: {
|
|
userToMoveToOrg: User;
|
|
targetOrgId: number;
|
|
targetOrgUsername: string;
|
|
nonOrgUserName: string | null;
|
|
}) {
|
|
await prisma.user.update({
|
|
where: {
|
|
id: userToMoveToOrg.id,
|
|
},
|
|
data: {
|
|
organizationId: targetOrgId,
|
|
username: targetOrgUsername,
|
|
metadata: {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-expect-error
|
|
...(userToMoveToOrg.metadata || {}),
|
|
migratedToOrgFrom: {
|
|
username: nonOrgUserName,
|
|
lastMigrationTime: new Date().toISOString(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await prisma.profile.upsert({
|
|
create: {
|
|
uid: ProfileRepository.generateProfileUid(),
|
|
userId: userToMoveToOrg.id,
|
|
organizationId: targetOrgId,
|
|
username: targetOrgUsername,
|
|
movedFromUser: {
|
|
connect: {
|
|
id: userToMoveToOrg.id,
|
|
},
|
|
},
|
|
},
|
|
update: {
|
|
organizationId: targetOrgId,
|
|
username: targetOrgUsername,
|
|
movedFromUser: {
|
|
connect: {
|
|
id: userToMoveToOrg.id,
|
|
},
|
|
},
|
|
},
|
|
where: {
|
|
userId_organizationId: {
|
|
userId: userToMoveToOrg.id,
|
|
organizationId: targetOrgId,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function moveTeamsWithoutMembersToOrg({
|
|
targetOrgId,
|
|
userToMoveToOrg,
|
|
}: {
|
|
targetOrgId: number;
|
|
userToMoveToOrg: User;
|
|
}) {
|
|
const memberships = await prisma.membership.findMany({
|
|
where: {
|
|
userId: userToMoveToOrg.id,
|
|
},
|
|
});
|
|
|
|
const membershipTeamIds = memberships.map((m) => m.teamId);
|
|
const teams = await prisma.team.findMany({
|
|
where: {
|
|
id: {
|
|
in: membershipTeamIds,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
metadata: true,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
|
|
const teamsToBeMovedToOrg = teams
|
|
.map((team) => {
|
|
return {
|
|
...team,
|
|
metadata: teamMetadataSchema.parse(team.metadata),
|
|
};
|
|
})
|
|
// Remove Orgs from the list
|
|
.filter((team) => !team.isOrganization);
|
|
|
|
const teamIdsToBeMovedToOrg = teamsToBeMovedToOrg.map((t) => t.id);
|
|
|
|
if (memberships.length) {
|
|
// Add the user's teams to the org
|
|
await prisma.team.updateMany({
|
|
where: {
|
|
id: {
|
|
in: teamIdsToBeMovedToOrg,
|
|
},
|
|
},
|
|
data: {
|
|
parentId: targetOrgId,
|
|
},
|
|
});
|
|
}
|
|
return teamsToBeMovedToOrg;
|
|
}
|
|
|
|
/**
|
|
* Make sure you pass it an organization ID only and not a team ID.
|
|
*/
|
|
async function setOrgSlug({ targetOrgId, targetSlug }: { targetOrgId: number; targetSlug: string }) {
|
|
await prisma.team.update({
|
|
where: {
|
|
id: targetOrgId,
|
|
},
|
|
data: {
|
|
slug: targetSlug,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function removeUserAlongWithItsTeamsRedirects({
|
|
nonOrgUserName,
|
|
teamsToBeRemovedFromOrg,
|
|
}: {
|
|
nonOrgUserName: string | null;
|
|
teamsToBeRemovedFromOrg: { slug: string | null }[];
|
|
}) {
|
|
if (!nonOrgUserName) {
|
|
return;
|
|
}
|
|
|
|
await prisma.tempOrgRedirect.deleteMany({
|
|
// This where clause is unique, so we will get only one result but using deleteMany because it doesn't throw an error if there are no rows to delete
|
|
where: {
|
|
type: RedirectType.User,
|
|
from: nonOrgUserName,
|
|
fromOrgId: 0,
|
|
},
|
|
});
|
|
|
|
for (const [, team] of Object.entries(teamsToBeRemovedFromOrg)) {
|
|
if (!team.slug) {
|
|
log.debug("No slug for team. Not removing the redirect", safeStringify({ team }));
|
|
continue;
|
|
}
|
|
await prisma.tempOrgRedirect.deleteMany({
|
|
where: {
|
|
type: RedirectType.Team,
|
|
from: team.slug,
|
|
fromOrgId: 0,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async function removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg }: { userToRemoveFromOrg: User }) {
|
|
const memberships = await prisma.membership.findMany({
|
|
where: {
|
|
userId: userToRemoveFromOrg.id,
|
|
},
|
|
});
|
|
|
|
const membershipTeamIds = memberships.map((m) => m.teamId);
|
|
const teams = await prisma.team.findMany({
|
|
where: {
|
|
id: {
|
|
in: membershipTeamIds,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
metadata: true,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
|
|
const teamsToBeRemovedFromOrg = teams
|
|
.map((team) => {
|
|
return {
|
|
...team,
|
|
metadata: teamMetadataSchema.parse(team.metadata),
|
|
};
|
|
})
|
|
// Remove Orgs from the list
|
|
.filter((team) => !team.isOrganization);
|
|
|
|
const teamIdsToBeRemovedFromOrg = teamsToBeRemovedFromOrg.map((t) => t.id);
|
|
|
|
if (memberships.length) {
|
|
// Remove the user's teams from the org
|
|
await prisma.team.updateMany({
|
|
where: {
|
|
id: {
|
|
in: teamIdsToBeRemovedFromOrg,
|
|
},
|
|
},
|
|
data: {
|
|
parentId: null,
|
|
},
|
|
});
|
|
}
|
|
return teamsToBeRemovedFromOrg;
|
|
}
|
|
|
|
async function dbRemoveUserFromOrg({
|
|
userToRemoveFromOrg,
|
|
nonOrgUserName,
|
|
}: {
|
|
userToRemoveFromOrg: User;
|
|
nonOrgUserName: string;
|
|
}) {
|
|
await prisma.user.update({
|
|
where: {
|
|
id: userToRemoveFromOrg.id,
|
|
},
|
|
data: {
|
|
organizationId: null,
|
|
username: nonOrgUserName,
|
|
metadata: {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-expect-error
|
|
...(userToRemoveFromOrg.metadata || {}),
|
|
migratedToOrgFrom: {
|
|
username: null,
|
|
reverted: true,
|
|
revertTime: new Date().toISOString(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await ProfileRepository.deleteMany({
|
|
userIds: [userToRemoveFromOrg.id],
|
|
organizationId: userToRemoveFromOrg.organizationId!,
|
|
});
|
|
}
|
|
|
|
async function removeMembership({
|
|
targetOrgId,
|
|
userToRemoveFromOrg,
|
|
}: {
|
|
targetOrgId: number;
|
|
userToRemoveFromOrg: User;
|
|
}) {
|
|
await prisma.membership.deleteMany({
|
|
where: {
|
|
teamId: targetOrgId,
|
|
userId: userToRemoveFromOrg.id,
|
|
},
|
|
});
|
|
}
|