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:
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 };
|
||||
|
||||
+320
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user