diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 462678e01f0884..a2e527106216f6 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.314", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index 2e5e818cfd6222..feda629b039bc0 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -2,6 +2,8 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inpu import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository"; import { Injectable, NotFoundException } from "@nestjs/common"; +import { TeamService } from "@calcom/platform-libraries"; + import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service"; @@ -62,10 +64,24 @@ export class OrganizationsMembershipService { } async deleteOrgMembership(organizationId: number, membershipId: number) { - const membership = await this.organizationsMembershipRepository.deleteOrgMembership( + // Get the membership first to get the userId + const membership = await this.organizationsMembershipRepository.findOrgMembership( organizationId, membershipId ); + + if (!membership) { + throw new NotFoundException( + `Membership with id ${membershipId} within organization id ${organizationId} not found` + ); + } + + await TeamService.removeMembers({ + teamIds: [organizationId], + userIds: [membership.userId], + isOrg: true, + }); + return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts index 3ca7b6fe8ef3b0..8b9a77366d8e5c 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts @@ -20,7 +20,6 @@ import { OrgTeamMembershipOutputResponseDto, } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service"; -import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { Controller, @@ -59,7 +58,6 @@ export class OrganizationsTeamsMembershipsController { constructor( private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService, - private teamsEventTypesService: TeamsEventTypesService, private readonly organizationsRepository: OrganizationsRepository ) {} @@ -127,8 +125,6 @@ export class OrganizationsTeamsMembershipsController { membershipId ); - await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId); - return { status: SUCCESS_STATUS, data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts b/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts index 08319ec593ccee..d5e84d5341c749 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/services/organizations-teams-memberships.service.ts @@ -3,6 +3,8 @@ import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/member import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository"; import { Injectable, NotFoundException } from "@nestjs/common"; +import { TeamService } from "@calcom/platform-libraries"; + @Injectable() export class OrganizationsTeamsMembershipsService { constructor( @@ -58,11 +60,21 @@ export class OrganizationsTeamsMembershipsService { } async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) { - const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById( + // First get the membership to get the userId + const teamMembership = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership( organizationId, teamId, membershipId ); + + if (!teamMembership) { + throw new NotFoundException( + `Membership with id ${membershipId} not found in team ${teamId} of organization ${organizationId}` + ); + } + + await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false }); + return teamMembership; } } diff --git a/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts b/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts index d0b2473894e0b7..a1e2003520d122 100644 --- a/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts +++ b/apps/api/v2/src/modules/teams/event-types/services/teams-event-types.service.ts @@ -156,17 +156,4 @@ export class TeamsEventTypesService { return this.eventTypesRepository.deleteEventType(eventTypeId); } - - async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) { - try { - await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId); - await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId); - } catch (err) { - this.logger.error("Could not remove user from all team event-types.", { - error: err, - userId, - teamId, - }); - } - } } diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts index 1d2db3be2967d8..ab3fd1b61d7a69 100644 --- a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts @@ -3,7 +3,6 @@ import { API_KEY_HEADER } from "@/lib/docs/headers"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; -import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output"; @@ -45,10 +44,7 @@ import { SkipTakePagination } from "@calcom/platform-types"; export class TeamsMembershipsController { private logger = new Logger("TeamsMembershipsController"); - constructor( - private teamsMembershipsService: TeamsMembershipsService, - private teamsEventTypesService: TeamsEventTypesService - ) {} + constructor(private teamsMembershipsService: TeamsMembershipsService) {} @Roles("TEAM_ADMIN") @Post("/") @@ -127,7 +123,6 @@ export class TeamsMembershipsController { membershipId, body ); - if (!currentMembership.accepted && updatedMembership.accepted) { try { await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId); @@ -151,8 +146,6 @@ export class TeamsMembershipsController { ): Promise { const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId); - await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId); - return { status: SUCCESS_STATUS, data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), diff --git a/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts index 328ac6151784a6..ef96a9180fc04f 100644 --- a/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts +++ b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts @@ -3,6 +3,8 @@ import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/up import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; import { Injectable, NotFoundException } from "@nestjs/common"; +import { TeamService } from "@calcom/platform-libraries"; + @Injectable() export class TeamsMembershipsService { constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {} @@ -41,10 +43,15 @@ export class TeamsMembershipsService { } async deleteTeamMembership(teamId: number, membershipId: number) { - const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById( - teamId, - membershipId - ); + // First get the membership to get the userId + const teamMembership = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId); + + if (!teamMembership) { + throw new NotFoundException(`Membership with id ${membershipId} not found in team ${teamId}`); + } + + await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false }); + return teamMembership; } } diff --git a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts index 9dad174ca209a4..d820808983a2e5 100644 --- a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts @@ -59,4 +59,8 @@ export class EventTypesRepositoryFixture { async delete(eventTypeId: EventType["id"]) { return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } }); } + + async findById(eventTypeId: EventType["id"]) { + return this.prismaReadClient.eventType.findUnique({ where: { id: eventTypeId } }); + } } diff --git a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts index 74f86ed0204565..7613a67119b6e7 100644 --- a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts @@ -35,4 +35,8 @@ export class MembershipRepositoryFixture { await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } }); return membership; } + + async findById(membershipId: Membership["id"]) { + return this.prismaReadClient.membership.findUnique({ where: { id: membershipId } }); + } } diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/new/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/new/layout.tsx index 136f262d95e49a..c2c1f0dbeb04f4 100644 --- a/apps/web/app/(use-page-wrapper)/settings/organizations/new/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/new/layout.tsx @@ -1,4 +1,3 @@ -import { _generateMetadata } from "app/_utils"; import { notFound } from "next/navigation"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 99298dca26d876..4caf9ddc616104 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -55,7 +55,7 @@ export const EventMeta = ({ timeZones, children, selectedTimeslot, - roundRobinHideOrgAndTeam + roundRobinHideOrgAndTeam, }: { event?: Pick< BookerEvent, diff --git a/packages/features/ee/dsync/lib/removeUserFromOrg.ts b/packages/features/ee/dsync/lib/removeUserFromOrg.ts index 006330e037764b..cc26ee886928f9 100644 --- a/packages/features/ee/dsync/lib/removeUserFromOrg.ts +++ b/packages/features/ee/dsync/lib/removeUserFromOrg.ts @@ -1,11 +1,7 @@ -import removeMember from "@calcom/features/ee/teams/lib/removeMember"; +import { TeamService } from "@calcom/lib/server/service/teamService"; const removeUserFromOrg = async ({ userId, orgId }: { userId: number; orgId: number }) => { - return removeMember({ - memberId: userId, - teamId: orgId, - isOrg: true, - }); + return TeamService.removeMembers({ teamIds: [orgId], userIds: [userId], isOrg: true }); }; export default removeUserFromOrg; diff --git a/packages/features/ee/teams/lib/removeMember.ts b/packages/features/ee/teams/lib/removeMember.ts deleted file mode 100644 index 13cc5ffe746567..00000000000000 --- a/packages/features/ee/teams/lib/removeMember.ts +++ /dev/null @@ -1,141 +0,0 @@ -import logger from "@calcom/lib/logger"; -import { ProfileRepository } from "@calcom/lib/server/repository/profile"; -import prisma from "@calcom/prisma"; - -import { TRPCError } from "@trpc/server"; - -import { deleteWorkfowRemindersOfRemovedMember } from "./deleteWorkflowRemindersOfRemovedMember"; - -const log = logger.getSubLogger({ prefix: ["removeMember"] }); - -const removeMember = async ({ - memberId, - teamId, - isOrg, -}: { - memberId: number; - teamId: number; - isOrg: boolean; -}) => { - const [membership] = await prisma.$transaction([ - prisma.membership.delete({ - where: { - userId_teamId: { userId: memberId, teamId: teamId }, - }, - include: { - user: true, - team: true, - }, - }), - // remove user as host from team events associated with this membership - prisma.host.deleteMany({ - where: { - userId: memberId, - eventType: { - teamId: teamId, - }, - }, - }), - ]); - - const team = await prisma.team.findUnique({ - where: { id: teamId }, - select: { - isOrganization: true, - organizationSettings: true, - id: true, - metadata: true, - activeOrgWorkflows: true, - parentId: true, - }, - }); - - const foundUser = await prisma.user.findUnique({ - where: { id: memberId }, - select: { - id: true, - movedToProfileId: true, - email: true, - username: true, - completedOnboarding: true, - teams: { - select: { - team: { - select: { - id: true, - parentId: true, - }, - }, - }, - }, - }, - }); - - if (!team || !foundUser) throw new TRPCError({ code: "NOT_FOUND" }); - - if (isOrg) { - log.debug("Removing a member from the organization"); - // Deleting membership from all child teams - // Delete all sub-team memberships where this team is the organization - await prisma.membership.deleteMany({ - where: { - team: { - parentId: teamId, - }, - userId: membership.userId, - }, - }); - - const userToDeleteMembershipOf = foundUser; - - const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({ - userId: userToDeleteMembershipOf.id, - organizationId: team.id, - }); - - if ( - userToDeleteMembershipOf.username && - userToDeleteMembershipOf.movedToProfileId === profileToDelete?.id - ) { - log.debug("Cleaning up tempOrgRedirect for user", userToDeleteMembershipOf.username); - - await prisma.tempOrgRedirect.deleteMany({ - where: { - from: userToDeleteMembershipOf.username, - }, - }); - } - - await prisma.$transaction([ - prisma.user.update({ - where: { id: membership.userId }, - data: { organizationId: null }, - }), - ProfileRepository.delete({ - userId: membership.userId, - organizationId: team.id, - }), - prisma.host.deleteMany({ - where: { - userId: memberId, - eventType: { - team: { - parentId: teamId, - }, - }, - }, - }), - ]); - } - - // Deleted managed event types from this team from this member - await prisma.eventType.deleteMany({ - where: { parent: { teamId: teamId }, userId: membership.userId }, - }); - - await deleteWorkfowRemindersOfRemovedMember(team, memberId, isOrg); - - return { membership }; -}; - -export default removeMember; diff --git a/packages/lib/server/service/__tests__/teamService.integration-test.ts b/packages/lib/server/service/__tests__/teamService.integration-test.ts new file mode 100644 index 00000000000000..8ee11501da1dca --- /dev/null +++ b/packages/lib/server/service/__tests__/teamService.integration-test.ts @@ -0,0 +1,1017 @@ +import type { Team, User } from "@prisma/client"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TeamService } from "../teamService"; + +vi.mock("@calcom/features/ee/billing/teams", () => { + const mockUpdateQuantity = vi.fn().mockResolvedValue(undefined); + const mockTeamBilling = { + updateQuantity: mockUpdateQuantity, + }; + + return { + TeamBilling: { + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBilling]), + }, + }; +}); + +const createTestUser = async (overrides?: { + email?: string; + username?: string | null; + organizationId?: number | null; +}) => { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); + + return prisma.user.create({ + data: { + email: overrides?.email ?? `test-user-${timestamp}-${randomSuffix}@example.com`, + username: + overrides?.username === null ? null : overrides?.username ?? `testuser-${timestamp}-${randomSuffix}`, + name: "Test User", + organizationId: overrides?.organizationId ?? undefined, + }, + }); +}; + +const createTestTeam = async (overrides?: { + name?: string; + slug?: string; + isOrganization?: boolean; + parentId?: number | null; +}) => { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); + + return prisma.team.create({ + data: { + name: overrides?.name ?? `Test Team ${timestamp}-${randomSuffix}`, + slug: overrides?.slug ?? `test-team-${timestamp}-${randomSuffix}`, + isOrganization: overrides?.isOrganization ?? false, + parentId: overrides?.parentId ?? undefined, + }, + }); +}; + +const createTestMembership = async ( + userId: number, + teamId: number, + overrides?: { + role?: MembershipRole; + accepted?: boolean; + } +) => { + return prisma.membership.create({ + data: { + userId, + teamId, + role: overrides?.role ?? MembershipRole.MEMBER, + accepted: overrides?.accepted ?? true, + }, + }); +}; + +const createTestEventType = async ( + teamId: number, + overrides?: { + title?: string; + slug?: string; + length?: number; + parentId?: number; + userId?: number; + } +) => { + const timestamp = Date.now(); + + return prisma.eventType.create({ + data: { + title: overrides?.title ?? `Test Event ${timestamp}`, + slug: overrides?.slug ?? `test-event-${timestamp}`, + teamId, + length: overrides?.length ?? 30, + parentId: overrides?.parentId, + userId: overrides?.userId, + }, + }); +}; + +const createTestHost = async ( + userId: number, + eventTypeId: number, + overrides?: { + isFixed?: boolean; + } +) => { + return prisma.host.create({ + data: { + userId, + eventTypeId, + isFixed: overrides?.isFixed ?? false, + }, + }); +}; + +const expectMembershipExists = async (userId: number, teamId: number) => { + const membership = await prisma.membership.findUnique({ + where: { + userId_teamId: { userId, teamId }, + }, + }); + expect(membership).not.toBeNull(); +}; + +const expectMembershipNotExists = async (userId: number, teamId: number) => { + const membership = await prisma.membership.findUnique({ + where: { + userId_teamId: { userId, teamId }, + }, + }); + expect(membership).toBeNull(); +}; + +const expectMembershipCount = async (teamId: number, expectedCount: number) => { + const count = await prisma.membership.count({ + where: { teamId }, + }); + expect(count).toBe(expectedCount); +}; + +const expectUserOrganization = async (userId: number, expectedOrgId: number | null) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { organizationId: true }, + }); + expect(user?.organizationId).toBe(expectedOrgId); +}; + +const expectUserUsername = async (userId: number, expectedUsername: string) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); + expect(user?.username).toBe(expectedUsername); +}; + +const cleanupTestData = async (teamIds: number[], userIds: number[]) => { + await prisma.host.deleteMany({ + where: { + eventType: { + teamId: { in: teamIds }, + }, + }, + }); + + await prisma.eventType.deleteMany({ + where: { + teamId: { in: teamIds }, + }, + }); + + await prisma.membership.deleteMany({ + where: { + OR: teamIds.map((teamId) => ({ teamId })), + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { in: userIds }, + }, + }); + + await prisma.team.deleteMany({ + where: { + id: { in: teamIds }, + }, + }); +}; + +describe("TeamService.removeMembers Integration Tests", () => { + let orgTestData: { + team: Team; + teams: Array<{ + team: Team; + members: User[]; + }>; + members: User[]; + }; + let regularTeamTestData: { + team: Team; + members: User[]; + }; + let userWithoutOrg: User; + + beforeEach(async () => { + // Create organization structure + const orgTeam = await createTestTeam({ isOrganization: true }); + const subTeam = await createTestTeam({ parentId: orgTeam.id }); + + // Create users + const orgUser1 = await createTestUser({ organizationId: orgTeam.id }); + const orgUser2 = await createTestUser({ organizationId: orgTeam.id }); + const nonOrgUser = await createTestUser(); + + // Create regular team + const regularTeamEntity = await createTestTeam(); + + // Set up memberships + await createTestMembership(orgUser1.id, orgTeam.id); + await createTestMembership(orgUser2.id, orgTeam.id); + await createTestMembership(orgUser1.id, regularTeamEntity.id); + await createTestMembership(orgUser2.id, regularTeamEntity.id); + await createTestMembership(orgUser1.id, subTeam.id); + await createTestMembership(orgUser2.id, subTeam.id); + await createTestMembership(nonOrgUser.id, regularTeamEntity.id); + await createTestMembership(nonOrgUser.id, subTeam.id); + + // Structure the test data + orgTestData = { + team: orgTeam, + teams: [{ + team: subTeam, + members: [orgUser1, orgUser2] + }], + members: [orgUser1, orgUser2] + }; + + regularTeamTestData = { + team: regularTeamEntity, + members: [orgUser1, orgUser2, nonOrgUser] + }; + + userWithoutOrg = nonOrgUser; + }); + + afterEach(async () => { + const teamIds = [ + orgTestData.team.id, + ...orgTestData.teams.map(t => t.team.id), + regularTeamTestData.team.id + ]; + const userIds = [ + ...orgTestData.members.map(u => u.id), + userWithoutOrg.id + ]; + + await cleanupTestData(teamIds, userIds); + vi.clearAllMocks(); + }); + + describe("Team Removal - Sub-Team/Regular Team", () => { + it("should remove members from a single team", async () => { + const [orgUser1, orgUser2] = orgTestData.members; + + await TeamService.removeMembers({ + teamIds: [regularTeamTestData.team.id], + userIds: [orgUser1.id, orgUser2.id] + }); + + await expectMembershipNotExists(orgUser1.id, regularTeamTestData.team.id); + await expectMembershipNotExists(orgUser2.id, regularTeamTestData.team.id); + await expectMembershipExists(userWithoutOrg.id, regularTeamTestData.team.id); + + await expectMembershipCount(regularTeamTestData.team.id, 1); + + await expectMembershipExists(orgUser1.id, orgTestData.team.id); + await expectMembershipExists(orgUser2.id, orgTestData.team.id); + }); + + it("should remove members from multiple teams", async () => { + const [orgUser1] = orgTestData.members; + const subTeam = orgTestData.teams[0].team; + + await TeamService.removeMembers({ + teamIds: [regularTeamTestData.team.id, subTeam.id], + userIds: [orgUser1.id] + }); + + await expectMembershipNotExists(orgUser1.id, regularTeamTestData.team.id); + await expectMembershipNotExists(orgUser1.id, subTeam.id); + + await expectMembershipExists(orgTestData.members[1].id, regularTeamTestData.team.id); + await expectMembershipExists(userWithoutOrg.id, regularTeamTestData.team.id); + await expectMembershipCount(regularTeamTestData.team.id, 2); + }); + + it("should remove hosts from team events when removing team member", async () => { + // Create a team member + const teamMember = await createTestUser(); + await createTestMembership(teamMember.id, regularTeamTestData.team.id); + + // Create multiple event types for the team + const teamEvent1 = await createTestEventType(regularTeamTestData.team.id, { + title: "Team Event 1", + slug: "team-event-1", + }); + + const teamEvent2 = await createTestEventType(regularTeamTestData.team.id, { + title: "Team Event 2", + slug: "team-event-2", + }); + + // Create an event type for another team (should not be affected) + const otherTeam = await createTestTeam({ name: "Other Team" }); + const otherTeamEvent = await createTestEventType(otherTeam.id, { + title: "Other Team Event", + slug: "other-team-event", + }); + + // Add the user as host to all events + await createTestHost(teamMember.id, teamEvent1.id); + await createTestHost(teamMember.id, teamEvent2.id); + await createTestHost(teamMember.id, otherTeamEvent.id); + + // Also add another user as host to teamEvent1 (should not be affected) + const anotherUser = await createTestUser(); + await createTestMembership(anotherUser.id, regularTeamTestData.team.id); + await createTestHost(anotherUser.id, teamEvent1.id); + + // Verify hosts exist before removal + const hostsBefore = await prisma.host.findMany({ + where: { userId: teamMember.id }, + }); + expect(hostsBefore).toHaveLength(3); + + // Remove member from team (not org) + await TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [teamMember.id], isOrg: false }); + + // Verify hosts for team events were removed + const hostsAfterForTeamEvents = await prisma.host.findMany({ + where: { + userId: teamMember.id, + eventTypeId: { in: [teamEvent1.id, teamEvent2.id] }, + }, + }); + expect(hostsAfterForTeamEvents).toHaveLength(0); + + // Verify host for other team event still exists + const hostsAfterForOtherTeam = await prisma.host.findMany({ + where: { + userId: teamMember.id, + eventTypeId: otherTeamEvent.id, + }, + }); + expect(hostsAfterForOtherTeam).toHaveLength(1); + + // Verify other user's host assignment was not affected + const otherUserHosts = await prisma.host.findMany({ + where: { userId: anotherUser.id }, + }); + expect(otherUserHosts).toHaveLength(1); + expect(otherUserHosts[0].eventTypeId).toBe(teamEvent1.id); + + // Verify membership was deleted + await expectMembershipNotExists(teamMember.id, regularTeamTestData.team.id); + + // Clean up + await prisma.eventType.deleteMany({ + where: { id: { in: [teamEvent1.id, teamEvent2.id, otherTeamEvent.id] } }, + }); + await cleanupTestData([otherTeam.id], [teamMember.id, anotherUser.id]); + }); + + it("should delete managed event types when removing from team", async () => { + // Create parent event types + const parentEventType1 = await createTestEventType(regularTeamTestData.team.id, { + title: "Parent Team Event 1", + slug: "parent-team-event-1", + }); + + const parentEventType2 = await createTestEventType(regularTeamTestData.team.id, { + title: "Parent Team Event 2", + slug: "parent-team-event-2", + }); + + // Create managed event types for the user (one per parent) + const managedEventType1 = await createTestEventType(regularTeamTestData.team.id, { + userId: orgTestData.members[0].id, + parentId: parentEventType1.id, + title: "Managed Event 1", + slug: "managed-event-1", + }); + + const managedEventType2 = await createTestEventType(regularTeamTestData.team.id, { + userId: orgTestData.members[0].id, + parentId: parentEventType2.id, + title: "Managed Event 2", + slug: "managed-event-2", + }); + + // Create a managed event type for another user (should not be affected) + const otherUserManagedEvent = await createTestEventType(regularTeamTestData.team.id, { + userId: orgTestData.members[1].id, + parentId: parentEventType1.id, + title: "Other User Managed Event", + slug: "other-user-managed-event", + }); + + // Remove orgTestData.members[0] from team + await TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [orgTestData.members[0].id], isOrg: false }); + + // Verify orgTestData.members[0]'s managed event types were deleted + const deletedEventType1 = await prisma.eventType.findUnique({ + where: { id: managedEventType1.id }, + }); + expect(deletedEventType1).toBeNull(); + + const deletedEventType2 = await prisma.eventType.findUnique({ + where: { id: managedEventType2.id }, + }); + expect(deletedEventType2).toBeNull(); + + // Verify parent event types still exist + const parentStillExists1 = await prisma.eventType.findUnique({ + where: { id: parentEventType1.id }, + }); + expect(parentStillExists1).not.toBeNull(); + + const parentStillExists2 = await prisma.eventType.findUnique({ + where: { id: parentEventType2.id }, + }); + expect(parentStillExists2).not.toBeNull(); + + // Verify other user's managed event still exists + const otherUserEventStillExists = await prisma.eventType.findUnique({ + where: { id: otherUserManagedEvent.id }, + }); + expect(otherUserEventStillExists).not.toBeNull(); + + // Clean up + await prisma.eventType.deleteMany({ + where: { id: { in: [parentEventType1.id, parentEventType2.id, otherUserManagedEvent.id] } }, + }); + }); + + it("should not modify user organization data when removing from sub-team", async () => { + await TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [orgTestData.members[0].id], isOrg: false }); + + await expectMembershipNotExists(orgTestData.members[0].id, regularTeamTestData.team.id); + + const user = await prisma.user.findUnique({ + where: { id: orgTestData.members[0].id }, + }); + // When isOrg is false for regular teams, it should not update organizationId or modify username + expect(user?.organizationId).toBe(orgTestData.team.id); + expect(user?.username).toBe(orgTestData.members[0].username); + }); + }); + + describe("Organization Removal", () => { + it("should remove members from organization and all sub-teams", async () => { + const originalUsername = orgTestData.members[0].username || ""; + + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [orgTestData.members[0].id], isOrg: true }); + + await expectMembershipNotExists(orgTestData.members[0].id, orgTestData.team.id); + await expectMembershipExists(orgTestData.members[0].id, regularTeamTestData.team.id); + await expectMembershipNotExists(orgTestData.members[0].id, orgTestData.teams[0].team.id); + + await expectUserOrganization(orgTestData.members[0].id, null); + await expectUserUsername(orgTestData.members[0].id, `${originalUsername}-${orgTestData.members[0].id}`); + }); + + it("should remove multiple members from organization", async () => { + await TeamService.removeMembers({ + teamIds: [orgTestData.team.id], + userIds: [orgTestData.members[0].id, orgTestData.members[1].id], + isOrg: true, + }); + + await expectMembershipNotExists(orgTestData.members[0].id, orgTestData.team.id); + await expectMembershipNotExists(orgTestData.members[1].id, orgTestData.team.id); + await expectMembershipNotExists(orgTestData.members[0].id, orgTestData.teams[0].team.id); + await expectMembershipNotExists(orgTestData.members[1].id, orgTestData.teams[0].team.id); + + await expectMembershipExists(userWithoutOrg.id, orgTestData.teams[0].team.id); + }); + + it("should delete host assignments when removing from organization", async () => { + const eventType = await createTestEventType(orgTestData.teams[0].team.id); + await createTestHost(orgTestData.members[0].id, eventType.id); + + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [orgTestData.members[0].id], isOrg: true }); + + const hosts = await prisma.host.findMany({ + where: { + userId: orgTestData.members[0].id, + eventTypeId: eventType.id, + }, + }); + expect(hosts).toHaveLength(0); + + await prisma.eventType.delete({ + where: { id: eventType.id }, + }); + }); + + it("should prevent username conflicts when removing from organization", async () => { + const sharedUsername = `sharedusername-${Date.now()}`; + + const userInOrg = await createTestUser({ + username: sharedUsername, + organizationId: orgTestData.team.id, + }); + await createTestMembership(userInOrg.id, orgTestData.team.id); + + const userOutsideOrg = await createTestUser({ + username: sharedUsername, + organizationId: null, + }); + + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [userInOrg.id], isOrg: true }); + + const updatedUser = await prisma.user.findUnique({ + where: { id: userInOrg.id }, + }); + expect(updatedUser?.username).toBe(`${sharedUsername}-${userInOrg.id}`); + expect(updatedUser?.organizationId).toBeNull(); + + const unchangedUser = await prisma.user.findUnique({ + where: { id: userOutsideOrg.id }, + }); + expect(unchangedUser?.username).toBe(sharedUsername); + + await cleanupTestData([], [userInOrg.id, userOutsideOrg.id]); + }); + + it("should preserve null username when removing from organization", async () => { + const userWithNullUsername = await createTestUser({ + username: null, + organizationId: orgTestData.team.id, + }); + await createTestMembership(userWithNullUsername.id, orgTestData.team.id); + + await TeamService.removeMembers({ + teamIds: [orgTestData.team.id], + userIds: [userWithNullUsername.id], + isOrg: true, + }); + + const updatedUser = await prisma.user.findUnique({ + where: { id: userWithNullUsername.id }, + }); + expect(updatedUser?.username).toBeNull(); + expect(updatedUser?.organizationId).toBeNull(); + + await cleanupTestData([], [userWithNullUsername.id]); + }); + + it("should delete profile when removing from organization", async () => { + const profileUser = await createTestUser({ organizationId: orgTestData.team.id }); + await createTestMembership(profileUser.id, orgTestData.team.id); + + const profile = await prisma.profile.create({ + data: { + uid: `profile-${Date.now()}`, + userId: profileUser.id, + organizationId: orgTestData.team.id, + username: profileUser.username || "", + }, + }); + + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [profileUser.id], isOrg: true }); + + const deletedProfile = await prisma.profile.findUnique({ + where: { id: profile.id }, + }); + expect(deletedProfile).toBeNull(); + + await cleanupTestData([], [profileUser.id]); + }); + + it("should successfully remove member without profile from organization", async () => { + const userWithoutProfile = await createTestUser({ organizationId: orgTestData.team.id }); + await createTestMembership(userWithoutProfile.id, orgTestData.team.id); + + await expect( + TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [userWithoutProfile.id], isOrg: true }) + ).resolves.not.toThrow(); + + await expectMembershipNotExists(userWithoutProfile.id, orgTestData.team.id); + await cleanupTestData([], [userWithoutProfile.id]); + }); + + it("should clean up tempOrgRedirect when user movedToProfileId matches", async () => { + const userWithProfile = await createTestUser({ organizationId: orgTestData.team.id }); + await createTestMembership(userWithProfile.id, orgTestData.team.id); + + const profile = await prisma.profile.create({ + data: { + uid: `profile-temp-${Date.now()}`, + userId: userWithProfile.id, + organizationId: orgTestData.team.id, + username: userWithProfile.username || "", + }, + }); + + await prisma.user.update({ + where: { id: userWithProfile.id }, + data: { movedToProfileId: profile.id }, + }); + + await prisma.tempOrgRedirect.create({ + data: { + type: "User", + enabled: true, + from: userWithProfile.username || "", + fromOrgId: orgTestData.team.id, + toUrl: "https://example.com", + }, + }); + + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [userWithProfile.id], isOrg: true }); + + const redirects = await prisma.tempOrgRedirect.findMany({ + where: { from: userWithProfile.username || "" }, + }); + expect(redirects).toHaveLength(0); + + await cleanupTestData([], [userWithProfile.id]); + }); + + it("should append userId to username when removing from organization", async () => { + const userWithUsername = await prisma.user.create({ + data: { + email: `member8-acme-${Date.now()}@example.com`, + username: "member8-acme", + organizationId: orgTestData.team.id, + }, + }); + await createTestMembership(userWithUsername.id, orgTestData.team.id); + + // Create a profile to match real-world scenario + const profile = await prisma.profile.create({ + data: { + uid: `profile-${Date.now()}`, + userId: userWithUsername.id, + organizationId: orgTestData.team.id, + username: "member8-acme", // Profile has same username + }, + }); + + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [userWithUsername.id], isOrg: true }); + + const updatedUser = await prisma.user.findUnique({ + where: { id: userWithUsername.id }, + }); + + // Should append userId to existing username + expect(updatedUser?.username).toBe(`member8-acme-${userWithUsername.id}`); + expect(updatedUser?.organizationId).toBeNull(); + + // Profile should be deleted + const deletedProfile = await prisma.profile.findUnique({ + where: { id: profile.id }, + }); + expect(deletedProfile).toBeNull(); + + await cleanupTestData([], [userWithUsername.id]); + }); + + it("should preserve null username even when profile has username", async () => { + const userWithNullUsername = await prisma.user.create({ + data: { + email: `null-username-user-${Date.now()}@example.com`, + username: null, // User has null username + organizationId: orgTestData.team.id, + }, + }); + await createTestMembership(userWithNullUsername.id, orgTestData.team.id); + + // Create a profile with username + const profile = await prisma.profile.create({ + data: { + uid: `profile-null-user-${Date.now()}`, + userId: userWithNullUsername.id, + organizationId: orgTestData.team.id, + username: "member8-acme", // Profile has username + }, + }); + + await TeamService.removeMembers({ + teamIds: [orgTestData.team.id], + userIds: [userWithNullUsername.id], + isOrg: true, + }); + + const updatedUser = await prisma.user.findUnique({ + where: { id: userWithNullUsername.id }, + }); + + // User username remains null because foundUser.username was null + expect(updatedUser?.username).toBeNull(); + expect(updatedUser?.organizationId).toBeNull(); + + // Profile should be deleted + const deletedProfile = await prisma.profile.findUnique({ + where: { id: profile.id }, + }); + expect(deletedProfile).toBeNull(); + + await cleanupTestData([], [userWithNullUsername.id]); + }); + + it("should append userId to empty username when removing from organization", async () => { + const userWithEmptyUsername = await prisma.user.create({ + data: { + email: `empty-username-${Date.now()}@example.com`, + username: "", // Empty string, not null + organizationId: orgTestData.team.id, + }, + }); + await createTestMembership(userWithEmptyUsername.id, orgTestData.team.id); + + await TeamService.removeMembers({ + teamIds: [orgTestData.team.id], + userIds: [userWithEmptyUsername.id], + isOrg: true, + }); + + const updatedUser = await prisma.user.findUnique({ + where: { id: userWithEmptyUsername.id }, + }); + + // Fixed: Empty string usernames should be appended with userId + expect(updatedUser?.username).toBe(`-${userWithEmptyUsername.id}`); + expect(updatedUser?.organizationId).toBeNull(); + + await cleanupTestData([], [userWithEmptyUsername.id]); + }); + + it("should correctly remove user from organization with sub-team hosts and profile", async () => { + // This test simulates exactly what happens in API v2 deletion + const apiUser = await prisma.user.create({ + data: { + email: `api-test-${Date.now()}@example.com`, + username: "member8-acme", // Using the exact username from the issue + organizationId: orgTestData.team.id, + }, + }); + + // Create membership in org + await createTestMembership(apiUser.id, orgTestData.team.id); + + // Create a sub-team (like in API v2 test) + const apiSubTeam = await createTestTeam({ + parentId: orgTestData.team.id, + name: "API Sub Team", + }); + + // Create membership in sub-team + await createTestMembership(apiUser.id, apiSubTeam.id); + + // Create event type in sub-team + const apiEventType = await createTestEventType(apiSubTeam.id, { + title: "API Team Event", + }); + + // Add user as host (like in API v2 test) + await createTestHost(apiUser.id, apiEventType.id); + + // Create profile (which exists in real scenario) + const profile = await prisma.profile.create({ + data: { + uid: `profile-api-${Date.now()}`, + userId: apiUser.id, + organizationId: orgTestData.team.id, + username: "member8-acme", + }, + }); + + // Now remove from organization (what API v2 does) + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [apiUser.id], isOrg: true }); + + // Check final state + const finalUser = await prisma.user.findUnique({ + where: { id: apiUser.id }, + select: { id: true, username: true, organizationId: true }, + }); + + // Username should be appended with userId, not null + expect(finalUser?.username).toBe(`member8-acme-${apiUser.id}`); + expect(finalUser?.organizationId).toBeNull(); + + // Verify profile was deleted + const deletedProfile = await prisma.profile.findUnique({ + where: { id: profile.id }, + }); + expect(deletedProfile).toBeNull(); + + // Verify host was removed + const hosts = await prisma.host.findMany({ + where: { userId: apiUser.id }, + }); + expect(hosts).toHaveLength(0); + + await cleanupTestData([apiSubTeam.id], [apiUser.id]); + }); + + it("should delete managed event types in sub-teams when removing from organization", async () => { + // Create another sub-team under the org + const secondSubTeam = await createTestTeam({ + parentId: orgTestData.team.id, + name: "Second Sub Team", + }); + + // Add user to the second sub-team + await createTestMembership(orgTestData.members[0].id, secondSubTeam.id); + + // Create parent event types in both sub-teams + const parentEventInTeam1 = await createTestEventType(orgTestData.teams[0].team.id, { + title: "Parent Event Team 1", + slug: "parent-event-team-1", + }); + + const parentEventInTeam2 = await createTestEventType(secondSubTeam.id, { + title: "Parent Event Team 2", + slug: "parent-event-team-2", + }); + + // Create managed events for orgTestData.members[0] in both sub-teams + const managedEventTeam1 = await createTestEventType(orgTestData.teams[0].team.id, { + userId: orgTestData.members[0].id, + parentId: parentEventInTeam1.id, + title: "User1 Managed Event Team 1", + slug: "user1-managed-event-team-1", + }); + + const managedEventTeam2 = await createTestEventType(secondSubTeam.id, { + userId: orgTestData.members[0].id, + parentId: parentEventInTeam2.id, + title: "User1 Managed Event Team 2", + slug: "user1-managed-event-team-2", + }); + + // Create managed event for orgTestData.members[1] (should not be affected) + const managedEventUser2 = await createTestEventType(orgTestData.teams[0].team.id, { + userId: orgTestData.members[1].id, + parentId: parentEventInTeam1.id, + title: "User2 Managed Event", + slug: "user2-managed-event", + }); + + // Verify managed events exist before removal + const eventsBeforeRemoval = await prisma.eventType.findMany({ + where: { + id: { in: [managedEventTeam1.id, managedEventTeam2.id, managedEventUser2.id] }, + }, + }); + expect(eventsBeforeRemoval).toHaveLength(3); + + // Remove orgTestData.members[0] from organization + await TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [orgTestData.members[0].id], isOrg: true }); + + // Verify orgTestData.members[0]'s managed events in all sub-teams were deleted + const deletedEvent1 = await prisma.eventType.findUnique({ + where: { id: managedEventTeam1.id }, + }); + expect(deletedEvent1).toBeNull(); + + const deletedEvent2 = await prisma.eventType.findUnique({ + where: { id: managedEventTeam2.id }, + }); + expect(deletedEvent2).toBeNull(); + + // Verify parent events still exist + const parentEvent1Still = await prisma.eventType.findUnique({ + where: { id: parentEventInTeam1.id }, + }); + expect(parentEvent1Still).not.toBeNull(); + + const parentEvent2Still = await prisma.eventType.findUnique({ + where: { id: parentEventInTeam2.id }, + }); + expect(parentEvent2Still).not.toBeNull(); + + // Verify orgTestData.members[1]'s managed event was not affected + const user2EventStill = await prisma.eventType.findUnique({ + where: { id: managedEventUser2.id }, + }); + expect(user2EventStill).not.toBeNull(); + + // Verify memberships were removed from org and all sub-teams + await expectMembershipNotExists(orgTestData.members[0].id, orgTestData.team.id); + await expectMembershipNotExists(orgTestData.members[0].id, orgTestData.teams[0].team.id); + await expectMembershipNotExists(orgTestData.members[0].id, secondSubTeam.id); + + // Clean up + await prisma.eventType.deleteMany({ + where: { + id: { in: [parentEventInTeam1.id, parentEventInTeam2.id, managedEventUser2.id] }, + }, + }); + await cleanupTestData([secondSubTeam.id], []); + }); + }); + + describe("Common Behaviors and Edge Cases", () => { + it("should call TeamBilling.updateQuantity for each team", async () => { + const { TeamBilling } = await import("@calcom/features/ee/billing/teams"); + + await TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [orgTestData.members[0].id, orgTestData.members[1].id] }); + + expect(TeamBilling.findAndInitMany).toHaveBeenCalledWith([regularTeamTestData.team.id]); + const mockInstances = await TeamBilling.findAndInitMany([regularTeamTestData.team.id]); + expect(mockInstances[0].updateQuantity).toHaveBeenCalled(); + }); + + it("should throw error when membership doesn't exist", async () => { + const nonExistentUserId = 999999; + + await expect( + TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [nonExistentUserId] }) + ).rejects.toThrow("Membership not found"); + }); + + it("should gracefully skip when given empty arrays", async () => { + await expect(TeamService.removeMembers({ teamIds: [], userIds: [orgTestData.members[0].id] })).resolves.not.toThrow(); + await expect(TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [] })).resolves.not.toThrow(); + await expect(TeamService.removeMembers({ teamIds: [], userIds: [] })).resolves.not.toThrow(); + }); + + it("should throw error on second removal attempt of same member", async () => { + await TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [orgTestData.members[0].id] }); + + await expect( + TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [orgTestData.members[0].id] }) + ).rejects.toThrow("Membership not found"); + }); + + it("should rollback all changes if any operation in transaction fails", async () => { + // Create a user and membership for this test + const rollbackTestUser = await createTestUser({ + username: "rollback-test-user", + organizationId: orgTestData.team.id, + }); + await createTestMembership(rollbackTestUser.id, orgTestData.team.id); + + // Create sub-team membership + const subTeamForRollback = await createTestTeam({ + parentId: orgTestData.team.id, + name: "Rollback Test Sub Team", + }); + await createTestMembership(rollbackTestUser.id, subTeamForRollback.id); + + // Create event type and host + const eventType = await createTestEventType(subTeamForRollback.id); + await createTestHost(rollbackTestUser.id, eventType.id); + + // Create profile + await prisma.profile.create({ + data: { + uid: `profile-rollback-${Date.now()}`, + userId: rollbackTestUser.id, + organizationId: orgTestData.team.id, + username: "rollback-test-user", + }, + }); + + // Create another user with the username we would try to update to + // This will cause the user.update to fail due to unique constraint + const conflictingUser = await createTestUser({ + username: `rollback-test-user-${rollbackTestUser.id}`, // This is what removeMember would try to set + }); + + // This should fail because of username unique constraint violation + await expect( + TeamService.removeMembers({ teamIds: [orgTestData.team.id], userIds: [rollbackTestUser.id], isOrg: true }) + ).rejects.toThrow(); + + // Verify nothing was changed - all data should still exist due to transaction rollback + const userAfterFailure = await prisma.user.findUnique({ + where: { id: rollbackTestUser.id }, + }); + expect(userAfterFailure?.username).toBe("rollback-test-user"); // Username unchanged + expect(userAfterFailure?.organizationId).toBe(orgTestData.team.id); // Still in org + + // Verify memberships still exist + await expectMembershipExists(rollbackTestUser.id, orgTestData.team.id); + await expectMembershipExists(rollbackTestUser.id, subTeamForRollback.id); + + // Verify profile still exists + const profile = await prisma.profile.findUnique({ + where: { + userId_organizationId: { + userId: rollbackTestUser.id, + organizationId: orgTestData.team.id, + }, + }, + }); + expect(profile).not.toBeNull(); + + // Verify host still exists + const hosts = await prisma.host.findMany({ + where: { userId: rollbackTestUser.id, eventTypeId: eventType.id }, + }); + expect(hosts).toHaveLength(1); + + // Clean up + await prisma.eventType.delete({ where: { id: eventType.id } }); + await cleanupTestData([subTeamForRollback.id], [rollbackTestUser.id, conflictingUser.id]); + }); + }); +}); \ No newline at end of file diff --git a/packages/lib/server/service/teamService.ts b/packages/lib/server/service/teamService.ts index 424809e5900de0..67c679227d7d7f 100644 --- a/packages/lib/server/service/teamService.ts +++ b/packages/lib/server/service/teamService.ts @@ -1,16 +1,52 @@ import { Prisma } from "@prisma/client"; import { TeamBilling } from "@calcom/features/ee/billing/teams"; -import removeMember from "@calcom/features/ee/teams/lib/removeMember"; +import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; import logger from "@calcom/lib/logger"; +import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { TeamRepository } from "@calcom/lib/server/repository/team"; import { WorkflowService } from "@calcom/lib/server/service/workflows"; import prisma from "@calcom/prisma"; +import type { Membership } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; +const log = logger.getSubLogger({ prefix: ["TeamService"] }); + +type MembershipWithRelations = Pick< + Membership, + "id" | "userId" | "teamId" | "role" | "accepted" | "disableImpersonation" +>; + +type TeamWithSettings = { + id: number; + isOrganization: boolean | null; + organizationSettings: unknown; + metadata: unknown; + activeOrgWorkflows: unknown; + parentId: number | null; +}; + +type UserWithTeams = { + id: number; + movedToProfileId: number | null; + email: string; + username: string | null; + completedOnboarding: boolean; + teams: { + team: { + id: number; + parentId: number | null; + }; + }[]; +}; + +export type RemoveMemberResult = { + membership: MembershipWithRelations; +}; + export class TeamService { /** * Deletes a team and all its associated data in a safe, transactional order. @@ -45,15 +81,23 @@ export class TeamService { return deletedTeam; } - static async removeMembers(teamIds: number[], memberIds: number[], isOrg = false) { - const deleteMembershipPromises = []; + static async removeMembers({ + teamIds, + userIds, + isOrg = false, + }: { + teamIds: number[]; + userIds: number[]; + isOrg?: boolean; + }) { + const deleteMembershipPromises: Promise[] = []; - for (const memberId of memberIds) { + for (const userId of userIds) { for (const teamId of teamIds) { deleteMembershipPromises.push( - removeMember({ + TeamService.removeMember({ teamId, - memberId, + userId, isOrg, }) ); @@ -121,4 +165,217 @@ export class TeamService { const teamBilling = await TeamBilling.findAndInit(teamId); return teamBilling.publish(); } + + private static async removeMember({ + userId, + teamId, + isOrg, + }: { + userId: number; + teamId: number; + isOrg: boolean; + }) { + const membership = await TeamService.fetchMembershipOrThrow(userId, teamId); + const team = await TeamService.fetchTeamOrThrow(teamId); + const user = await TeamService.fetchUserOrThrow(userId); + + if (isOrg) { + log.debug("Removing a member from the organization"); + await TeamService.removeFromOrganization(membership, team, user); + } else { + log.debug("Removing a member from a team"); + await TeamService.removeFromTeam(membership, teamId); + } + + await deleteWorkfowRemindersOfRemovedMember(team, userId, isOrg); + + return { membership }; + } + + // TODO: Needs to be moved to repository + private static async fetchMembershipOrThrow( + userId: number, + teamId: number + ): Promise { + const membership = await prisma.membership.findUnique({ + where: { + userId_teamId: { userId: userId, teamId: teamId }, + }, + select: { + id: true, + userId: true, + teamId: true, + role: true, + accepted: true, + disableImpersonation: true, + }, + }); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Membership not found" }); + } + + return membership; + } + + // TODO: Needs to be moved to repository + private static async fetchTeamOrThrow(teamId: number): Promise { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { + isOrganization: true, + organizationSettings: true, + id: true, + metadata: true, + activeOrgWorkflows: true, + parentId: true, + }, + }); + + if (!team) { + throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); + } + + return team; + } + + // TODO: Needs to be moved to repository + private static async fetchUserOrThrow(userId: number): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + movedToProfileId: true, + email: true, + username: true, + completedOnboarding: true, + teams: { + select: { + team: { + select: { + id: true, + parentId: true, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + return user; + } + + // TODO: Needs to be moved to repository + private static async cleanupTempOrgRedirect(user: UserWithTeams, team: TeamWithSettings) { + const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({ + userId: user.id, + organizationId: team.id, + }); + + if (user.username && user.movedToProfileId === profileToDelete?.id) { + log.debug("Cleaning up tempOrgRedirect for user", user.username); + await prisma.tempOrgRedirect.deleteMany({ + where: { + from: user.username, + }, + }); + } + } + + private static async removeFromOrganization( + membership: MembershipWithRelations, + team: TeamWithSettings, + user: UserWithTeams + ) { + await TeamService.cleanupTempOrgRedirect(user, team); + const newUsername = generateNewUsername(user); + + await prisma.$transaction([ + // Remove user from all sub-teams event type hosts + prisma.host.deleteMany({ + where: { + userId: membership.userId, + eventType: { + team: { + parentId: team.id, + }, + }, + }, + }), + // Delete managed child events in sub-teams + prisma.eventType.deleteMany({ + where: { + userId: membership.userId, + parent: { + team: { + parentId: team.id, + }, + }, + }, + }), + // Remove organizationId from the user + prisma.user.update({ + where: { id: membership.userId }, + data: { + organizationId: null, + username: newUsername, + }, + }), + // Delete the profile of the user from the organization + ProfileRepository.delete({ + userId: membership.userId, + organizationId: team.id, + }), + // Delete all sub-team memberships where this team is the organization + prisma.membership.deleteMany({ + where: { + team: { + parentId: team.id, + }, + userId: membership.userId, + }, + }), + // Delete the membership of the user from the organization + prisma.membership.delete({ + where: { + userId_teamId: { userId: membership.userId, teamId: team.id }, + }, + }), + ]); + + // Generate new username for user leaving organization + function generateNewUsername(user: UserWithTeams): string | null { + // We ensure that new username would be unique across all users in the global namespace outside any organization + return user.username != null ? `${user.username}-${user.id}` : null; + } + } + + // Remove member from regular team + private static async removeFromTeam(membership: MembershipWithRelations, teamId: number) { + await prisma.$transaction([ + // Remove user from all team event types' hosts + prisma.host.deleteMany({ + where: { + userId: membership.userId, + eventType: { + teamId: teamId, + }, + }, + }), + // Deleted managed event types from this team for this member + prisma.eventType.deleteMany({ + where: { parent: { teamId: teamId }, userId: membership.userId }, + }), + // Delete the membership of the user from the team + prisma.membership.delete({ + where: { + userId_teamId: { userId: membership.userId, teamId: teamId }, + }, + }), + ]); + } } diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 0c35a53cfb54f0..c6da1488ea340b 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -120,4 +120,5 @@ export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; export { verifyPhoneNumber, sendVerificationCode }; +export { TeamService } from "@calcom/lib/server/service/teamService"; export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts index 1b630e4cd4347b..1ce45d95f79b43 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -59,7 +59,7 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = message: "You can not remove yourself from a team you own.", }); - await TeamService.removeMembers(teamIds, memberIds, isOrg); + await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg }); }; export default removeMemberHandler; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.test.ts b/packages/trpc/server/routers/viewer/teams/removeMember.test.ts deleted file mode 100644 index b9e380336be084..00000000000000 --- a/packages/trpc/server/routers/viewer/teams/removeMember.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// @ts-nocheck -// TODO: Bring this test back with the correct setup (no illegal imports) -import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; - -import { describe, test, expect } from "vitest"; - -import { SchedulingType, MembershipRole } from "@calcom/prisma/enums"; - -import type { TrpcSessionUser } from "../../../types"; -import removeMember from "./removeMember.handler"; - -describe.skip("removeMember", () => { - describe("should remove a member from a team", () => { - test(`1) Should remove a member from a team - 2) Should remove the member from hosts - `, async () => { - const org = await createOrganization({ - name: "Test Org", - slug: "testorg", - }); - - // Create the child team - const childTeam = { - id: 202, - name: "Team 1", - slug: "team-1", - parentId: org.id, - }; - - const organizer = getOrganizer({ - name: "Organizer", - email: "organizer@example.com", - id: 101, - organizationId: org.id, - schedules: [TestData.schedules.IstWorkHours], - teams: [ - { - membership: { - accepted: true, - role: MembershipRole.ADMIN, - }, - team: { - id: org.id, - name: "Test Org", - slug: "testorg", - }, - }, - { - membership: { - accepted: true, - role: MembershipRole.ADMIN, - }, - team: { - id: childTeam.id, - name: "Team 1", - slug: "team-1", - parentId: org.id, - }, - }, - ], - }); - - const otherTeamMembers = [ - { - name: "Other Team Member 1", - username: "other-team-member-1", - timeZone: Timezones["+5:30"], - defaultScheduleId: null, - email: "other-team-member-1@example.com", - id: 102, - organizationId: org.id, - schedules: [TestData.schedules.IstEveningShift], - teams: [ - { - membership: { - accepted: true, - role: MembershipRole.MEMBER, - }, - team: { - id: org.id, - name: "Test Org", - slug: "testorg", - }, - }, - { - membership: { - accepted: true, - role: MembershipRole.MEMBER, - }, - team: { - id: childTeam.id, - name: "Team 1", - slug: "team-1", - parentId: org.id, - }, - }, - ], - }, - ]; - - await createBookingScenario( - getScenarioData( - { - eventTypes: [ - { - id: 1, - teamId: childTeam.id, - schedulingType: SchedulingType.ROUND_ROBIN, - length: 30, - hosts: [ - { - userId: 101, - isFixed: false, - }, - { - userId: 102, - isFixed: false, - }, - ], - }, - ], - organizer, - usersApartFromOrganizer: otherTeamMembers, - apps: [TestData.apps["daily-video"]], - }, - org - ) - ); - - //Logged in user is admin of the org - const ctx = { - user: { - id: organizer.id, - name: organizer.name, - } as NonNullable, - }; - - await removeMember({ - ctx, - input: { - teamIds: [org.id], - memberIds: [102], - isOrg: true, - }, - }); - - //Check if the remaining memberships are correct - const remainingMemberships = await prismaMock.membership.count({ - where: { - teamId: childTeam.id, - }, - }); - - expect(remainingMemberships).toBe(1); - - //Check if the event type has the correct hosts - const remainingHostsCount = await prismaMock.host.count({ - where: { - eventTypeId: 1, - }, - }); - - expect(remainingHostsCount).toBe(1); - }); - }); -}); diff --git a/yarn.lock b/yarn.lock index d6513632a03dc2..bd6a988cad2bd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,7 +2500,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.314" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3559,13 +3559,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.312": - version: 0.0.312 - resolution: "@calcom/platform-libraries@npm:0.0.312" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.314": + version: 0.0.314 + resolution: "@calcom/platform-libraries@npm:0.0.314" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 46520737404a61f3aa96c6f1adfa81e67f645fd984f495662ca7c63184b0310117a1dad57ddbe7ea487f2e5a0c9cbe62749a9a43b3197a021b19e3ccadc1f5b2 + checksum: b59eb591273334ed7d7d07a8f0518eb8cb40255883dff49626f85d3bd804db4da357bf77cd4f7b14ae0743d0a699647232b39956af9123bdc5e7b2458cab30eb languageName: node linkType: hard