From e67e073f04bfd5d3711d1af0d6293133749c2f01 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 28 Mar 2025 09:56:26 +0100 Subject: [PATCH] refactor: v2 team event type assignAllTeamMembers (#20400) * fix: platform seed data * fix: assignAllTeamMembers: true excludes team owners in platform * refactor: platform plan guard explicit error message * refactor: require either hosts or assignAllTeamMembers=true * refactor: require either hosts or assignAllTeamMembers=true * fix: before creating or updating team event type check if doesnt exist with that slug * chore: regenerate docs * test: can update round robin that had assign all team members true * fix: platform guard spec test * fix: seed platform membership role * fix: test ts error --- .../billing/platform-plan.guard.spec.ts | 17 +- .../guards/billing/platform-plan.guard.ts | 9 +- .../assign-all-team-members.e2e-spec.ts | 481 ++++++++++++++++++ .../organizations-event-types.e2e-spec.ts | 8 +- .../event-types/services/input.service.ts | 39 +- .../create-organization-membership.input.ts | 5 +- .../teams-event-types.controller.e2e-spec.ts | 4 +- .../teams-event-types.repository.ts | 11 + .../modules/teams/teams/teams.repository.ts | 18 +- apps/api/v2/swagger/documentation.json | 7 +- .../repository/hosts.repository.fixture.ts | 6 +- docs/api-reference/v2/openapi.json | 6 +- .../inputs/create-event-type.input.ts | 37 +- packages/prisma/seed.ts | 12 +- 14 files changed, 625 insertions(+), 35 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts index b4aea0a00a..93c67ee001 100644 --- a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.spec.ts @@ -2,6 +2,7 @@ import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.g import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { RedisService } from "@/modules/redis/redis.service"; import { createMock } from "@golevelup/ts-jest"; +import { ForbiddenException } from "@nestjs/common"; import { ExecutionContext } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; @@ -47,11 +48,11 @@ describe("PlatformPlanGuard", () => { await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); - it("should return false if the organization does not exist", async () => { + it("should throw ForbiddenException if the organization does not exist", async () => { jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null); - await expect(guard.canActivate(mockContext)).resolves.toBe(false); + await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException); }); it("should return true if the organization is not platform", async () => { @@ -66,7 +67,7 @@ describe("PlatformPlanGuard", () => { await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); - it("should return false if the organization has no subscription", async () => { + it("should throw ForbiddenException if the organization has no subscription", async () => { jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ isPlatform: true, @@ -78,10 +79,10 @@ describe("PlatformPlanGuard", () => { }, }); - await expect(guard.canActivate(mockContext)).resolves.toBe(false); + await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException); }); - it("should return false if the user has a lower plan than required", async () => { + it("should throw ForbiddenException if the user has a lower plan than required", async () => { jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({ isPlatform: true, @@ -93,7 +94,7 @@ describe("PlatformPlanGuard", () => { }, }); - await expect(guard.canActivate(mockContext)).resolves.toBe(false); + await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException); }); it("should return true if the result is cached in Redis", async () => { @@ -103,11 +104,11 @@ describe("PlatformPlanGuard", () => { await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); - it("should return false if the result is cached in Redis", async () => { + it("should throw ForbiddenException if the result is cached as false in Redis", async () => { jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS"); jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false)); - await expect(guard.canActivate(mockContext)).resolves.toBe(false); + await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException); }); function createMockExecutionContext(context: Record): ExecutionContext { diff --git a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts index 977e17d329..7231092297 100644 --- a/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/billing/platform-plan.guard.ts @@ -3,7 +3,7 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st import { PlatformPlanType } from "@/modules/billing/types"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { RedisService } from "@/modules/redis/redis.service"; -import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { Request } from "express"; @@ -55,7 +55,12 @@ export class PlatformPlanGuard implements CanActivate { } await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300); - return canAccess; + if (canAccess) { + return canAccess; + } + throw new ForbiddenException( + `Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.` + ); } } diff --git a/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts new file mode 100644 index 0000000000..57690f6f4f --- /dev/null +++ b/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts @@ -0,0 +1,481 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { + GetBookingsDataEntry, + GetBookingsOutput_2024_04_15, +} from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { Locales } from "@/lib/enums/locales"; +import { + CreateManagedUserData, + CreateManagedUserOutput, +} from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; +import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; +import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; +import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { + CAL_API_VERSION_HEADER, + SUCCESS_STATUS, + VERSION_2024_06_14, + X_CAL_CLIENT_ID, + X_CAL_SECRET_KEY, +} from "@calcom/platform-constants"; +import { + ApiSuccessResponse, + CreateEventTypeInput_2024_06_14, + CreateTeamEventTypeInput_2024_06_14, + EventTypeOutput_2024_06_14, + Host, + OrgTeamOutputDto, + TeamEventTypeOutput_2024_06_14, + UpdateTeamEventTypeInput_2024_06_14, +} from "@calcom/platform-types"; + +const CLIENT_REDIRECT_URI = "http://localhost:4321"; + +describe("Assign all team members", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const platformAdminEmail = `assign-all-team-members-admin-${randomString()}@api.com`; + let platformAdmin: User; + + let managedTeam: OrgTeamOutputDto; + + const managedUsersTimeZone = "Europe/Rome"; + const firstManagedUserEmail = `managed-user-bookings-2024-04-15-first-user@api.com`; + const secondManagedUserEmail = `managed-user-bookings-2024-04-15-second-user@api.com`; + let firstManagedUser: CreateManagedUserData; + let secondManagedUser: CreateManagedUserData; + + let roundRobinEventType: TeamEventTypeOutput_2024_06_14; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail }); + + organization = await teamRepositoryFixture.create({ + name: `oauth-client-users-organization-${randomString()}`, + isPlatform: true, + isOrganization: true, + platformBilling: { + create: { + customerId: "cus_999", + plan: "ESSENTIALS", + subscriptionId: "sub_999", + }, + }, + }); + oAuthClient = await createOAuthClient(organization.id); + + await profilesRepositoryFixture.create({ + uid: randomString(), + username: platformAdminEmail, + organization: { connect: { id: organization.id } }, + user: { + connect: { id: platformAdmin.id }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: platformAdmin.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 1023, + areDefaultEventTypesEnabled: false, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("setup managed users", () => { + it("should create first managed user", async () => { + const requestBody: CreateManagedUserInput = { + email: firstManagedUserEmail, + timeZone: managedUsersTimeZone, + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name: "Alice Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.refreshToken).toBeDefined(); + + firstManagedUser = responseBody.data; + }); + + it("should create second managed user", async () => { + const requestBody: CreateManagedUserInput = { + email: secondManagedUserEmail, + timeZone: managedUsersTimeZone, + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name: "Bob Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.refreshToken).toBeDefined(); + + secondManagedUser = responseBody.data; + }); + }); + + describe("should setup managed team", () => { + it("should create managed team", async () => { + const body: CreateOrgTeamDto = { + name: `team-${randomString()}`, + }; + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + managedTeam = responseBody.data; + }); + }); + }); + + describe("should setup memberships", () => { + it("should create first user's membership of the org's team", async () => { + const body: CreateOrgTeamMembershipDto = { + userId: firstManagedUser.user.id, + accepted: true, + role: "MEMBER", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/memberships`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + }); + }); + + it("should create second user's membership of the org's team", async () => { + const body: CreateOrgTeamMembershipDto = { + userId: secondManagedUser.user.id, + accepted: true, + role: "MEMBER", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/memberships`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + }); + }); + }); + + describe("should setup event types using assignAllTeamMembers true", () => { + it("should not be able to setup team event type if no hosts nor assignAllTeamMembers provided", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation round robin", + slug: `organizations-event-types-round-robin-${randomString()}`, + lengthInMinutes: 60, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + schedulingType: "collective", + }; + + const response = await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(400); + }); + + it("should setup collective event type assignAllTeamMembers true", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation collective", + slug: `assign-all-team-members-collective-${randomString()}`, + lengthInMinutes: 60, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + schedulingType: "collective", + assignAllTeamMembers: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(body.title); + expect(data.hosts.length).toEqual(2); + expect(data.schedulingType).toEqual("collective"); + evaluateHost({ userId: firstManagedUser.user.id }, data.hosts[0]); + evaluateHost({ userId: secondManagedUser.user.id }, data.hosts[1]); + + const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id); + expect(eventTypeHosts.length).toEqual(2); + const firstHost = eventTypeHosts.find((host) => host.userId === firstManagedUser.user.id); + const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id); + expect(firstHost).toBeDefined(); + expect(secondHost).toBeDefined(); + }); + }); + + it("should setup round robin event type assignAllTeamMembers true", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation round robin", + slug: `assign-all-team-members-round-robin-${randomString()}`, + lengthInMinutes: 60, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + schedulingType: "roundRobin", + assignAllTeamMembers: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(body.title); + expect(data.hosts.length).toEqual(2); + expect(data.schedulingType).toEqual("roundRobin"); + evaluateHost( + { userId: firstManagedUser.user.id, mandatory: false, priority: "medium" }, + data.hosts[0] + ); + evaluateHost( + { userId: secondManagedUser.user.id, mandatory: false, priority: "medium" }, + data.hosts[1] + ); + + const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id); + expect(eventTypeHosts.length).toEqual(2); + const firstHost = eventTypeHosts.find((host) => host.userId === firstManagedUser.user.id); + const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id); + expect(firstHost).toBeDefined(); + expect(secondHost).toBeDefined(); + roundRobinEventType = data; + }); + }); + + it("should update round robin event type", async () => { + const body: UpdateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation round robin updated", + }; + + return request(app.getHttpServer()) + .patch( + `/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types/${roundRobinEventType.id}` + ) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.title).toEqual(body.title); + expect(data.hosts.length).toEqual(2); + expect(data.schedulingType).toEqual("roundRobin"); + evaluateHost( + { userId: firstManagedUser.user.id, mandatory: false, priority: "medium" }, + data.hosts[0] + ); + evaluateHost( + { userId: secondManagedUser.user.id, mandatory: false, priority: "medium" }, + data.hosts[1] + ); + + const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id); + expect(eventTypeHosts.length).toEqual(2); + const firstHost = eventTypeHosts.find((host) => host.userId === firstManagedUser.user.id); + const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id); + expect(firstHost).toBeDefined(); + expect(secondHost).toBeDefined(); + }); + }); + + it("should setup managed event type assignAllTeamMembers true", async () => { + const body: CreateTeamEventTypeInput_2024_06_14 = { + title: "Coding consultation managed", + slug: `assign-all-team-members-managed-${randomString()}`, + lengthInMinutes: 60, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + schedulingType: "managed", + assignAllTeamMembers: true, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`) + .send(body) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(3); + + const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes( + firstManagedUser.user.id + ); + const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes( + secondManagedUser.user.id + ); + const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(managedTeam.id); + const managedTeamEventTypes = teamEventTypes.filter( + (eventType) => eventType.schedulingType === "MANAGED" + ); + + expect(teammate1EventTypes.length).toEqual(1); + expect(teammate2EventTypes.length).toEqual(1); + expect(managedTeamEventTypes.length).toEqual(1); + expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true); + + const responseTeamEvent = responseBody.data.find( + (eventType) => eventType.schedulingType === "managed" + ); + expect(responseTeamEvent).toBeDefined(); + expect(responseTeamEvent?.teamId).toEqual(managedTeam.id); + expect(responseTeamEvent?.assignAllTeamMembers).toEqual(true); + + const responseTeammate1Event = responseBody.data.find( + (eventType) => eventType.ownerId === firstManagedUser.user.id + ); + expect(responseTeammate1Event).toBeDefined(); + expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + + const responseTeammate2Event = responseBody.data.find( + (eventType) => eventType.ownerId === secondManagedUser.user.id + ); + expect(responseTeammate2Event).toBeDefined(); + expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id); + }); + }); + }); + + function evaluateHost(expected: Host, received: Host | undefined) { + expect(expected.userId).toEqual(received?.userId); + expect(expected.mandatory).toEqual(received?.mandatory); + expect(expected.priority).toEqual(received?.priority); + } + + afterAll(async () => { + await userRepositoryFixture.delete(firstManagedUser.user.id); + await userRepositoryFixture.delete(secondManagedUser.user.id); + await userRepositoryFixture.delete(platformAdmin.id); + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts index 025d3dc559..f98fe717f4 100644 --- a/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts @@ -325,8 +325,8 @@ describe("Organizations Event Types Endpoints", () => { expect(data.title).toEqual(body.title); expect(data.hosts.length).toEqual(2); expect(data.schedulingType).toEqual("collective"); - evaluateHost(body.hosts[0], data.hosts[0]); - evaluateHost(body.hosts[1], data.hosts[1]); + evaluateHost(body.hosts?.[0] || { userId: -1 }, data.hosts[0]); + evaluateHost(body.hosts?.[1] || { userId: -1 }, data.hosts[1]); expect(data.bookingLimitsCount).toEqual(body.bookingLimitsCount); expect(data.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); @@ -1046,8 +1046,8 @@ describe("Organizations Event Types Endpoints", () => { expect(data.title).toEqual(body.title); expect(data.hosts.length).toEqual(2); expect(data.schedulingType).toEqual("roundRobin"); - evaluateHost(body.hosts[0], data.hosts[0]); - evaluateHost(body.hosts[1], data.hosts[1]); + evaluateHost(body.hosts?.[0] || { userId: -1 }, data.hosts[0]); + evaluateHost(body.hosts?.[1] || { userId: -1 }, data.hosts[1]); expect(data.bookingLimitsCount).toEqual(body.bookingLimitsCount); expect(data.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); diff --git a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts index 67cbd7c34d..f3287cab05 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/input.service.ts @@ -33,6 +33,7 @@ export class InputOrganizationsEventTypesService { inputEventType: CreateTeamEventTypeInput_2024_06_14 ) { await this.validateHosts(teamId, inputEventType.hosts); + await this.validateTeamEventTypeSlug(teamId, inputEventType.slug); const transformedBody = await this.transformInputCreateTeamEventType(teamId, inputEventType); @@ -62,6 +63,9 @@ export class InputOrganizationsEventTypesService { inputEventType: UpdateTeamEventTypeInput_2024_06_14 ) { await this.validateHosts(teamId, inputEventType.hosts); + if (inputEventType.slug) { + await this.validateTeamEventTypeSlug(teamId, inputEventType.slug); + } const transformedBody = await this.transformInputUpdateTeamEventType(eventTypeId, teamId, inputEventType); @@ -85,10 +89,28 @@ export class InputOrganizationsEventTypesService { return transformedBody; } + async validateTeamEventTypeSlug(teamId: number, slug: string) { + const teamEventWithSlugExists = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug( + teamId, + slug + ); + + if (teamEventWithSlugExists) { + throw new BadRequestException("Team event type with this slug already exists"); + } + } + async transformInputCreateTeamEventType( teamId: number, inputEventType: CreateTeamEventTypeInput_2024_06_14 ) { + const hasHosts = !!inputEventType.hosts && !!inputEventType.hosts.length; + const hasAssignAllTeamMembers = inputEventType.assignAllTeamMembers === true; + + if (!hasHosts && !hasAssignAllTeamMembers) { + throw new BadRequestException("Either hosts must be provided or assignAllTeamMembers must be true"); + } + const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType; const eventType = this.inputEventTypesService.transformInputCreateEventType(rest); @@ -185,7 +207,7 @@ export class InputOrganizationsEventTypesService { eventType: { children: { userId: number | null }[] } | null ) { if (inputEventType.assignAllTeamMembers) { - return await this.teamsRepository.getTeamMembersIds(teamId); + return await this.getTeamUsersIds(teamId); } // note(Lauris): when API user updates managed event type users @@ -197,6 +219,17 @@ export class InputOrganizationsEventTypesService { return eventType?.children.map((child) => child.userId).filter((id) => !!id) as number[]; } + async getTeamUsersIds(teamId: number) { + const team = await this.teamsRepository.getById(teamId); + const isPlatformTeam = !!team?.createdByOAuthClientId; + if (isPlatformTeam) { + // note(Lauris): platform team creators have role "OWNER" but we don't want to assign them to team members marked as "assignAllTeamMembers: true" + // because they are not a managed user. + return await this.teamsRepository.getTeamManagedUsersIds(teamId); + } + return await this.teamsRepository.getTeamUsersIds(teamId); + } + transformInputTeamLocations(inputLocations: CreateTeamEventTypeInput_2024_06_14["locations"]) { return transformTeamLocationsApiToInternal(inputLocations); } @@ -217,7 +250,7 @@ export class InputOrganizationsEventTypesService { } async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) { - const membersIds = await this.teamsRepository.getTeamMembersIds(teamId); + const membersIds = await this.getTeamUsersIds(teamId); const isFixed = schedulingType === "COLLECTIVE" ? true : false; return membersIds.map((id) => ({ @@ -249,7 +282,7 @@ export class InputOrganizationsEventTypesService { async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) { if (hosts && hosts.length) { - const membersIds = await this.teamsRepository.getTeamMembersIds(teamId); + const membersIds = await this.getTeamUsersIds(teamId); const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId)); if (invalidHosts.length) { throw new NotFoundException( diff --git a/apps/api/v2/src/modules/organizations/memberships/inputs/create-organization-membership.input.ts b/apps/api/v2/src/modules/organizations/memberships/inputs/create-organization-membership.input.ts index 29d16c1f94..bcb0ac094e 100644 --- a/apps/api/v2/src/modules/organizations/memberships/inputs/create-organization-membership.input.ts +++ b/apps/api/v2/src/modules/organizations/memberships/inputs/create-organization-membership.input.ts @@ -13,7 +13,10 @@ export class CreateOrgMembershipDto { readonly accepted?: boolean = false; @IsEnum(MembershipRole) - @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) + @ApiProperty({ + enum: ["MEMBER", "OWNER", "ADMIN"], + description: "If you are platform customer then managed users should only have MEMBER role.", + }) readonly role: MembershipRole = MembershipRole.MEMBER; @IsOptional() diff --git a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts index caae397a1d..6cc8149493 100644 --- a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts @@ -304,8 +304,8 @@ describe("Organizations Event Types Endpoints", () => { expect(data.title).toEqual(body.title); expect(data.hosts.length).toEqual(2); expect(data.schedulingType).toEqual("collective"); - evaluateHost(body.hosts[0], data.hosts[0]); - evaluateHost(body.hosts[1], data.hosts[1]); + evaluateHost(body.hosts?.[0] || { userId: -1 }, data.hosts[0]); + evaluateHost(body.hosts?.[1] || { userId: -1 }, data.hosts[1]); expect(data.bookingLimitsCount).toEqual(body.bookingLimitsCount); expect(data.onlyShowFirstAvailableSlot).toEqual(body.onlyShowFirstAvailableSlot); expect(data.bookingLimitsDuration).toEqual(body.bookingLimitsDuration); diff --git a/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts b/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts index d72deb62bc..c3c9a8491d 100644 --- a/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts +++ b/apps/api/v2/src/modules/teams/event-types/teams-event-types.repository.ts @@ -49,6 +49,17 @@ export class TeamsEventTypesRepository { }); } + async getEventTypeByTeamIdAndSlug(teamId: number, eventTypeSlug: string) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + teamId_slug: { + teamId, + slug: eventTypeSlug, + }, + }, + }); + } + async getTeamEventTypes(teamId: number) { return this.dbRead.prisma.eventType.findMany({ where: { diff --git a/apps/api/v2/src/modules/teams/teams/teams.repository.ts b/apps/api/v2/src/modules/teams/teams/teams.repository.ts index d8f3dc9514..e131a6c053 100644 --- a/apps/api/v2/src/modules/teams/teams/teams.repository.ts +++ b/apps/api/v2/src/modules/teams/teams/teams.repository.ts @@ -30,7 +30,7 @@ export class TeamsRepository { }); } - async getTeamMembersIds(teamId: number) { + async getTeamUsersIds(teamId: number) { const teamMembers = await this.dbRead.prisma.membership.findMany({ where: { teamId, @@ -43,6 +43,22 @@ export class TeamsRepository { return teamMembers.map((member) => member.userId); } + async getTeamManagedUsersIds(teamId: number) { + const teamMembers = await this.dbRead.prisma.membership.findMany({ + where: { + teamId, + user: { + isPlatformManaged: true, + }, + }, + }); + if (!teamMembers || teamMembers.length === 0) { + return []; + } + + return teamMembers.map((member) => member.userId); + } + async getTeamsUserIsMemberOf(userId: number) { return this.dbRead.prisma.team.findMany({ where: { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 5f7d9a04cb..1c03ede7e2 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -12818,7 +12818,8 @@ "MEMBER", "OWNER", "ADMIN" - ] + ], + "description": "If you are platform customer then managed users should only have MEMBER role." }, "disableImpersonation": { "type": "boolean", @@ -13202,6 +13203,7 @@ "description": "The scheduling type for the team event - collective, roundRobin or managed." }, "hosts": { + "description": "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users.", "type": "array", "items": { "$ref": "#/components/schemas/Host" @@ -13248,8 +13250,7 @@ "lengthInMinutes", "title", "slug", - "schedulingType", - "hosts" + "schedulingType" ] }, "CreateTeamEventTypeOutput": { diff --git a/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts index 304fdf1415..87deef956e 100644 --- a/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/hosts.repository.fixture.ts @@ -1,8 +1,6 @@ -import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { TestingModule } from "@nestjs/testing"; -import { EventType } from "@prisma/client"; import { Prisma } from "@calcom/prisma/client"; @@ -22,4 +20,8 @@ export class HostsRepositoryFixture { }, }); } + + async getEventTypeHosts(eventTypeId: number) { + return this.prismaReadClient.host.findMany({ where: { eventTypeId } }); + } } diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 24eb6742c2..5a9813ac88 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -11699,7 +11699,8 @@ "role": { "type": "string", "default": "MEMBER", - "enum": ["MEMBER", "OWNER", "ADMIN"] + "enum": ["MEMBER", "OWNER", "ADMIN"], + "description": "If you are platform customer then managed users should only have MEMBER role." }, "disableImpersonation": { "type": "boolean", @@ -12036,6 +12037,7 @@ "description": "The scheduling type for the team event - collective, roundRobin or managed." }, "hosts": { + "description": "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users.", "type": "array", "items": { "$ref": "#/components/schemas/Host" @@ -12078,7 +12080,7 @@ } } }, - "required": ["lengthInMinutes", "title", "slug", "schedulingType", "hosts"] + "required": ["lengthInMinutes", "title", "slug", "schedulingType"] }, "CreateTeamEventTypeOutput": { "type": "object", diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts index aad9e55ede..dc3f793128 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts @@ -5,6 +5,7 @@ import { ApiExtraModels, } from "@nestjs/swagger"; import { Type, Transform, Expose } from "class-transformer"; +import type { ValidationOptions, ValidationArguments } from "class-validator"; import { IsString, IsInt, @@ -17,6 +18,7 @@ import { ValidateNested, ArrayNotEmpty, ArrayUnique, + registerDecorator, } from "class-validator"; import { SchedulingType } from "@calcom/platform-enums"; @@ -446,6 +448,32 @@ export class Host { priority?: keyof typeof HostPriority = "medium"; } +function RequireHostsOrAssignAllMembers(validationOptions?: ValidationOptions) { + return function (object: any) { + registerDecorator({ + name: "requireHostsOrAssignAllMembers", + target: object, + propertyName: "hosts OR assignAllTeamMembers", + options: validationOptions, + constraints: [], + validator: { + validate(_value: any, args: ValidationArguments) { + const obj = args.object as CreateTeamEventTypeInput_2024_06_14; + + const hasHosts = !!obj.hosts && !!obj.hosts.length; + const hasAssignAllTeamMembers = obj.assignAllTeamMembers === true; + + return hasHosts || hasAssignAllTeamMembers; + }, + defaultMessage(): string { + return "Either hosts OR assignAllTeamMembers set to true is required"; + }, + }, + }); + }; +} + +@RequireHostsOrAssignAllMembers() export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInput { @Transform(({ value }) => { if (value === "collective") { @@ -470,8 +498,13 @@ export class CreateTeamEventTypeInput_2024_06_14 extends BaseCreateEventTypeInpu @ValidateNested({ each: true }) @Type(() => Host) @IsArray() - @DocsProperty({ type: [Host] }) - hosts!: Host[]; + @IsOptional() + @DocsPropertyOptional({ + type: [Host], + description: + "Hosts contain specific team members you want to assign to this event type, but if you want to assign all team members, use `assignAllTeamMembers: true` instead and omit this field. For platform customers the hosts can include userIds only of managed users.", + }) + hosts?: Host[]; @IsBoolean() @IsOptional() diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 7d51c40a23..bb67c3e5a8 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -163,20 +163,22 @@ async function createPlatformAndSetupUser({ `šŸ‘¤ Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page šŸ‘‰ ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}` ); - const { role = MembershipRole.OWNER, username } = platformUser; + const { username } = platformUser; + + const membershipRole = MembershipRole.OWNER; if (!!team) { await associateUserAndOrg({ teamId: team.id, userId: platformUser.id, - role: role as MembershipRole, + role: membershipRole, username: user.username, }); await prisma.platformBilling.create({ data: { id: team?.id, - plan: "STARTER", + plan: "SCALE", customerId: "cus_123", subscriptionId: "sub_123", }, @@ -194,7 +196,7 @@ async function createPlatformAndSetupUser({ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWNtZSAiLCJwZXJtaXNzaW9ucyI6MTAyMywicmVkaXJlY3RVcmlzIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NDMyMSJdLCJib29raW5nUmVkaXJlY3RVcmkiOiIiLCJib29raW5nQ2FuY2VsUmVkaXJlY3RVcmkiOiIiLCJib29raW5nUmVzY2hlZHVsZVJlZGlyZWN0VXJpIjoiIiwiYXJlRW1haWxzRW5hYmxlZCI6dHJ1ZSwiaWF0IjoxNzE5NTk1ODA4fQ.L5_jSS14fcKLCD_9_DAOgtGd6lUSZlU5CEpCPaPt41I", }, }); - console.log(`\tšŸ‘¤ Added '${teamInput.name}' membership for '${username}' with role '${role}'`); + console.log(`\tšŸ‘¤ Added '${teamInput.name}' membership for '${username}' with role '${membershipRole}'`); } } @@ -961,7 +963,7 @@ async function main() { password: "PLATFORMadmin2024!", username: "platform", name: "Platform Admin", - role: "ADMIN", + role: "USER", }, });