diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/migrations/move-team-to-org/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/migrations/move-team-to-org/page.tsx new file mode 100644 index 0000000000..8b75ee33cc --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/migrations/move-team-to-org/page.tsx @@ -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 => { + const t = await getTranslate(); + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 67a53ded9d..1c55d73e11 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -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 + aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu") + }`}>
{teamMenuState[index].teamMenuOpen ? ( @@ -620,7 +624,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record )}
- {} + { } {!team.parentId && ( - {/* TODO */} - {/* + {/* TODO */} + {/* */} - - {HAS_TEAM_OPT_IN_FEATURES && ( - )} - {/* Hide if there is a parent ID */} - {!team.parentId ? ( - <> + {HAS_TEAM_OPT_IN_FEATURES && ( - - ) : null} - - - )} + )} + {/* Hide if there is a parent ID */} + {!team.parentId ? ( + <> + + + ) : null} + + + )} ); @@ -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 && ( + aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen + ? t("collapse_menu") + : t("expand_menu") + }`}>
{otherTeamMenuState[index].teamMenuOpen ? ( @@ -965,7 +968,7 @@ const SettingsSidebarContainer = ({ )}
- {} + { } {!otherTeam.parentId && ( { + 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 ( +
+
{ + const parsedValues = formSchema.parse(values); + moveTeamMutation.mutate(parsedValues); + }}> +
+ + + +
+ {t("organization_migration_move_team_footnote")} +
+
+ +
+ + {moveTeamMutation.isSuccess && moveTeamMutation.data && ( + +

+ {t("team_id")}: {moveTeamMutation.data.teamId} +

+ {moveTeamMutation.data.oldTeamSlug && ( +

+ {t("move_team_to_org_old_slug")}:{" "} + {moveTeamMutation.data.oldTeamSlug} +

+ )} + {moveTeamMutation.data.newTeamSlug && ( +

+ {t("move_team_to_org_new_slug")}:{" "} + {moveTeamMutation.data.newTeamSlug} +

+ )} +

+ {t("organization_id")}:{" "} + {moveTeamMutation.data.organizationId} +

+ {moveTeamMutation.data.organizationSlug && ( +

+ {t("move_team_to_org_organization_slug")}:{" "} + {moveTeamMutation.data.organizationSlug} +

+ )} +
+ } + /> + )} + + ); +} diff --git a/apps/web/playwright/lib/orgMigration.ts b/apps/web/playwright/lib/orgMigration.ts index 3427adcadb..926edeb7ec 100644 --- a/apps/web/playwright/lib/orgMigration.ts +++ b/apps/web/playwright/lib/orgMigration.ts @@ -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, diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index f97d2b02a7..d8c92ddbdc 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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", diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts index 9fcc67b622..a97a6576e2 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -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"); diff --git a/packages/trpc/server/routers/viewer/admin/moveTeamToOrg.handler.ts b/packages/trpc/server/routers/viewer/admin/moveTeamToOrg.handler.ts new file mode 100644 index 0000000000..14caf8da62 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/moveTeamToOrg.handler.ts @@ -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; + 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; diff --git a/packages/trpc/server/routers/viewer/admin/moveTeamToOrg.schema.ts b/packages/trpc/server/routers/viewer/admin/moveTeamToOrg.schema.ts new file mode 100644 index 0000000000..258bd45042 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/moveTeamToOrg.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts index 4a51ecaf35..789d3880d6 100644 --- a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts @@ -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; + }); }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index ba83378688..8e5eb04c0d 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -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,