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:
co-authored by
bot_apk
Devin AI
parent
4b247640a8
commit
dc43eba23b
+25
@@ -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;
|
||||
+67
-64
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
+59
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user