diff --git a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx index 5c51b9b92524d7..a60f5342ed3fef 100644 --- a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx +++ b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx @@ -156,7 +156,7 @@ const AddNewTeamsFormChild = ({ orgId: org.id, moveTeams, teamNames: fields.map((field) => field.name), - creationSource: CreationSource.WEBAPP, + creationSource: CreationSource.WEBAPP_NEW_ORG, }); }; diff --git a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx index 4c4fb14959dfbd..562b512c4fedbe 100644 --- a/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx +++ b/packages/features/ee/organizations/components/CreateANewOrganizationForm.tsx @@ -108,7 +108,7 @@ const CreateANewOrganizationFormChild = ({ handleSubmit={(v) => { if (!createOrganizationMutation.isPending) { setServerErrorMessage(null); - createOrganizationMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP }); + createOrganizationMutation.mutate({ ...v, creationSource: CreationSource.WEBAPP_NEW_ORG }); } }}>
diff --git a/packages/features/ee/platform/components/CreateANewPlatformForm.tsx b/packages/features/ee/platform/components/CreateANewPlatformForm.tsx index 6f4b4365b67946..19a52448e173ed 100644 --- a/packages/features/ee/platform/components/CreateANewPlatformForm.tsx +++ b/packages/features/ee/platform/components/CreateANewPlatformForm.tsx @@ -92,7 +92,7 @@ const CreateANewPlatformFormChild = ({ session }: { session: Ensure diff --git a/packages/lib/server/repository/organization.test.ts b/packages/lib/server/repository/organization.test.ts index e23ffda402624d..8249e9de772b25 100644 --- a/packages/lib/server/repository/organization.test.ts +++ b/packages/lib/server/repository/organization.test.ts @@ -3,6 +3,7 @@ import prismock from "../../../../tests/libs/__mocks__/prisma"; import { describe, it, expect, beforeEach, vi } from "vitest"; import type { Prisma } from "@calcom/prisma/client"; +import { CreationSource, MembershipRole, WatchlistSeverity } from "@calcom/prisma/enums"; import { OrganizationRepository } from "./organization"; @@ -10,6 +11,14 @@ vi.mock("./teamUtils", () => ({ getParsedTeam: (org: any) => org, })); +vi.mock("@calcom/lib/server/i18n", () => { + return { + getTranslation: (key: string) => { + return () => key; + }, + }; +}); + async function createOrganization( data: Prisma.TeamCreateInput & { organizationSettings: { @@ -151,3 +160,113 @@ describe("Organization.getVerifiedOrganizationByAutoAcceptEmailDomain", () => { expect(result).toEqual(null); }); }); + +describe("Organization.createWithNonExistentOwner", () => { + it("should create organization with non-existent owner", async () => { + const orgData = { + name: "Test Org", + slug: "test-org", + isOrganizationConfigured: true, + isOrganizationAdminReviewed: true, + autoAcceptEmail: "test.com", + seats: 30, + pricePerSeat: 37, + isPlatform: false, + }; + + const createdOrg = await OrganizationRepository.createWithNonExistentOwner({ + orgData, + owner: { + email: "test@test.com", + }, + creationSource: CreationSource.WEBAPP_NEW_ORG, + }); + + expect(createdOrg) + .toHaveProperty("orgOwner") + .toHaveProperty("organization") + .toHaveProperty("ownerProfile"); + + const { orgOwner, organization } = createdOrg; + + const user = await prismock.user.findFirst({ + where: { + id: orgOwner.id, + }, + include: { + profiles: true, + teams: true, + }, + }); + + expect(user).toEqual( + expect.objectContaining({ + locked: false, + creationSource: CreationSource.WEBAPP_NEW_ORG, + }) + ); + + expect(user.profiles).toHaveLength(1); + expect(user.teams).toHaveLength(1); + + const userProfile = user.profiles[0]; + const userMembership = user.teams[0]; + + expect(userProfile).toEqual( + expect.objectContaining({ + organizationId: organization.id, + }) + ); + + expect(userMembership).toEqual( + expect.objectContaining({ + teamId: organization.id, + accepted: true, + role: MembershipRole.OWNER, + }) + ); + }); + + it("should lock the organizer if they are on the watchlist", async () => { + const orgData = { + name: "Test Org", + slug: "test-org", + isOrganizationConfigured: true, + isOrganizationAdminReviewed: true, + autoAcceptEmail: "test.com", + seats: 30, + pricePerSeat: 37, + isPlatform: false, + }; + + await prismock.watchlist.create({ + data: { + type: "EMAIL", + value: "test@test.com", + severity: WatchlistSeverity.CRITICAL, + createdById: 1, + }, + }); + + await OrganizationRepository.createWithNonExistentOwner({ + orgData, + owner: { + email: "test@test.com", + }, + creationSource: CreationSource.WEBAPP_NEW_ORG, + }); + + const user = await prismock.user.findFirst({ + where: { + email: "test@test.com", + }, + }); + + expect(user).toEqual( + expect.objectContaining({ + locked: true, + creationSource: CreationSource.WEBAPP_NEW_ORG, + }) + ); + }); +}); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 809c91df00eb3f..17dfc415d71b94 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -2,6 +2,7 @@ import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getO import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { UserCreationService } from "@calcom/lib/server/service/UserCreationService"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { CreationSource } from "@calcom/prisma/enums"; @@ -9,7 +10,6 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { createAProfileForAnExistingUser } from "../../createAProfileForAnExistingUser"; import { getParsedTeam } from "./teamUtils"; -import { UserRepository } from "./user"; const orgSelect = { id: true, @@ -86,20 +86,16 @@ export class OrganizationRepository { logger.debug("createWithNonExistentOwner", safeStringify({ orgData, owner })); const organization = await this.create(orgData); const ownerUsernameInOrg = getOrgUsernameFromEmail(owner.email, orgData.autoAcceptEmail); - const ownerInDb = await UserRepository.create({ - email: owner.email, - username: ownerUsernameInOrg, - organizationId: organization.id, - locked: false, - creationSource, - }); - - await prisma.membership.create({ + const ownerInDb = await UserCreationService.createUser({ data: { - userId: ownerInDb.id, + email: owner.email, + username: ownerUsernameInOrg, + creationSource, + }, + orgData: { + id: organization.id, role: MembershipRole.OWNER, accepted: true, - teamId: organization.id, }, }); diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 09d77ccee652bf..2949b4c30aa840 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -577,19 +577,25 @@ export class UserRepository { }); } - static async create( + static async create({ + data, + orgData, + }: { data: Omit & { username: string; hashedPassword?: string; - organizationId: number | null; creationSource: CreationSource; locked: boolean; - } - ) { - const organizationIdValue = data.organizationId; + }; + orgData?: { + id: number; + role: MembershipRole; + accepted: boolean; + }; + }) { const { email, username, creationSource, locked, ...rest } = data; - logger.info("create user", { email, username, organizationIdValue, locked }); + logger.info("create user", { email, username, orgId: orgData?.id, locked }); const t = await getTranslation("en", "common"); const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); @@ -615,16 +621,23 @@ export class UserRepository { }, creationSource, locked, - ...(organizationIdValue + ...(orgData ? { - organizationId: organizationIdValue, + organizationId: orgData.id, profiles: { create: { username, - organizationId: organizationIdValue, + organizationId: orgData.id, uid: ProfileRepository.generateProfileUid(), }, }, + teams: { + create: { + role: orgData.role, + accepted: orgData.accepted, + teamId: orgData.id, + }, + }, } : {}), ...rest, diff --git a/packages/lib/server/service/userCreationService.test.ts b/packages/lib/server/service/userCreationService.test.ts index 39e35fe1de3d8f..ae363fa788110e 100644 --- a/packages/lib/server/service/userCreationService.test.ts +++ b/packages/lib/server/service/userCreationService.test.ts @@ -4,7 +4,7 @@ import { describe, test, expect, vi, beforeEach } from "vitest"; import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; -import { CreationSource } from "@calcom/prisma/enums"; +import { CreationSource, MembershipRole } from "@calcom/prisma/enums"; import { UserRepository } from "../repository/user"; import { UserCreationService } from "./userCreationService"; @@ -58,9 +58,12 @@ describe("UserCreationService", () => { expect(UserRepository.create).toHaveBeenCalledWith( expect.objectContaining({ - username: "test", - locked: false, - organizationId: null, + data: { + email: "test@example.com", + username: "test", + creationSource: CreationSource.WEBAPP, + locked: false, + }, }) ); @@ -70,11 +73,19 @@ describe("UserCreationService", () => { test("should lock user when email is in watchlist", async () => { vi.mocked(checkIfEmailIsBlockedInWatchlistController).mockResolvedValue(true); + vi.spyOn(UserRepository, "create").mockResolvedValue({ + username: "test", + locked: true, + organizationId: null, + } as any); + const user = await UserCreationService.createUser({ data: mockUserData }); expect(UserRepository.create).toHaveBeenCalledWith( expect.objectContaining({ - locked: true, + data: expect.objectContaining({ + locked: true, + }), }) ); @@ -85,6 +96,12 @@ describe("UserCreationService", () => { const mockPassword = "password"; vi.mocked(hashPassword).mockResolvedValue("hashed_password"); + vi.spyOn(UserRepository, "create").mockResolvedValue({ + username: "test", + locked: true, + organizationId: null, + } as any); + const user = await UserCreationService.createUser({ data: { ...mockUserData, password: mockPassword }, }); @@ -92,10 +109,35 @@ describe("UserCreationService", () => { expect(hashPassword).toHaveBeenCalledWith(mockPassword); expect(UserRepository.create).toHaveBeenCalledWith( expect.objectContaining({ - hashedPassword: "hashed_password", + data: expect.objectContaining({ + hashedPassword: "hashed_password", + }), }) ); expect(user).not.toHaveProperty("locked"); }); + + test("if orgData is passed, user should be created with orgData", async () => { + vi.spyOn(UserRepository, "create").mockResolvedValue({ + username: "test", + locked: true, + organizationId: null, + } as any); + + const user = await UserCreationService.createUser({ + data: mockUserData, + orgData: { id: 1, role: MembershipRole.OWNER, accepted: true }, + }); + + expect(UserRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + orgData: expect.objectContaining({ + id: 1, + role: MembershipRole.OWNER, + accepted: true, + }), + }) + ); + }); }); diff --git a/packages/lib/server/service/userCreationService.ts b/packages/lib/server/service/userCreationService.ts index 5056965348511d..8b992b9aed12fd 100644 --- a/packages/lib/server/service/userCreationService.ts +++ b/packages/lib/server/service/userCreationService.ts @@ -1,7 +1,12 @@ import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; import logger from "@calcom/lib/logger"; -import type { CreationSource, UserPermissionRole, IdentityProvider } from "@calcom/prisma/enums"; +import type { + CreationSource, + UserPermissionRole, + IdentityProvider, + MembershipRole, +} from "@calcom/prisma/enums"; import slugify from "../../slugify"; import { UserRepository } from "../repository/user"; @@ -20,17 +25,22 @@ interface CreateUserInput { timeFormat?: number; locale?: string; avatar?: string; - organizationId?: number | null; creationSource: CreationSource; role?: UserPermissionRole; emailVerified?: Date; identityProvider?: IdentityProvider; } +interface OrgData { + id: number; + role: MembershipRole; + accepted: boolean; +} + const log = logger.getSubLogger({ prefix: ["[userCreationService]"] }); export class UserCreationService { - static async createUser({ data }: { data: CreateUserInput }) { + static async createUser({ data, orgData }: { data: CreateUserInput; orgData?: OrgData }) { const { email, password, username } = data; const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController(email); @@ -38,11 +48,13 @@ export class UserCreationService { const hashedPassword = password ? await hashPassword(password) : null; const user = await UserRepository.create({ - ...data, - username: slugify(username), - ...(hashedPassword && { hashedPassword }), - organizationId: data?.organizationId ?? null, - locked: shouldLockByDefault, + data: { + ...data, + username: slugify(username), + ...(hashedPassword && { hashedPassword }), + locked: shouldLockByDefault, + }, + ...(orgData ? { orgData } : {}), }); log.info(`Created user: ${user.id} with locked status of ${user.locked}`); diff --git a/packages/prisma/migrations/20250210032716_add_new_org_creation_source/migration.sql b/packages/prisma/migrations/20250210032716_add_new_org_creation_source/migration.sql new file mode 100644 index 00000000000000..13141e7b1ddf06 --- /dev/null +++ b/packages/prisma/migrations/20250210032716_add_new_org_creation_source/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "CreationSource" ADD VALUE 'webapp_new_org'; +ALTER TYPE "CreationSource" ADD VALUE 'api_v2_new_org'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 38d02202f29d07..5127d008dfe6d7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -47,6 +47,8 @@ enum CreationSource { API_V2 @map("api_v2") WEBAPP @map("webapp") SELF_SERVE_ADMIN @map("self_serve_admin") + WEBAPP_NEW_ORG @map("webapp_new_org") + API_V2_NEW_ORG @map("api_v2_new_org") } model Host {