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,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";
}
}