feat: add Move Team to Organization admin migration page (#25067)

* feat: restore moveTeamToOrg admin endpoint for organization migration

- Add moveTeamToOrg and removeTeamFromOrg functions to orgMigration.ts
- Restore API endpoint at /api/orgMigration/moveTeamToOrg
- Restore admin UI page at /settings/admin/orgMigrations/moveTeamToOrg
- Add helper functions for team redirect management
- Support moving team members along with the team

This endpoint allows admins to migrate teams to organizations after org creation,
which is needed as a temporary solution until proper org admin permissions are implemented.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: move moveTeamToOrg to lib/orgMigration and fix redirect URL

- Move moveTeamToOrg and removeTeamFromOrg functions from playwright/lib to lib/orgMigration.ts
- Update API endpoint to import from lib/orgMigration instead of playwright/lib
- Fix redirect URL format: use / instead of /team/
- Fix import path: use ../playwright/lib/orgMigration instead of ./playwright/lib/orgMigration
- Rename unused _dbRemoveTeamFromOrg in playwright file to satisfy linter
- Remove duplicate functions from playwright/lib/orgMigration.ts

This fixes the Vercel deployment failure caused by importing from test-only directories
in production API routes, and corrects the redirect URL format to match the original implementation.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: reuse existing createTeamsHandler for moveTeamToOrg endpoint

- Remove custom orgMigration.ts implementation
- Update API endpoint to call existing createTeamsHandler with org owner impersonation
- Remove moveMembers option from UI (always moves members by design)
- Fix Vercel deployment by removing playwright import from production code
- Use OrganizationRepository.adminFindById to fetch org owner

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: migrate moveTeamToOrg admin page and API to App Router

- Move admin page from pages/settings/admin/orgMigrations to app/(use-page-wrapper)/settings/(admin-layout)/admin/orgMigrations
- Convert API route from pages/api/orgMigration/moveTeamToOrg.ts to app/api/orgMigration/moveTeamToOrg/route.ts
- Create client view component in modules/settings/admin/org-migrations/
- Remove old pages directory files and getServerSideProps

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: correct import paths for App Router compatibility

- Fix @calcom/lib/server to @calcom/lib/server/i18n for getTranslation
- Fix @calcom/ui barrel import to specific component paths

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: use TFunction type for getFormSchema parameter

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: use buildLegacyRequest for App Router session compatibility

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Remove unused fn

* cleanup

* cleanup

* fix: ui

* fix: handle slug conflict error when moving team to organization

- Intercept Prisma P2002 unique constraint error when moving a team
- Convert to user-friendly CONFLICT error with clear message
- Add test case for slug conflict scenario

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: use isPending instead of isLoading for tRPC mutation

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fixes

* fix: remove PII (emails) from admin log statement

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: use instanceof pattern for Prisma error detection

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fixes

* fix: use i18n key for slug conflict error message instead of hardcoded English string

Co-Authored-By: bot_apk <apk@cognition.ai>

* fix: narrow P2002 catch scope to only prisma.team.update call

Separates the try-catch for prisma.team.update (slug conflict) from
creditService.moveCreditsFromTeamToOrg to avoid misattributing credit
service P2002 errors as slug conflicts.

Co-Authored-By: bot_apk <apk@cognition.ai>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
This commit is contained in:
Hariom Balhara
2026-03-19 09:55:02 +05:30
committed by GitHub
co-authored by bot_apk Devin AI
parent 4b247640a8
commit dc43eba23b
10 changed files with 450 additions and 130 deletions
@@ -0,0 +1,25 @@
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { _generateMetadata, getTranslate } from "app/_utils";
import MoveTeamToOrgView from "~/settings/admin/org-migrations/move-team-to-org-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("organization_migration_move_team"),
(t) => t("organization_migration_move_team_description"),
undefined,
undefined,
"/settings/admin/migrations/move-team-to-org"
);
const Page = async (): Promise<React.ReactNode> => {
const t = await getTranslate();
return (
<SettingsHeader
title={t("organization_migration_move_team")}
description={t("organization_migration_move_team_description")}>
<MoveTeamToOrgView />
</SettingsHeader>
);
};
export default Page;
@@ -80,12 +80,12 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
},
...(HAS_USER_OPT_IN_FEATURES
? [
{
name: "features",
href: "/settings/my-account/features",
trackingMetadata: { section: "my_account", page: "features" },
},
]
{
name: "features",
href: "/settings/my-account/features",
trackingMetadata: { section: "my_account", page: "features" },
},
]
: []),
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
@@ -181,13 +181,13 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
},
...(orgBranding
? [
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
trackingMetadata: { section: "organization", page: "members" },
},
]
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
trackingMetadata: { section: "organization", page: "members" },
},
]
: []),
{
name: "privacy_and_security",
@@ -212,12 +212,12 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
},
...(HAS_ORG_OPT_IN_FEATURES
? [
{
name: "features",
href: "/settings/organizations/features",
trackingMetadata: { section: "organization", page: "features" },
},
]
{
name: "features",
href: "/settings/organizations/features",
trackingMetadata: { section: "organization", page: "features" },
},
]
: []),
],
},
@@ -274,6 +274,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/admin/organizations",
trackingMetadata: { section: "admin", page: "organizations" },
},
{
name: "migrations",
href: "/settings/admin/migrations/move-team-to-org",
trackingMetadata: { section: "admin", page: "migrations" },
},
{
name: "lockedSMS",
href: "/settings/admin/lockedSMS",
@@ -610,9 +615,8 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
setTeamMenuState(newTeamMenuState);
}
}}
aria-label={`${team.name} ${
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
}`}>
aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
}`}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<ChevronDownIcon className="h-4 w-4" />
@@ -620,7 +624,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
<ChevronRightIcon className="h-4 w-4" />
)}
</div>
{}
{ }
{!team.parentId && (
<Avatar
size="xs"
@@ -662,55 +666,55 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
<VerticalTabItem
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "appearance", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{HAS_TEAM_OPT_IN_FEATURES && (
<VerticalTabItem
name={t("features")}
href={`/settings/teams/${team.id}/features`}
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
trackingMetadata={{ section: "team", page: "appearance", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
)}
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
{HAS_TEAM_OPT_IN_FEATURES && (
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
name={t("features")}
href={`/settings/teams/${team.id}/features`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "billing", teamId: team.id }}
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
) : null}
<VerticalTabItem
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "settings", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
)}
)}
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "billing", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
) : null}
<VerticalTabItem
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "settings", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
)}
</CollapsibleContent>
</Collapsible>
);
@@ -810,7 +814,7 @@ const SettingsSidebarContainer = ({
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
{}
{ }
{!tab.icon && tab?.avatar && (
<Avatar
size="xs"
@@ -953,11 +957,10 @@ const SettingsSidebarContainer = ({
setOtherTeamMenuState(newOtherTeamMenuState);
}
}}
aria-label={`${otherTeam.name} ${
otherTeamMenuState[index].teamMenuOpen
? t("collapse_menu")
: t("expand_menu")
}`}>
aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen
? t("collapse_menu")
: t("expand_menu")
}`}>
<div className="me-3">
{otherTeamMenuState[index].teamMenuOpen ? (
<ChevronDownIcon className="h-4 w-4" />
@@ -965,7 +968,7 @@ const SettingsSidebarContainer = ({
<ChevronRightIcon className="h-4 w-4" />
)}
</div>
{}
{ }
{!otherTeam.parentId && (
<Avatar
size="xs"
@@ -0,0 +1,119 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { zodResolver } from "@hookform/resolvers/zod";
import type { TFunction } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
export const getFormSchema = (t: TFunction) => {
return z.object({
teamId: z.number(),
targetOrgId: z.number(),
teamSlugInOrganization: z.string().min(1),
});
};
export default function MoveTeamToOrgView() {
const { t } = useLocale();
const formSchema = getFormSchema(t);
const formMethods = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const moveTeamMutation = trpc.viewer.admin.moveTeamToOrg.useMutation({
onSuccess: (data) => {
showToast(t(data.message), "success", 10000);
},
onError: (error) => {
showToast(t(error.message), "error", 10000);
},
});
const { register } = formMethods;
return (
<div className="space-y-3">
<Form
className="space-y-3"
noValidate={true}
form={formMethods}
handleSubmit={async (values) => {
const parsedValues = formSchema.parse(values);
moveTeamMutation.mutate(parsedValues);
}}>
<div className="space-y-3">
<TextField
{...register("teamId", { valueAsNumber: true })}
type="number"
label={t("team_id")}
required
placeholder={t("move_team_to_org_team_id_placeholder")}
/>
<TextField
{...register("teamSlugInOrganization")}
label={t("move_team_to_org_new_slug")}
required
placeholder={t("move_team_to_org_new_slug_placeholder")}
/>
<TextField
{...register("targetOrgId", { valueAsNumber: true })}
type="number"
label={t("move_team_to_org_target_org_id")}
required
placeholder={t("move_team_to_org_target_org_id_placeholder")}
/>
<div className="mt-2 text-gray-600 text-sm">
{t("organization_migration_move_team_footnote")}
</div>
</div>
<Button type="submit" loading={moveTeamMutation.isPending}>
{t("organization_migration_move_team")}
</Button>
</Form>
{moveTeamMutation.isSuccess && moveTeamMutation.data && (
<Alert
className="mt-6"
severity="info"
CustomIcon="check"
title={t("move_team_to_org_migration_successful")}
message={
<div className="space-y-1">
<p>
<span className="font-medium">{t("team_id")}:</span> {moveTeamMutation.data.teamId}
</p>
{moveTeamMutation.data.oldTeamSlug && (
<p>
<span className="font-medium">{t("move_team_to_org_old_slug")}:</span>{" "}
{moveTeamMutation.data.oldTeamSlug}
</p>
)}
{moveTeamMutation.data.newTeamSlug && (
<p>
<span className="font-medium">{t("move_team_to_org_new_slug")}:</span>{" "}
{moveTeamMutation.data.newTeamSlug}
</p>
)}
<p>
<span className="font-medium">{t("organization_id")}:</span>{" "}
{moveTeamMutation.data.organizationId}
</p>
{moveTeamMutation.data.organizationSlug && (
<p>
<span className="font-medium">{t("move_team_to_org_organization_slug")}:</span>{" "}
{moveTeamMutation.data.organizationSlug}
</p>
)}
</div>
}
/>
)}
</div>
);
}
+4 -52
View File
@@ -7,8 +7,8 @@ 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 { RedirectType } from "@calcom/prisma/enums";
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"] });
@@ -26,7 +26,7 @@ type UserMetadata = {
* Make sure that the migration is idempotent
*/
export async function moveUserToOrg({
user: { id: userId, userName: userName },
user: { id: userId, userName },
targetOrg: {
id: targetOrgId,
username: targetOrgUsername,
@@ -429,7 +429,7 @@ async function dbMoveUserToOrg({
username: targetOrgUsername,
metadata: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error
...(userToMoveToOrg.metadata || {}),
migratedToOrgFrom: {
username: nonOrgUserName,
@@ -574,54 +574,6 @@ async function removeUserAlongWithItsTeamsRedirects({
}
}
async function dbRemoveTeamFromOrg({ teamId }: { teamId: number }) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
if (!team) {
throw new HttpError({
statusCode: 400,
message: `Team with id: ${teamId} not found`,
});
}
const teamMetadata = teamMetadataStrictSchema.parse(team?.metadata);
try {
return await prisma.team.update({
where: {
id: teamId,
},
data: {
parentId: null,
slug: teamMetadata?.migratedToOrgFrom?.teamSlug || team.slug,
metadata: {
...teamMetadata,
migratedToOrgFrom: {
reverted: true,
lastRevertTime: new Date().toISOString(),
},
},
},
include: {
members: true,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new HttpError({
message: `Looks like the team's name is already taken by some other team outside the org or an org itself. Please change this team's name or the other team/org's name. If you rename the team that you are trying to remove from the org, you will have to manually remove the redirect from the database for that team as the slug would have changed.`,
statusCode: 400,
});
}
}
throw e;
}
}
async function removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg }: { userToRemoveFromOrg: User }) {
const memberships = await prisma.membership.findMany({
where: {
@@ -688,7 +640,7 @@ async function dbRemoveUserFromOrg({
username: nonOrgUserName,
metadata: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error
...(userToRemoveFromOrg.metadata || {}),
migratedToOrgFrom: {
username: null,
+25 -6
View File
@@ -1656,12 +1656,12 @@
"event_cancelled_trigger": "when event is canceled",
"after_hosts_cal_video_no_show": "After hosts didn't join cal video",
"after_guests_cal_video_no_show": "After guests didn't join cal video",
"wrong_routing": "Wrong routing",
"wrong_routing_description": "Review and manage reports of incorrectly routed bookings",
"view_form_submission": "View form submission",
"form_submission": "Form submission",
"wrong_assignment_report": "Wrong assignment report",
"wrong_assignment_reports": "Wrong Assignment Reports",
"wrong_routing": "Wrong routing",
"wrong_routing_description": "Review and manage reports of incorrectly routed bookings",
"view_form_submission": "View form submission",
"form_submission": "Form submission",
"wrong_assignment_report": "Wrong assignment report",
"wrong_assignment_reports": "Wrong Assignment Reports",
"wrong_assignment_reports_description": "Review and manage reports of incorrectly routed bookings",
"no_pending_reports": "No pending reports",
"no_pending_reports_description": "There are no wrong assignment reports waiting for review",
@@ -2916,6 +2916,7 @@
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
"organizations": "Organizations",
"migrations": "Migrations",
"upload_cal_video_logo": "Upload Cal Video logo",
"update_cal_video_logo": "Update Cal Video logo",
"upload_banner": "Upload banner",
@@ -4683,6 +4684,24 @@
"last_6_months": "Last 6 months",
"last_12_months": "Last 12 months",
"calendar_events_disabled_video_limitation": "When calendar events are disabled, meeting links cannot be generated for apps whose link generation is tied to calendar event creation, for example, Google Meet and Microsoft Teams. Other video apps like Cal Video will continue to work.",
"organization_migration_move_team": "Move team to organization",
"organization_migration_move_team_description": "Move an existing regular team to an organization with a new slug",
"organization_migration_move_team_footnote": "Note: Team members will automatically be invited to the organization when the team is moved. Also, the old team booking links will redirect to the new organization booking link.",
"cannot_move_subteam_already_in_org": "Cannot move a sub-team that is already part of an organization",
"team_moved_to_org_success": "Team successfully moved to organization",
"organization_owner_not_found": "Organization owner not found",
"team_not_found": "Team not found",
"organization_not_found": "Organization not found",
"move_team_to_org_team_id_placeholder": "Enter team ID to move to organization",
"move_team_to_org_new_slug": "New Slug",
"move_team_to_org_new_slug_placeholder": "Team slug in the organization",
"move_team_to_org_target_org_id": "Target Organization ID",
"move_team_to_org_target_org_id_placeholder": "Enter target organization ID",
"move_team_to_org_migration_successful": "Migration Successful",
"move_team_to_org_old_slug": "Old Slug",
"move_team_to_org_organization_slug": "Organization Slug",
"cannot_move_organization_to_organization": "Cannot move an organization to an organization",
"slug_already_in_use_in_org": "Slug is already in use within this organization",
"upgrade_dialog_title_short": "Upgrade",
"upgrade_dialog_title": "Upgrade plan",
"upgrade_price_per_month_user": "per month/user",
@@ -7,6 +7,7 @@ import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.sch
import { ZAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema";
import { ZListMembersSchema } from "./listPaginated.schema";
import { ZAdminLockUserAccountSchema } from "./lockUserAccount.schema";
import { ZMoveTeamToOrgSchema } from "./moveTeamToOrg.schema";
import { ZAdminRemoveTwoFactor } from "./removeTwoFactor.schema";
import { ZResendPurchaseCompleteEmailSchema } from "./resendPurchaseCompleteEmail.schema";
import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema";
@@ -14,14 +15,14 @@ import { ZSetSMSLockState } from "./setSMSLockState.schema";
import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure";
import { ZAdminUnassignFeatureFromTeamSchema } from "./unassignFeatureFromTeam.schema";
import { ZAdminVerifyWorkflowsSchema } from "./verifyWorkflows.schema";
import { watchlistRouter } from "./watchlist/_router";
import { ZWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema";
import {
workspacePlatformCreateSchema,
workspacePlatformToggleEnabledSchema,
workspacePlatformUpdateSchema,
workspacePlatformUpdateServiceAccountSchema,
workspacePlatformToggleEnabledSchema,
} from "./workspacePlatform/schema";
import { watchlistRouter } from "./watchlist/_router";
const NAMESPACE = "admin";
@@ -95,6 +96,10 @@ export const adminRouter = router({
const { default: handler } = await import("./unassignFeatureFromTeam.handler");
return handler(opts);
}),
moveTeamToOrg: authedAdminProcedure.input(ZMoveTeamToOrgSchema).mutation(async (opts) => {
const { default: handler } = await import("./moveTeamToOrg.handler");
return handler(opts);
}),
workspacePlatform: router({
list: authedAdminProcedure.query(async () => {
const { default: handler } = await import("./workspacePlatform/list.handler");
@@ -0,0 +1,113 @@
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import slugify from "@calcom/lib/slugify";
import { CreationSource } from "@calcom/prisma/enums";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TMoveTeamToOrg } from "./moveTeamToOrg.schema";
const log = logger.getSubLogger({ prefix: ["moveTeamToOrg"] });
type MoveTeamToOrgOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: typeof import("@calcom/prisma").prisma;
};
input: TMoveTeamToOrg;
};
export const moveTeamToOrgHandler = async ({ ctx, input }: MoveTeamToOrgOptions) => {
const { teamId, targetOrgId, teamSlugInOrganization } = input;
log.debug(
"Moving team to org:",
safeStringify({
teamId,
targetOrgId,
teamSlugInOrganization,
})
);
const organizationRepository = getOrganizationRepository();
let org;
try {
org = await organizationRepository.adminFindById({ id: targetOrgId });
} catch (error) {
throw new TRPCError({ code: "NOT_FOUND", message: "organization_not_found" });
}
if (!org.members || org.members.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "organization_owner_not_found" });
}
const teamRepository = new TeamRepository(ctx.prisma);
const team = await teamRepository.findById({ id: teamId });
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "team_not_found" });
}
if (team.parentId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "cannot_move_subteam_already_in_org",
});
}
if (team.isOrganization) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "cannot_move_organization_to_organization",
});
}
const orgOwner = org.members[0].user;
log.info(
"Admin moving team to organization",
safeStringify({
adminUserId: ctx.user.id,
orgOwnerId: orgOwner.id,
teamId,
targetOrgId,
})
);
const oldTeamSlug = team.slug;
const newTeamSlug = slugify(teamSlugInOrganization);
await createTeamsHandler({
ctx: {
user: {
id: orgOwner.id,
organizationId: targetOrgId,
},
},
input: {
teamNames: [],
orgId: targetOrgId,
moveTeams: [
{
id: teamId,
newSlug: newTeamSlug,
shouldMove: true,
},
],
creationSource: CreationSource.WEBAPP,
},
});
return {
message: "team_moved_to_org_success",
teamId,
oldTeamSlug,
newTeamSlug,
organizationId: targetOrgId,
organizationSlug: org.slug,
};
};
export default moveTeamToOrgHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZMoveTeamToOrgSchema = z.object({
teamId: z.number(),
targetOrgId: z.number(),
teamSlugInOrganization: z.string().min(1),
});
export type TMoveTeamToOrg = z.infer<typeof ZMoveTeamToOrgSchema>;
@@ -1,5 +1,6 @@
import prismock from "@calcom/testing/lib/__mocks__/prisma";
import slugify from "@calcom/lib/slugify";
import { Prisma } from "@calcom/prisma/client";
import { CreationSource, MembershipRole, RedirectType, UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -1248,5 +1249,63 @@ describe("createTeams handler - Comprehensive Tests", () => {
]);
inviteSpy.mockRestore();
});
it("should throw CONFLICT error when moving team with slug that already exists in the organization", async () => {
const { owner, organization } = await createScenario();
const teamToMove = await createTestTeam({
name: "Team To Move",
slug: "original-slug",
parentId: null,
});
await createTestMembership({
userId: owner.id,
teamId: teamToMove.id,
role: MembershipRole.OWNER,
});
// Mock the Prisma update to throw a unique constraint error (P2002)
// This simulates what happens when the database rejects a duplicate slug
const originalUpdate = prismock.team.update;
prismock.team.update = vi.fn().mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`slug`,`parentId`)",
{
code: "P2002",
clientVersion: "5.0.0",
}
)
);
await expect(
createTeamsHandler({
ctx: {
user: {
id: owner.id,
organizationId: organization.id,
},
},
input: {
teamNames: [],
orgId: organization.id,
moveTeams: [
{
id: teamToMove.id,
shouldMove: true,
newSlug: "conflicting-slug",
},
],
creationSource: CreationSource.WEBAPP,
},
})
).rejects.toMatchObject({
code: "CONFLICT",
message: "slug_already_in_use_in_org",
});
// Restore the original update function
prismock.team.update = originalUpdate;
});
});
});
@@ -7,13 +7,11 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
import { MembershipRole, RedirectType } from "@calcom/prisma/enums";
import { teamMetadataSchema, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import { inviteMembersWithNoInviterPermissionCheck } from "../teams/inviteMember/inviteMember.handler";
import type { TCreateTeamsSchema } from "./createTeams.schema";
@@ -231,10 +229,13 @@ async function moveTeam({
parentId: org.id,
},
});
const creditService = new CreditService();
await creditService.moveCreditsFromTeamToOrg({ teamId, orgId: org.id });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new TRPCError({
code: "CONFLICT",
message: "slug_already_in_use_in_org",
});
}
log.error(
"Error while moving team to organization",
safeStringify(error),
@@ -247,6 +248,21 @@ async function moveTeam({
throw error;
}
try {
const creditService = new CreditService();
await creditService.moveCreditsFromTeamToOrg({ teamId, orgId: org.id });
} catch (error) {
log.error(
"Error while moving credits from team to organization",
safeStringify(error),
safeStringify({
teamId,
orgId: org.id,
})
);
throw error;
}
// Owner is already a member of the team. Inviting an existing member can throw error
const invitableMembers = team.members.filter(isMembershipNotWithOwner).map((membership) => ({
usernameOrEmail: membership.user.email,