From 41cde8e3843d9d76e545cf96c9fe73b1b3d17249 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 16 Jul 2024 06:50:46 -0400 Subject: [PATCH] feat: API org user scope (#15739) * Init organizations users GET endpoint * Fix get users endpoint * Add create user input * Add POST to organization user endpoint * Creating a user make email mandatory * Move DTOs out of platform types * Use plainToInstance for data filtering * POST create user and org membership * Add email check * Filter update user call * Add getOrg decorator to endpoints * Add update endpoint * Add delete endpoint * Fix merge changes to create new org user * Init tests * Send org signup email * Abstract username check * Implement email service * WIP E2E test * Rename methods * Update update org user DTO * Remove unused inputs * chore: add ApiProperty decorator to users class validators * Type fix * Update tests * chore: code review comments * chore: code review comments * chore: code review comments --------- Co-authored-by: Morgan Vernay Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- apps/api/v2/src/modules/email/email.module.ts | 11 + .../api/v2/src/modules/email/email.service.ts | 32 ++ .../users/organizations-users.controller.ts | 115 +++++ .../users/organizations-users.e2e-spec.ts | 347 ++++++++++++++ .../inputs/create-organization-user.input.ts | 17 + .../inputs/get-organization-users.input.ts | 3 + .../inputs/update-organization-user.input.ts | 3 + .../organizations/organizations.module.ts | 20 +- .../outputs/get-organization-users.output.ts | 20 + .../organizations-users.repository.ts | 76 +++ .../services/organizations-users-service.ts | 118 +++++ .../modules/users/inputs/create-user.input.ts | 131 +++++ .../modules/users/inputs/get-users.input.ts | 19 + .../modules/users/inputs/update-user.input.ts | 4 + .../modules/users/outputs/get-users.output.ts | 239 ++++++++++ .../users/validators/avatarValidator.ts | 11 + .../users/validators/isEmailStringOrArray.ts | 24 + .../users/validators/localeValidator.ts | 39 ++ .../users/validators/themeValidator.ts | 17 + .../users/validators/timeFormatValidator.ts | 17 + .../users/validators/timeZoneValidator.ts | 18 + .../users/validators/weekdayValidator.ts | 16 + apps/api/v2/swagger/documentation.json | 449 ++++++++++++++++++ 23 files changed, 1743 insertions(+), 3 deletions(-) create mode 100644 apps/api/v2/src/modules/email/email.module.ts create mode 100644 apps/api/v2/src/modules/email/email.service.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts create mode 100644 apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts create mode 100644 apps/api/v2/src/modules/organizations/services/organizations-users-service.ts create mode 100644 apps/api/v2/src/modules/users/inputs/create-user.input.ts create mode 100644 apps/api/v2/src/modules/users/inputs/get-users.input.ts create mode 100644 apps/api/v2/src/modules/users/inputs/update-user.input.ts create mode 100644 apps/api/v2/src/modules/users/outputs/get-users.output.ts create mode 100644 apps/api/v2/src/modules/users/validators/avatarValidator.ts create mode 100644 apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts create mode 100644 apps/api/v2/src/modules/users/validators/localeValidator.ts create mode 100644 apps/api/v2/src/modules/users/validators/themeValidator.ts create mode 100644 apps/api/v2/src/modules/users/validators/timeFormatValidator.ts create mode 100644 apps/api/v2/src/modules/users/validators/timeZoneValidator.ts create mode 100644 apps/api/v2/src/modules/users/validators/weekdayValidator.ts diff --git a/apps/api/v2/src/modules/email/email.module.ts b/apps/api/v2/src/modules/email/email.module.ts new file mode 100644 index 0000000000..f8e63b2b93 --- /dev/null +++ b/apps/api/v2/src/modules/email/email.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from "@nestjs/common"; + +import { EmailService } from "./email.service"; + +@Global() +@Module({ + imports: [], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/apps/api/v2/src/modules/email/email.service.ts b/apps/api/v2/src/modules/email/email.service.ts new file mode 100644 index 0000000000..a05965b8af --- /dev/null +++ b/apps/api/v2/src/modules/email/email.service.ts @@ -0,0 +1,32 @@ +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.18"; + +@Injectable() +export class EmailService { + public async sendSignupToOrganizationEmail({ + usernameOrEmail, + orgName, + orgId, + locale, + inviterName, + }: { + usernameOrEmail: string; + orgName: string; + orgId: number; + locale: string | null; + inviterName: string; + }) { + const translation = await getTranslation(locale || "en", "common"); + + await sendSignupToOrganizationEmail({ + usernameOrEmail, + team: { name: orgName, parent: null }, + inviterName: inviterName, + isOrg: true, + teamId: orgId, + translation, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts new file mode 100644 index 0000000000..d3dcd572f0 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts @@ -0,0 +1,115 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; +import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input"; +import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input"; +import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input"; +import { GetOrganizationUsersOutput } from "@/modules/organizations/outputs/get-organization-users.output"; +import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output"; +import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service"; +import { GetUserOutput } from "@/modules/users/outputs/get-users.output"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + UseGuards, + Get, + Post, + Patch, + Delete, + Param, + ParseIntPipe, + Body, + UseInterceptors, + Query, +} from "@nestjs/common"; +import { ClassSerializerInterceptor } from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { Team } from "@calcom/prisma/client"; + +@Controller({ + path: "/v2/organizations/:orgId/users", + version: API_VERSIONS_VALUES, +}) +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) +@UseGuards(IsOrgGuard) +@DocsTags("Organizations Users") +export class OrganizationsUsersController { + constructor(private readonly organizationsUsersService: OrganizationsUsersService) {} + + @Get() + @Roles("ORG_ADMIN") + async getOrganizationsUsers( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() query: GetOrganizationsUsersInput + ): Promise { + const users = await this.organizationsUsersService.getUsers( + orgId, + query.emails, + query.skip ?? 0, + query.take ?? 250 + ); + + return { + status: SUCCESS_STATUS, + data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })), + }; + } + + @Post() + @Roles("ORG_ADMIN") + async createOrganizationUser( + @Param("orgId", ParseIntPipe) orgId: number, + @GetOrg() org: Team, + @Body() input: CreateOrganizationUserInput, + @GetUser() inviter: UserWithProfile + ): Promise { + const user = await this.organizationsUsersService.createUser( + org, + input, + inviter.name ?? inviter.username ?? inviter.email + ); + return { + status: SUCCESS_STATUS, + data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }), + }; + } + + @Patch("/:userId") + @Roles("ORG_ADMIN") + @UseGuards(IsUserInOrg) + async updateOrganizationUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number, + @GetOrg() org: Team, + @Body() input: UpdateOrganizationUserInput + ): Promise { + const user = await this.organizationsUsersService.updateUser(orgId, userId, input); + return { + status: SUCCESS_STATUS, + data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }), + }; + } + + @Delete("/:userId") + @Roles("ORG_ADMIN") + @UseGuards(IsUserInOrg) + async deleteOrganizationUser( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("userId", ParseIntPipe) userId: number + ): Promise { + const user = await this.organizationsUsersService.deleteUser(orgId, userId); + return { + status: SUCCESS_STATUS, + data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts new file mode 100644 index 0000000000..2dad8a9e5b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts @@ -0,0 +1,347 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { EmailService } from "@/modules/email/email.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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 { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { User, Team, Membership } from "@calcom/prisma/client"; + +describe("Organizations Users Endpoints", () => { + describe("Member role", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const userEmail = "member1@org.com"; + let user: User; + let org: Team; + let membership: Membership; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: "Test org 3", + isOrganization: true, + }); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should not be able to find org users", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`).expect(403); + }); + + it("should not be able to create a new org user", async () => { + return request(app.getHttpServer()).post(`/v2/organizations/${org.id}/users`).expect(403); + }); + + it("should not be able to update an org user", async () => { + return request(app.getHttpServer()).patch(`/v2/organizations/${org.id}/users/${user.id}`).expect(403); + }); + + it("should not be able to delete an org user", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/users/${user.id}`).expect(403); + }); + + afterAll(async () => { + // await membershipFixtures.delete(membership.id); + await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + + await app.close(); + }); + }); + describe("Admin role", () => { + let app: INestApplication; + let profileRepositoryFixture: ProfileRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + + const userEmail = "admin1@org.com"; + const nonMemberEmail = "non-member@test.com"; + let user: User; + let org: Team; + let createdUser: User; + + const orgMembersData = [ + { + email: "member1@org.com", + username: "member1@org.com", + }, + { + email: "member2@org.com", + username: "member2@org.com", + }, + { + email: "member3@org.com", + username: "member3@org.com", + }, + ]; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: "Test org 2", + isOrganization: true, + }); + + await userRepositoryFixture.create({ + email: nonMemberEmail, + username: "non-member", + }); + + const orgMembers = await Promise.all( + orgMembersData.map((member) => + userRepositoryFixture.create({ + email: member.email, + username: member.username, + organization: { connect: { id: org.id } }, + }) + ) + ); + // create profiles of orgMember like they would be when being invied to the org + await Promise.all( + orgMembers.map((member) => + profileRepositoryFixture.create({ + uid: `usr-${member.id}`, + username: member.username ?? `usr-${member.id}`, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: member.id, + }, + }, + }) + ) + ); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + await Promise.all( + orgMembers.map((member) => membershipFixtures.addUserToOrg(member, org, "MEMBER", true)) + ); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all org users", async () => { + const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(4); + + expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0); + }); + + it("should only get users with the specified email", async () => { + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + emails: userEmail, + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(1); + + expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1); + }); + + it("should get users within the specified emails array", async () => { + const orgMemberEmail = orgMembersData[0].email; + + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + emails: [userEmail, orgMemberEmail], + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(2); + + expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1); + expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1); + }); + + it("should update an org user", async () => { + const { body } = await request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${user.id}`) + .send({ + theme: "light", + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data as User; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.theme).toBe("light"); + }); + + it("should create a new org user", async () => { + const newOrgUser = { + email: "new-org-member-b@org.com", + organizationRole: "MEMBER", + autoAccept: true, + }; + + const emailSpy = jest + .spyOn(EmailService.prototype, "sendSignupToOrganizationEmail") + .mockImplementation(() => Promise.resolve()); + const { body } = await request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users`) + .send({ + email: newOrgUser.email, + }) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.email).toBe(newOrgUser.email); + expect(emailSpy).toHaveBeenCalledWith({ + usernameOrEmail: newOrgUser.email, + orgName: org.name, + orgId: org.id, + inviterName: "admin1@org.com", + locale: null, + }); + createdUser = userData; + }); + + it("should delete an org user", async () => { + const { body } = await request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/users/${createdUser.id}`) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data as User; + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.id).toBe(createdUser.id); + }); + + afterAll(async () => { + // await membershipFixtures.delete(membership.id); + await Promise.all([ + userRepositoryFixture.deleteByEmail(user.email), + userRepositoryFixture.deleteByEmail(nonMemberEmail), + ...orgMembersData.map((member) => userRepositoryFixture.deleteByEmail(member.email)), + ]); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts new file mode 100644 index 0000000000..b2bd5cf390 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-user.input.ts @@ -0,0 +1,17 @@ +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { MembershipRole } from "@prisma/client"; +import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator"; + +export class CreateOrganizationUserInput extends CreateUserInput { + @IsOptional() + @IsString() + locale = "en"; + + @IsOptional() + @IsEnum(MembershipRole) + organizationRole: MembershipRole = MembershipRole.MEMBER; + + @IsOptional() + @IsBoolean() + autoAccept = true; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts b/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts new file mode 100644 index 0000000000..6dfc79f57e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/get-organization-users.input.ts @@ -0,0 +1,3 @@ +import { GetUsersInput } from "@/modules/users/inputs/get-users.input"; + +export class GetOrganizationsUsersInput extends GetUsersInput {} diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts new file mode 100644 index 0000000000..1cba9543ca --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-user.input.ts @@ -0,0 +1,3 @@ +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; + +export class UpdateOrganizationUserInput extends UpdateUserInput {} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 78b27d2ddd..0092364b84 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -1,12 +1,17 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; +import { EmailModule } from "@/modules/email/email.module"; +import { EmailService } from "@/modules/email/email.service"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller"; import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller"; +import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository"; import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository"; import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service"; import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service"; import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; @@ -15,7 +20,7 @@ import { UsersModule } from "@/modules/users/users.module"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule, RedisModule], + imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule, RedisModule, EmailModule], providers: [ OrganizationsRepository, OrganizationsTeamsRepository, @@ -24,8 +29,17 @@ import { Module } from "@nestjs/common"; MembershipsRepository, OrganizationsSchedulesService, OrganizationSchedulesRepository, + OrganizationsUsersRepository, + OrganizationsUsersService, + EmailService, ], - exports: [OrganizationsService, OrganizationsRepository, OrganizationsTeamsRepository], - controllers: [OrganizationsTeamsController, OrganizationsSchedulesController], + exports: [ + OrganizationsService, + OrganizationsRepository, + OrganizationsTeamsRepository, + OrganizationsUsersRepository, + OrganizationsUsersService, + ], + controllers: [OrganizationsTeamsController, OrganizationsSchedulesController, OrganizationsUsersController], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts b/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts new file mode 100644 index 0000000000..0b0b53a3f9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/get-organization-users.output.ts @@ -0,0 +1,20 @@ +import { GetUserOutput } from "@/modules/users/outputs/get-users.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum } from "class-validator"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class GetOrganizationUsersOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + data!: GetUserOutput[]; +} + +export class GetOrganizationUserOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + data!: GetUserOutput; +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts new file mode 100644 index 0000000000..5612972e5c --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-users.repository.ts @@ -0,0 +1,76 @@ +import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input"; +import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsUsersRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + private filterOnOrgMembership(orgId: number) { + return { + profiles: { + some: { + organizationId: orgId, + }, + }, + }; + } + + async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) { + return await this.dbRead.prisma.user.findMany({ + where: { + ...this.filterOnOrgMembership(orgId), + ...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}), + }, + skip, + take, + }); + } + + async getOrganizationUserByUsername(orgId: number, username: string) { + return await this.dbRead.prisma.user.findFirst({ + where: { + username, + ...this.filterOnOrgMembership(orgId), + }, + }); + } + + async getOrganizationUserByEmail(orgId: number, email: string) { + return await this.dbRead.prisma.user.findFirst({ + where: { + email, + ...this.filterOnOrgMembership(orgId), + }, + }); + } + + async createOrganizationUser(orgId: number, createUserBody: CreateOrganizationUserInput) { + const createdUser = await this.dbWrite.prisma.user.create({ + data: createUserBody, + }); + + return createdUser; + } + + async updateOrganizationUser(orgId: number, userId: number, updateUserBody: UpdateOrganizationUserInput) { + return await this.dbWrite.prisma.user.update({ + where: { + id: userId, + organizationId: orgId, + }, + data: updateUserBody, + }); + } + + async deleteUser(orgId: number, userId: number) { + return await this.dbWrite.prisma.user.delete({ + where: { + id: userId, + organizationId: orgId, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts new file mode 100644 index 0000000000..83eaab5b73 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-users-service.ts @@ -0,0 +1,118 @@ +import { EmailService } from "@/modules/email/email.service"; +import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input"; +import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input"; +import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { Injectable, ConflictException } from "@nestjs/common"; +import { plainToInstance } from "class-transformer"; + +import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.18"; +import { Team } from "@calcom/prisma/client"; + +@Injectable() +export class OrganizationsUsersService { + constructor( + private readonly organizationsUsersRepository: OrganizationsUsersRepository, + private readonly emailService: EmailService + ) {} + + async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) { + const emailArray = !emailInput ? [] : emailInput; + + const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails( + orgId, + emailArray, + skip, + take + ); + + return users; + } + + async createUser(org: Team, userCreateBody: CreateOrganizationUserInput, inviterName: string) { + // Check if email exists in the system + const userEmailCheck = await this.organizationsUsersRepository.getOrganizationUserByEmail( + org.id, + userCreateBody.email + ); + + if (userEmailCheck) throw new ConflictException("A user already exists with that email"); + + // Check if username is already in use in the org + if (userCreateBody.username) { + await this.checkForUsernameConflicts(org.id, userCreateBody.username); + } + + const usernameOrEmail = userCreateBody.username ? userCreateBody.username : userCreateBody.email; + + // Create new org user + const createdUserCall = await createNewUsersConnectToOrgIfExists({ + invitations: [ + { + usernameOrEmail: usernameOrEmail, + role: userCreateBody.organizationRole, + }, + ], + teamId: org.id, + isOrg: true, + parentId: null, + autoAcceptEmailDomain: "not-required-for-this-endpoint", + orgConnectInfoByUsernameOrEmail: { + [usernameOrEmail]: { + orgId: org.id, + autoAccept: userCreateBody.autoAccept, + }, + }, + }); + + const createdUser = createdUserCall[0]; + + // Update user fields that weren't included in createNewUsersConnectToOrgIfExists + const updateUserBody = plainToInstance(CreateUserInput, userCreateBody, { strategy: "excludeAll" }); + + // Update new user with other userCreateBody params + const user = await this.organizationsUsersRepository.updateOrganizationUser( + org.id, + createdUser.id, + updateUserBody + ); + + // Need to send email to new user to create password + await this.emailService.sendSignupToOrganizationEmail({ + usernameOrEmail, + orgName: org.name, + orgId: org.id, + locale: user?.locale, + inviterName, + }); + + return user; + } + + async updateUser(orgId: number, userId: number, userUpdateBody: UpdateOrganizationUserInput) { + if (userUpdateBody.username) { + await this.checkForUsernameConflicts(orgId, userUpdateBody.username); + } + + const user = await this.organizationsUsersRepository.updateOrganizationUser( + orgId, + userId, + userUpdateBody + ); + return user; + } + + async deleteUser(orgId: number, userId: number) { + const user = await this.organizationsUsersRepository.deleteUser(orgId, userId); + return user; + } + + async checkForUsernameConflicts(orgId: number, username: string) { + const isUsernameTaken = await this.organizationsUsersRepository.getOrganizationUserByUsername( + orgId, + username + ); + + if (isUsernameTaken) throw new ConflictException("Username is already taken"); + } +} diff --git a/apps/api/v2/src/modules/users/inputs/create-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-user.input.ts new file mode 100644 index 0000000000..03c7a27054 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/create-user.input.ts @@ -0,0 +1,131 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Transform } from "class-transformer"; +import { + IsBoolean, + IsEmail, + IsHexColor, + IsNumber, + IsOptional, + IsString, + Validate, + Min, +} from "class-validator"; + +import { AvatarValidator } from "../validators/avatarValidator"; +import { LocaleValidator } from "../validators/localeValidator"; +import { ThemeValidator } from "../validators/themeValidator"; +import { TimeFormatValidator } from "../validators/timeFormatValidator"; +import { TimeZoneValidator } from "../validators/timeZoneValidator"; +import { WeekdayValidator } from "../validators/weekdayValidator"; + +export class CreateUserInput { + @ApiProperty({ type: String, description: "User email address", example: "user@example.com" }) + @IsEmail() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.toLowerCase(); + } + }) + @Expose() + email!: string; + + @ApiProperty({ type: String, required: false, description: "Username", example: "user123" }) + @IsOptional() + @IsString() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.toLowerCase(); + } + }) + @Expose() + username?: string; + + @ApiProperty({ type: String, required: false, description: "Preferred weekday", example: "Monday" }) + @IsOptional() + @IsString() + @Validate(WeekdayValidator) + @Expose() + weekday?: string; + + @ApiProperty({ + type: String, + required: false, + description: "Brand color in HEX format", + example: "#FFFFFF", + }) + @IsOptional() + @IsHexColor() + @Expose() + brandColor?: string; + + @ApiProperty({ + type: String, + required: false, + description: "Dark brand color in HEX format", + example: "#000000", + }) + @IsOptional() + @IsHexColor() + @Expose() + darkBrandColor?: string; + + @ApiProperty({ type: Boolean, required: false, description: "Hide branding", example: false }) + @IsOptional() + @IsBoolean() + @Expose() + hideBranding?: boolean; + + @ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" }) + @IsOptional() + @IsString() + @Validate(TimeZoneValidator) + @Expose() + timeZone?: string; + + @ApiProperty({ type: String, required: false, description: "Theme", example: "dark" }) + @IsOptional() + @IsString() + @Validate(ThemeValidator) + @Expose() + theme?: string | null; + + @ApiProperty({ type: String, required: false, description: "Application theme", example: "light" }) + @IsOptional() + @IsString() + @Validate(ThemeValidator) + @Expose() + appTheme?: string | null; + + @ApiProperty({ type: Number, required: false, description: "Time format", example: 24 }) + @IsOptional() + @IsNumber() + @Validate(TimeFormatValidator) + @Expose() + timeFormat?: number; + + @ApiProperty({ type: Number, required: false, description: "Default schedule ID", example: 1, minimum: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Expose() + defaultScheduleId?: number; + + @ApiProperty({ type: String, required: false, description: "Locale", example: "en", default: "en" }) + @IsOptional() + @IsString() + @Validate(LocaleValidator) + @Expose() + locale?: string | null = "en"; + + @ApiProperty({ + type: String, + required: false, + description: "Avatar URL", + example: "https://example.com/avatar.jpg", + }) + @IsOptional() + @IsString() + @Validate(AvatarValidator) + @Expose() + avatarUrl?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/get-users.input.ts b/apps/api/v2/src/modules/users/inputs/get-users.input.ts new file mode 100644 index 0000000000..dae816f944 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/get-users.input.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsOptional, Validate } from "class-validator"; + +import { SkipTakePagination } from "@calcom/platform-types"; + +import { IsEmailStringOrArray } from "../validators/isEmailStringOrArray"; + +export class GetUsersInput extends SkipTakePagination { + @IsOptional() + @Validate(IsEmailStringOrArray) + @Transform(({ value }: { value: string | string[] }) => { + return typeof value === "string" ? [value] : value; + }) + @ApiProperty({ + description: "The email address or an array of email addresses to filter by", + }) + emails?: string[]; +} diff --git a/apps/api/v2/src/modules/users/inputs/update-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-user.input.ts new file mode 100644 index 0000000000..501bd7e1d4 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/update-user.input.ts @@ -0,0 +1,4 @@ +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { PartialType } from "@nestjs/mapped-types"; + +export class UpdateUserInput extends PartialType(CreateUserInput) {} diff --git a/apps/api/v2/src/modules/users/outputs/get-users.output.ts b/apps/api/v2/src/modules/users/outputs/get-users.output.ts new file mode 100644 index 0000000000..fdec53748b --- /dev/null +++ b/apps/api/v2/src/modules/users/outputs/get-users.output.ts @@ -0,0 +1,239 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator"; + +export class GetUserOutput { + @IsInt() + @Expose() + @ApiProperty({ type: Number, required: true, description: "The ID of the user", example: 1 }) + id!: number; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The username of the user", + example: "john_doe", + }) + username!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The name of the user", + example: "John Doe", + }) + name!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + required: true, + description: "The email of the user", + example: "john@example.com", + }) + email!: string; + + @IsDateString() + @Expose() + @ApiProperty({ + type: Date, + nullable: true, + required: false, + description: "The date when the email was verified", + example: "2022-01-01T00:00:00Z", + }) + emailVerified!: Date | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The bio of the user", + example: "I am a software developer", + }) + bio!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The URL of the user's avatar", + example: "https://example.com/avatar.jpg", + }) + avatarUrl!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + required: true, + description: "The time zone of the user", + example: "America/New_York", + }) + timeZone!: string; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + required: true, + description: "The week start day of the user", + example: "Monday", + }) + weekStart!: string; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The app theme of the user", + example: "light", + }) + appTheme!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The theme of the user", + example: "default", + }) + theme!: string | null; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + nullable: true, + required: false, + description: "The ID of the default schedule for the user", + example: 1, + }) + defaultScheduleId!: number | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The locale of the user", + example: "en-US", + }) + locale!: string | null; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + nullable: true, + required: false, + description: "The time format of the user", + example: 12, + }) + timeFormat!: number | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + type: Boolean, + required: true, + description: "Whether to hide branding for the user", + example: false, + }) + hideBranding!: boolean; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The brand color of the user", + example: "#ffffff", + }) + brandColor!: string | null; + + @IsString() + @Expose() + @ApiProperty({ + type: String, + nullable: true, + required: false, + description: "The dark brand color of the user", + example: "#000000", + }) + darkBrandColor!: string | null; + + @IsBoolean() + @Expose() + @ApiProperty({ + type: Boolean, + nullable: true, + required: false, + description: "Whether dynamic booking is allowed for the user", + example: true, + }) + allowDynamicBooking!: boolean | null; + + @IsDateString() + @Expose() + @ApiProperty({ + type: Date, + required: true, + description: "The date when the user was created", + example: "2022-01-01T00:00:00Z", + }) + createdDate!: Date; + + @IsBoolean() + @Expose() + @ApiProperty({ + type: Boolean, + nullable: true, + required: false, + description: "Whether the user is verified", + example: true, + }) + verified!: boolean | null; + + @IsInt() + @Expose() + @ApiProperty({ + type: Number, + nullable: true, + required: false, + description: "The ID of the user who invited this user", + example: 1, + }) + invitedTo!: number | null; +} + +export class GetUsersOutput { + @ValidateNested() + @Type(() => GetUserOutput) + @IsArray() + @ApiProperty({ + type: [GetUserOutput], + required: true, + description: "The list of users", + example: [{ id: 1, username: "john_doe", name: "John Doe", email: "john@example.com" }], + }) + users!: GetUserOutput[]; +} diff --git a/apps/api/v2/src/modules/users/validators/avatarValidator.ts b/apps/api/v2/src/modules/users/validators/avatarValidator.ts new file mode 100644 index 0000000000..d6b01e9167 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/avatarValidator.ts @@ -0,0 +1,11 @@ +import { ValidatorConstraint } from "class-validator"; +import type { ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "avatarValidator", async: false }) +export class AvatarValidator implements ValidatorConstraintInterface { + validate(avatarString: string) { + // Checks if avatar string is a valid base 64 image + const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + return regex.test(avatarString); + } +} diff --git a/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts b/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts new file mode 100644 index 0000000000..b4156be4b6 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/isEmailStringOrArray.ts @@ -0,0 +1,24 @@ +import { ValidatorConstraint } from "class-validator"; +import type { ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "IsEmailStringOrArray", async: false }) +export class IsEmailStringOrArray implements ValidatorConstraintInterface { + validate(value: any): boolean { + if (typeof value === "string") { + return this.validateEmail(value); + } else if (Array.isArray(value)) { + return value.every((item) => this.validateEmail(item)); + } + return false; + } + + validateEmail(email: string): boolean { + const regex = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return regex.test(email); + } + + defaultMessage() { + return "Please submit only valid email addresses"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/localeValidator.ts b/apps/api/v2/src/modules/users/validators/localeValidator.ts new file mode 100644 index 0000000000..18c25d0eab --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/localeValidator.ts @@ -0,0 +1,39 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "localeValidator", async: false }) +export class LocaleValidator implements ValidatorConstraintInterface { + validate(locale: string) { + const localeValues = [ + "en", + "fr", + "it", + "ru", + "es", + "de", + "pt", + "ro", + "nl", + "pt-BR", + "ko", + "ja", + "pl", + "ar", + "iw", + "zh-CN", + "zh-TW", + "cs", + "sr", + "sv", + "vi", + ]; + + if (localeValues.includes(locale)) return true; + + return false; + } + + defaultMessage() { + return "Please include a valid locale"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/themeValidator.ts b/apps/api/v2/src/modules/users/validators/themeValidator.ts new file mode 100644 index 0000000000..7b6ce944b8 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/themeValidator.ts @@ -0,0 +1,17 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "themeValidator", async: false }) +export class ThemeValidator implements ValidatorConstraintInterface { + validate(theme: string) { + const themeValues = ["dark", "light"]; + + if (themeValues.includes(theme)) return true; + + return false; + } + + defaultMessage() { + return "Please include either 'dark' or 'light"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts b/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts new file mode 100644 index 0000000000..c5b82ef111 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/timeFormatValidator.ts @@ -0,0 +1,17 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "timeFormatValidator", async: false }) +export class TimeFormatValidator implements ValidatorConstraintInterface { + validate(timeFormat: number) { + const timeFormatValues = [12, 24]; + + if (timeFormatValues.includes(timeFormat)) return true; + + return false; + } + + defaultMessage() { + return "Please include either 12 or 24"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts b/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts new file mode 100644 index 0000000000..448d79f0d0 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/timeZoneValidator.ts @@ -0,0 +1,18 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; +import tzdata from "tzdata"; + +@ValidatorConstraint({ name: "timezoneValidator", async: false }) +export class TimeZoneValidator implements ValidatorConstraintInterface { + validate(timeZone: string) { + const timeZoneList = Object.keys(tzdata.zones); + + if (timeZoneList.includes(timeZone)) return true; + + return false; + } + + defaultMessage() { + return "Please include a valid time zone"; + } +} diff --git a/apps/api/v2/src/modules/users/validators/weekdayValidator.ts b/apps/api/v2/src/modules/users/validators/weekdayValidator.ts new file mode 100644 index 0000000000..8a53a649e7 --- /dev/null +++ b/apps/api/v2/src/modules/users/validators/weekdayValidator.ts @@ -0,0 +1,16 @@ +import type { ValidatorConstraintInterface } from "class-validator"; +import { ValidatorConstraint } from "class-validator"; + +@ValidatorConstraint({ name: "weekdayValidator", async: false }) +export class WeekdayValidator implements ValidatorConstraintInterface { + validate(weekday: string) { + const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + + if (weekdays.includes(weekday)) return true; + return false; + } + + defaultMessage() { + return "Please include a valid weekday"; + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 4c5003de3d..868af27d51 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1085,6 +1085,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" + } + } + } + }, "responses": { "201": { "description": "", @@ -1187,6 +1197,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" + } + } + } + }, "responses": { "200": { "description": "", @@ -1240,6 +1260,168 @@ ] } }, + "/v2/organizations/{orgId}/users": { + "get": { + "operationId": "OrganizationsUsersController_getOrganizationsUsers", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrganizationsUsersInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Users" + ] + }, + "post": { + "operationId": "OrganizationsUsersController_createOrganizationUser", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrganizationUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Users" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}": { + "patch": { + "operationId": "OrganizationsUsersController_updateOrganizationUser", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Users" + ] + }, + "delete": { + "operationId": "OrganizationsUsersController_deleteOrganizationUser", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Users" + ] + } + }, "/v2/schedules": { "post": { "operationId": "SchedulesController_2024_04_15_createSchedule", @@ -1352,6 +1534,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15" + } + } + } + }, "responses": { "200": { "description": "", @@ -4133,6 +4325,57 @@ "data" ] }, + "CreateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "One-on-one coaching" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome" + }, + "availability": { + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "09:00", + "endTime": "10:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true + }, + "overrides": { + "example": [ + { + "date": "2024-05-20", + "startTime": "12:00", + "endTime": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + }, + "required": [ + "name", + "timeZone", + "isDefault" + ] + }, "CreateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -4181,6 +4424,52 @@ "data" ] }, + "UpdateScheduleInput_2024_06_11": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "One-on-one coaching" + }, + "timeZone": { + "type": "string", + "example": "Europe/Rome" + }, + "availability": { + "example": [ + { + "days": [ + "Monday", + "Tuesday" + ], + "startTime": "09:00", + "endTime": "10:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" + } + }, + "isDefault": { + "type": "boolean", + "example": true + }, + "overrides": { + "example": [ + { + "date": "2024-05-20", + "startTime": "12:00", + "endTime": "14:00" + } + ], + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" + } + } + } + }, "UpdateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -4220,6 +4509,106 @@ "status" ] }, + "GetOrganizationsUsersInput": { + "type": "object", + "properties": { + "email": { + "type": "object", + "description": "The email address or an array of email addresses to filter by" + } + } + }, + "CreateOrganizationUserInput": { + "type": "object", + "properties": { + "locale": { + "type": "object", + "nullable": true, + "default": "en" + }, + "organizationRole": { + "type": "object", + "default": "MEMBER" + }, + "autoAccept": { + "type": "object", + "default": true + }, + "email": { + "type": "string" + }, + "username": { + "type": "string" + }, + "weekday": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "timeZone": { + "type": "string" + }, + "theme": { + "type": "string", + "nullable": true + }, + "appTheme": { + "type": "string", + "nullable": true + }, + "timeFormat": { + "type": "number" + }, + "defaultScheduleId": { + "type": "number", + "minimum": 0 + }, + "avatarUrl": { + "type": "string" + } + }, + "required": [ + "locale", + "organizationRole", + "autoAccept", + "email" + ] + }, + "UpdateOrganizationUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "GetDefaultScheduleOutput_2024_06_11": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" + } + }, + "required": [ + "status", + "data" + ] + }, "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": { @@ -4517,6 +4906,66 @@ "data" ] }, + "UpdateScheduleInput_2024_04_15": { + "type": "object", + "properties": { + "timeZone": { + "type": "string" + }, + "name": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "schedule": { + "example": [ + [], + [ + { + "start": "2022-01-01T00:00:00.000Z", + "end": "2022-01-02T00:00:00.000Z" + } + ], + [], + [], + [], + [], + [] + ], + "items": { + "type": "array" + }, + "type": "array" + }, + "dateOverrides": { + "example": [ + [], + [ + { + "start": "2022-01-01T00:00:00.000Z", + "end": "2022-01-02T00:00:00.000Z" + } + ], + [], + [], + [], + [], + [] + ], + "items": { + "type": "array" + }, + "type": "array" + } + }, + "required": [ + "timeZone", + "name", + "isDefault", + "schedule" + ] + }, "EventTypeModel_2024_04_15": { "type": "object", "properties": {