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 <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2024-07-16 10:50:46 +00:00
committed by GitHub
co-authored by Morgan Vernay Morgan
parent 9fec77e345
commit 41cde8e384
23 changed files with 1743 additions and 3 deletions
@@ -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 {}
@@ -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,
});
}
}
@@ -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<GetOrganizationUsersOutput> {
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<GetOrganizationUserOutput> {
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<GetOrganizationUserOutput> {
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<GetOrganizationUserOutput> {
const user = await this.organizationsUsersService.deleteUser(orgId, userId);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
};
}
}
@@ -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();
});
});
});
@@ -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;
}
@@ -0,0 +1,3 @@
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
export class GetOrganizationsUsersInput extends GetUsersInput {}
@@ -0,0 +1,3 @@
import { UpdateUserInput } from "@/modules/users/inputs/update-user.input";
export class UpdateOrganizationUserInput extends UpdateUserInput {}
@@ -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 {}
@@ -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;
}
@@ -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,
},
});
}
}
@@ -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");
}
}
@@ -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;
}
@@ -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[];
}
@@ -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) {}
@@ -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[];
}
@@ -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);
}
}
@@ -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";
}
}
@@ -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";
}
}
@@ -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";
}
}
@@ -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";
}
}
@@ -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";
}
}
@@ -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";
}
}
+449
View File
@@ -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": {