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:
co-authored by
Morgan Vernay
Morgan
parent
9fec77e345
commit
41cde8e384
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+115
@@ -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" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+347
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user