fix: Org onboarding handle error due to platform team being moved to regular org (#20786)

* Add better error logs and handle platform team

* Add better error logs and handle platform team
This commit is contained in:
Hariom Balhara
2025-04-21 14:58:55 +05:30
committed by GitHub
parent f6c7d8d97f
commit 396811b954
5 changed files with 395 additions and 23 deletions
@@ -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,
@@ -106,7 +106,7 @@ async function createTestTeam(data: { name: string; slug: string }) {
async function createTestOrganizationOnboarding(
data?: Partial<typeof mockOrganizationOnboarding> & { organizationId?: number | null }
) {
console.log("Creating organization onboarding", data);
console.log("TEST: Creating organization onboarding", data);
return prismock.organizationOnboarding.create({
data: {
...mockOrganizationOnboarding,
@@ -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 };
@@ -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<string, unknown>;
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<string, unknown>;
};
organizationOwner?: {
email?: string;
role?: UserPermissionRole;
};
teams?: Array<{
name: string;
slug?: string;
metadata?: Record<string, unknown>;
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<ReturnType<typeof createTestTeam>>[] = [];
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
});
});
@@ -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) => ({