diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 451091d837..9012d15fcd 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -70,6 +70,15 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { }); try { + logger.info( + safeStringify({ + orgId: organizationOnboarding.organizationId, + orgSlug: organizationOnboarding.slug, + isDomainConfigured: organizationOnboarding.isDomainConfigured, + createdAt: organizationOnboarding.createdAt, + stripeSubscriptionId: organizationOnboarding.stripeSubscriptionId, + }) + ); const { organization } = await createOrganizationFromOnboarding({ organizationOnboarding, paymentSubscriptionId, diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts index 2923a02561..4bdd84e916 100644 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts +++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts @@ -106,7 +106,7 @@ async function createTestTeam(data: { name: string; slug: string }) { async function createTestOrganizationOnboarding( data?: Partial & { organizationId?: number | null } ) { - console.log("Creating organization onboarding", data); + console.log("TEST: Creating organization onboarding", data); return prismock.organizationOnboarding.create({ data: { ...mockOrganizationOnboarding, diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts index 55bbb35561..0c9b767ad8 100644 --- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts +++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts @@ -98,7 +98,7 @@ async function createOrganizationWithExistingUserAsOwner({ let organization = orgData.id ? await OrganizationRepository.findById({ id: orgData.id }) : null; if (organization) { - log.debug( + log.info( `Reusing existing organization:`, safeStringify({ slug: orgData.slug, @@ -122,7 +122,7 @@ async function createOrganizationWithExistingUserAsOwner({ // In this case we create the organization with slug=null. Let the teams migrate and then the conflict would go away. const canSetSlug = slugConflictType === "teamUserIsMemberOfExists" ? false : true; - log.debug( + log.info( `Creating organization for owner ${owner.email} with slug ${orgData.slug} and canSetSlug=${canSetSlug}` ); @@ -191,7 +191,7 @@ async function createOrganizationWithNonExistentUserAsOwner({ : await OrganizationRepository.findBySlug({ slug: orgData.slug }); if (organization) { - log.debug( + log.info( `createOrganizationWithNonExistentUserAsOwner: Reusing existing organization:`, safeStringify({ slug: orgData.slug, id: organization.id }) ); @@ -259,7 +259,7 @@ async function createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, o shouldMove: true, })); - log.debug( + log.info( `Creating ${teamsToCreate} teams and moving ${teamsToMove.map( (team) => team.newSlug )} teams for organization ${organizationId}` @@ -280,7 +280,7 @@ async function createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, o }, }); - log.debug( + log.info( `Created ${teamsToCreate.length} teams and moved ${teamsToMove.length} teams for organization ${organizationId}` ); } @@ -288,7 +288,7 @@ async function createOrMoveTeamsToOrganization(teams: TeamData[], owner: User, o async function inviteMembers(invitedMembers: InvitedMember[], organization: Team) { if (invitedMembers.length === 0) return; - log.debug(`Inviting ${invitedMembers.length} members to organization ${organization.id}`); + log.info(`Inviting ${invitedMembers.length} members to organization ${organization.id}`); await inviteMembersWithNoInviterPermissionCheck({ inviterName: null, teamId: organization.id, @@ -407,7 +407,10 @@ async function handleOrganizationCreation({ bio: organizationOnboarding.bio, }; - log.debug("handleOrganizationCreation", safeStringify({ orgData })); + log.info( + "handleOrganizationCreation", + safeStringify({ orgId: organizationOnboarding.organizationId, orgSlug: organizationOnboarding.slug }) + ); if (!owner) { const result = await createOrganizationWithNonExistentUserAsOwner({ email: organizationOnboarding.orgOwnerEmail, @@ -465,6 +468,13 @@ export const createOrganizationFromOnboarding = async ({ paymentSubscriptionId: string; paymentSubscriptionItemId: string; }) => { + log.info( + "createOrganizationFromOnboarding", + safeStringify({ + orgId: organizationOnboarding.organizationId, + orgSlug: organizationOnboarding.slug, + }) + ); if ( await hasConflictingOrganization({ slug: organizationOnboarding.slug, @@ -501,11 +511,18 @@ export const createOrganizationFromOnboarding = async ({ // If the organization was created with slug=null, then set the slug now, assuming that the team having the same slug is migrated now // If the team wasn't owned by the orgOwner, then org creation would have failed and we wouldn't be here if (!organization.slug) { - const { slug } = await OrganizationRepository.setSlug({ - id: organization.id, - slug: organizationOnboarding.slug, - }); - organization.slug = slug; + try { + const { slug } = await OrganizationRepository.setSlug({ + id: organization.id, + slug: organizationOnboarding.slug, + }); + organization.slug = slug; + } catch (error) { + // Almost always the reason would be that the organization's slug conflicts with a team's slug + // The owner might not have chosen the conflicting team for migration - Can be confirmed by checking `teams` column in the database. + log.error("RecoverableError: Error while setting slug for organization", safeStringify(error)); + throw new Error("Unable to set slug for organization"); + } } return { organization, owner }; 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 new file mode 100644 index 0000000000..3cc7257308 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts @@ -0,0 +1,320 @@ +import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, beforeEach } from "vitest"; + +import slugify from "@calcom/lib/slugify"; +import { MembershipRole, UserPermissionRole, CreationSource } from "@calcom/prisma/enums"; + +import { createTeamsHandler } from "../createTeams.handler"; + +// Helper functions for creating test data +async function createTestUser(data: { + email: string; + name?: string; + username?: string; + role?: UserPermissionRole; + organizationId?: number | null; +}) { + return prismock.user.create({ + data: { + email: data.email, + name: data.name || "Test User", + username: data.username || "testuser", + role: data.role || UserPermissionRole.USER, + organizationId: data.organizationId, + }, + }); +} + +async function createTestTeam(data: { + name: string; + slug?: string | null; + parentId?: number | null; + metadata?: Record; + isPlatform?: boolean; +}) { + return prismock.team.create({ + data: { + name: data.name, + slug: data.slug || slugify(data.name), + parentId: data.parentId, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + metadata: data.metadata || {}, + isPlatform: data.isPlatform ?? false, + }, + }); +} + +async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) { + return prismock.membership.create({ + data: { + userId: data.userId, + teamId: data.teamId, + role: data.role || MembershipRole.MEMBER, + accepted: true, + }, + }); +} + +type CreateScenarioOptions = { + organization?: { + name: string; + slug?: string; + metadata?: Record; + }; + organizationOwner?: { + email?: string; + role?: UserPermissionRole; + }; + teams?: Array<{ + name: string; + slug?: string; + metadata?: Record; + addToParentId: "createdOrganization" | number | null; + }>; +}; + +async function createScenario(options: CreateScenarioOptions = {}) { + // Create organization owner + const owner = await createTestUser({ + email: options.organizationOwner?.email || "owner@example.com", + role: options.organizationOwner?.role || UserPermissionRole.USER, + }); + + // Create organization + const organization = await createTestTeam({ + name: options.organization?.name || "Test Organization", + slug: options.organization?.slug, + metadata: options.organization?.metadata || { requestedSlug: "test-org" }, + }); + + // Set owner's organizationId + await prismock.user.update({ + where: { id: owner.id }, + data: { organizationId: organization.id }, + }); + + // Create organization membership for owner + await createTestMembership({ + userId: owner.id, + teamId: organization.id, + role: MembershipRole.OWNER, + }); + + // Create teams if specified + const teams: Awaited>[] = []; + if (options.teams) { + for (const team of options.teams) { + const createdTeam = await createTestTeam({ + name: team.name, + slug: team.slug, + parentId: team.addToParentId === "createdOrganization" ? organization.id : team.addToParentId, + metadata: team.metadata, + }); + teams.push(createdTeam); + } + } + + return { + owner, + organization, + teams, + }; +} + +describe("createTeams handler", () => { + beforeEach(async () => { + // @ts-expect-error reset is a method on Prismock + await prismock.reset(); + }); + + it("should create teams successfully", async () => { + const { owner, organization } = await createScenario(); + + const result = await createTeamsHandler({ + ctx: { + user: { + id: owner.id, + organizationId: organization.id, + }, + }, + input: { + teamNames: ["Team 1", "Team 2"], + orgId: organization.id, + moveTeams: [], + creationSource: CreationSource.WEBAPP, + }, + }); + + expect(result).toEqual({ duplicatedSlugs: [] }); + + // Verify teams were created + const createdTeams = await prismock.team.findMany({ + where: { parentId: organization.id }, + }); + + expect(createdTeams).toHaveLength(2); + expect(createdTeams[0].name).toBe("Team 1"); + expect(createdTeams[0].slug).toBe("team-1"); + expect(createdTeams[1].name).toBe("Team 2"); + expect(createdTeams[1].slug).toBe("team-2"); + }); + + it("should handle creation of team in Organization that has same slug as a team already in the same org", async () => { + const { owner, organization } = await createScenario({ + teams: [{ name: "Team 1", slug: "team-1", addToParentId: "createdOrganization" }], + }); + + const result = await createTeamsHandler({ + ctx: { + user: { + id: owner.id, + organizationId: organization.id, + }, + }, + input: { + // Team 1 -> team-1 slug + // Team 2 -> team-2 slug + teamNames: ["Team 1", "Team 2"], + orgId: organization.id, + moveTeams: [], + creationSource: CreationSource.WEBAPP, + }, + }); + + // Because team-1 already exists in org, it will not be created again. + expect(result).toEqual({ duplicatedSlugs: ["team-1"] }); + + // Verify only non-duplicate team was created + const createdTeams = await prismock.team.findMany({ + where: { parentId: organization.id }, + }); + + expect(createdTeams).toHaveLength(2); // Original team + Team 2 + expect(createdTeams.map((t) => t.name).sort()).toEqual(["Team 1", "Team 2"]); + }); + + it("should move teams to organization successfully", async () => { + const { owner, organization, teams } = await createScenario({ + teams: [ + { name: "Team To Move 1", slug: "team-to-move-1", addToParentId: "createdOrganization" }, + { name: "Team To Move 2", slug: "team-to-move-2", addToParentId: "createdOrganization" }, + ], + }); + + const result = await createTeamsHandler({ + ctx: { + user: { + id: owner.id, + organizationId: organization.id, + }, + }, + input: { + teamNames: [], + orgId: organization.id, + moveTeams: teams.map((team) => ({ + id: team.id, + shouldMove: true, + newSlug: `moved-${team.slug}`, + })), + creationSource: CreationSource.WEBAPP, + }, + }); + + expect(result).toEqual({ duplicatedSlugs: [] }); + + // Verify teams were moved + const allTeams = await prismock.team.findMany({ + where: { parentId: organization.id }, + }); + + expect(allTeams).toHaveLength(2); + expect(allTeams[0].slug).toBe("moved-team-to-move-1"); + expect(allTeams[1].slug).toBe("moved-team-to-move-2"); + }); + + it("should ignore empty team names, for creation", async () => { + const { owner, organization } = await createScenario(); + + const result = await createTeamsHandler({ + ctx: { + user: { + id: owner.id, + organizationId: organization.id, + }, + }, + input: { + teamNames: ["", " ", "Team 1"], + orgId: organization.id, + moveTeams: [], + creationSource: CreationSource.WEBAPP, + }, + }); + + expect(result).toEqual({ duplicatedSlugs: [] }); + + // Verify only non-empty team was created + const createdTeams = await prismock.team.findMany({ + where: { parentId: organization.id }, + }); + + expect(createdTeams).toHaveLength(1); + expect(createdTeams[0].name).toBe("Team 1"); + }); + + it("should not move teams belonging to a platform organization", async () => { + // First create a platform organization + const platformOrg = await createTestTeam({ + name: "Platform Organization", + slug: "platform-org", + metadata: {}, + isPlatform: true, + }); + + const { owner, organization, teams } = await createScenario({ + teams: [ + { name: "Regular Team", addToParentId: "createdOrganization" }, + { name: "Platform Team", addToParentId: platformOrg.id }, + ], + }); + + const result = await createTeamsHandler({ + ctx: { + user: { + id: owner.id, + organizationId: organization.id, + }, + }, + input: { + teamNames: [], + orgId: organization.id, + moveTeams: teams.map((team) => ({ + id: team.id, + shouldMove: true, + newSlug: `moved-${team.slug}`, + })), + creationSource: CreationSource.WEBAPP, + }, + }); + + expect(result).toEqual({ duplicatedSlugs: [] }); + + // Verify only non-platform team was moved + const movedTeams = await prismock.team.findMany({ + where: { parentId: organization.id }, + }); + + expect(movedTeams).toHaveLength(1); + expect(movedTeams[0].slug).toBe("moved-regular-team"); + + // Verify platform team was not moved + const platformTeam = await prismock.team.findFirst({ + where: { name: "Platform Team" }, + include: { parent: true }, + }); + + expect(platformTeam?.parent?.isPlatform).toBe(true); + expect(platformTeam?.slug).toBe("platform-team"); // Original slug unchanged + }); +}); diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index 87607b92ed..3436d59540 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -193,6 +193,12 @@ async function moveTeam({ id: true, slug: true, metadata: true, + parent: { + select: { + id: true, + isPlatform: true, + }, + }, members: { select: { role: true, @@ -214,19 +220,39 @@ async function moveTeam({ }); } - log.debug("Moving team", safeStringify({ teamId, newSlug, org, oldSlug: team.slug })); + if (team.parent?.isPlatform) { + log.info( + "Team belongs to a platform organization. Not moving to regular organization.", + safeStringify({ teamId, newSlug, org, oldSlug: team.slug, platformOrgId: team.parent.id }) + ); + return; + } + log.info("Moving team", safeStringify({ teamId, newSlug, oldSlug: team.slug })); newSlug = newSlug ?? team.slug; const orgMetadata = teamMetadataSchema.parse(org.metadata); - await prisma.team.update({ - where: { - id: teamId, - }, - data: { - slug: newSlug, - parentId: org.id, - }, - }); + try { + await prisma.team.update({ + where: { + id: teamId, + }, + data: { + slug: newSlug, + parentId: org.id, + }, + }); + } catch (error) { + log.error( + "Error while moving team to organization", + safeStringify(error), + safeStringify({ + teamId, + newSlug, + 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) => ({