diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 70cd8fa297..3cdf87a742 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1982,7 +1982,7 @@ export interface PeopleUpdateDto { */ export interface PeopleUpdateItem { /** - * Person date of birth. + * Person date of birth. Note: the mobile app cannot currently set the birth date to null. * @type {string} * @memberof PeopleUpdateItem */ @@ -2056,7 +2056,7 @@ export interface PersonResponseDto { */ export interface PersonUpdateDto { /** - * Person date of birth. + * Person date of birth. Note: the mobile app cannot currently set the birth date to null. * @type {string} * @memberof PersonUpdateDto */ diff --git a/mobile/openapi/doc/PeopleUpdateItem.md b/mobile/openapi/doc/PeopleUpdateItem.md index 25152c4e4b..381b4a1e19 100644 Binary files a/mobile/openapi/doc/PeopleUpdateItem.md and b/mobile/openapi/doc/PeopleUpdateItem.md differ diff --git a/mobile/openapi/doc/PersonUpdateDto.md b/mobile/openapi/doc/PersonUpdateDto.md index a4df668785..f8633e48f9 100644 Binary files a/mobile/openapi/doc/PersonUpdateDto.md and b/mobile/openapi/doc/PersonUpdateDto.md differ diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 0abb7a474c..065126746f 100644 Binary files a/mobile/openapi/lib/model/people_update_item.dart and b/mobile/openapi/lib/model/people_update_item.dart differ diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index fc384c842e..63f0eded7e 100644 Binary files a/mobile/openapi/lib/model/person_update_dto.dart and b/mobile/openapi/lib/model/person_update_dto.dart differ diff --git a/mobile/openapi/test/people_update_item_test.dart b/mobile/openapi/test/people_update_item_test.dart index 4c91143bd5..8829352cf6 100644 Binary files a/mobile/openapi/test/people_update_item_test.dart and b/mobile/openapi/test/people_update_item_test.dart differ diff --git a/mobile/openapi/test/person_update_dto_test.dart b/mobile/openapi/test/person_update_dto_test.dart index 80c46e44f2..6ed4482cc6 100644 Binary files a/mobile/openapi/test/person_update_dto_test.dart and b/mobile/openapi/test/person_update_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 4178f39eba..99526294a1 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6331,7 +6331,7 @@ "PeopleUpdateItem": { "properties": { "birthDate": { - "description": "Person date of birth.", + "description": "Person date of birth.\nNote: the mobile app cannot currently set the birth date to null.", "format": "date", "nullable": true, "type": "string" @@ -6390,7 +6390,7 @@ "PersonUpdateDto": { "properties": { "birthDate": { - "description": "Person date of birth.", + "description": "Person date of birth.\nNote: the mobile app cannot currently set the birth date to null.", "format": "date", "nullable": true, "type": "string" diff --git a/server/src/domain/album/dto/album-create.dto.ts b/server/src/domain/album/dto/album-create.dto.ts index 586cde2c64..e95ec4a834 100644 --- a/server/src/domain/album/dto/album-create.dto.ts +++ b/server/src/domain/album/dto/album-create.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { ValidateUUID } from '../../domain.util'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { Optional, ValidateUUID } from '../../domain.util'; export class CreateAlbumDto { @IsNotEmpty() @@ -9,7 +9,7 @@ export class CreateAlbumDto { albumName!: string; @IsString() - @IsOptional() + @Optional() description?: string; @ValidateUUID({ optional: true, each: true }) diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index 8270777e2b..a03f06e8d6 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,12 +1,12 @@ -import { IsOptional, IsString } from 'class-validator'; -import { ValidateUUID } from '../../domain.util'; +import { IsString } from 'class-validator'; +import { ValidateUUID, Optional } from '../../domain.util'; export class UpdateAlbumDto { - @IsOptional() + @Optional() @IsString() albumName?: string; - @IsOptional() + @Optional() @IsString() description?: string; diff --git a/server/src/domain/album/dto/album.dto.ts b/server/src/domain/album/dto/album.dto.ts index f4b39a8994..1d69a03eb2 100644 --- a/server/src/domain/album/dto/album.dto.ts +++ b/server/src/domain/album/dto/album.dto.ts @@ -1,9 +1,9 @@ import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional } from 'class-validator'; -import { toBoolean } from '../../domain.util'; +import { IsBoolean } from 'class-validator'; +import { toBoolean, Optional } from '../../domain.util'; export class AlbumInfoDto { - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) withoutAssets?: boolean; diff --git a/server/src/domain/album/dto/get-albums.dto.ts b/server/src/domain/album/dto/get-albums.dto.ts index ebe2080bb3..c36428eb37 100644 --- a/server/src/domain/album/dto/get-albums.dto.ts +++ b/server/src/domain/album/dto/get-albums.dto.ts @@ -1,10 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional } from 'class-validator'; -import { toBoolean, ValidateUUID } from '../../domain.util'; +import { IsBoolean } from 'class-validator'; +import { toBoolean, ValidateUUID, Optional } from '../../domain.util'; export class GetAlbumsDto { - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) @ApiProperty() diff --git a/server/src/domain/api-key/api-key.dto.ts b/server/src/domain/api-key/api-key.dto.ts index 06309b8dc0..c25ef5fd49 100644 --- a/server/src/domain/api-key/api-key.dto.ts +++ b/server/src/domain/api-key/api-key.dto.ts @@ -1,9 +1,9 @@ -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; - +import { IsNotEmpty, IsString } from 'class-validator'; +import { Optional } from '../domain.util'; export class APIKeyCreateDto { @IsString() @IsNotEmpty() - @IsOptional() + @Optional() name?: string; } diff --git a/server/src/domain/asset/dto/asset-statistics.dto.ts b/server/src/domain/asset/dto/asset-statistics.dto.ts index ef9c0606fd..1ef18385d1 100644 --- a/server/src/domain/asset/dto/asset-statistics.dto.ts +++ b/server/src/domain/asset/dto/asset-statistics.dto.ts @@ -1,19 +1,19 @@ import { AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional } from 'class-validator'; -import { toBoolean } from '../../domain.util'; +import { IsBoolean } from 'class-validator'; +import { toBoolean, Optional } from '../../domain.util'; import { AssetStats } from '../asset.repository'; export class AssetStatsDto { @IsBoolean() @Transform(toBoolean) - @IsOptional() + @Optional() isArchived?: boolean; @IsBoolean() @Transform(toBoolean) - @IsOptional() + @Optional() isFavorite?: boolean; } diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index 1e4c3faa98..3609dcb6e2 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,12 +1,13 @@ -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean } from 'class-validator'; import { BulkIdsDto } from '../response-dto'; +import { Optional } from '../../domain.util'; export class AssetBulkUpdateDto extends BulkIdsDto { - @IsOptional() + @Optional() @IsBoolean() isFavorite?: boolean; - @IsOptional() + @Optional() @IsBoolean() isArchived?: boolean; } diff --git a/server/src/domain/asset/dto/download.dto.ts b/server/src/domain/asset/dto/download.dto.ts index 604a8ea5fb..d1f9f595e4 100644 --- a/server/src/domain/asset/dto/download.dto.ts +++ b/server/src/domain/asset/dto/download.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsOptional, IsPositive } from 'class-validator'; -import { ValidateUUID } from '../../domain.util'; +import { IsInt, IsPositive } from 'class-validator'; +import { Optional, ValidateUUID } from '../../domain.util'; export class DownloadInfoDto { @ValidateUUID({ each: true, optional: true }) @@ -14,7 +14,7 @@ export class DownloadInfoDto { @IsInt() @IsPositive() - @IsOptional() + @Optional() @ApiProperty({ type: 'integer' }) archiveSize?: number; } diff --git a/server/src/domain/asset/dto/map-marker.dto.ts b/server/src/domain/asset/dto/map-marker.dto.ts index 682cb24367..72aed2886a 100644 --- a/server/src/domain/asset/dto/map-marker.dto.ts +++ b/server/src/domain/asset/dto/map-marker.dto.ts @@ -1,21 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsOptional } from 'class-validator'; -import { toBoolean } from '../../domain.util'; +import { IsBoolean, IsDate } from 'class-validator'; +import { toBoolean, Optional } from '../../domain.util'; export class MapMarkerDto { @ApiProperty() - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) isFavorite?: boolean; - @IsOptional() + @Optional() @IsDate() @Type(() => Date) fileCreatedAfter?: Date; - @IsOptional() + @Optional() @IsDate() @Type(() => Date) fileCreatedBefore?: Date; diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts index d3c991c15e..a818fbb84c 100644 --- a/server/src/domain/asset/dto/time-bucket.dto.ts +++ b/server/src/domain/asset/dto/time-bucket.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { toBoolean, ValidateUUID } from '../../domain.util'; +import { IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { toBoolean, ValidateUUID, Optional } from '../../domain.util'; import { TimeBucketSize } from '../asset.repository'; export class TimeBucketDto { @@ -19,12 +19,12 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) personId?: string; - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) isArchived?: boolean; - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) isFavorite?: boolean; diff --git a/server/src/domain/audit/audit.dto.ts b/server/src/domain/audit/audit.dto.ts index 2494883e04..b437ed5b7f 100644 --- a/server/src/domain/audit/audit.dto.ts +++ b/server/src/domain/audit/audit.dto.ts @@ -1,7 +1,8 @@ import { EntityType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsOptional, IsUUID } from 'class-validator'; +import { IsDate, IsEnum, IsUUID } from 'class-validator'; +import { Optional } from '../domain.util'; export class AuditDeletesDto { @IsDate() @@ -12,7 +13,7 @@ export class AuditDeletesDto { @IsEnum(EntityType) entityType!: EntityType; - @IsOptional() + @Optional() @IsUUID('4') @ApiProperty({ format: 'uuid' }) userId?: string; diff --git a/server/src/domain/auth/dto/login-credential.dto.ts b/server/src/domain/auth/dto/login-credential.dto.ts index dfb9da873b..12516a1083 100644 --- a/server/src/domain/auth/dto/login-credential.dto.ts +++ b/server/src/domain/auth/dto/login-credential.dto.ts @@ -4,8 +4,9 @@ import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; export class LoginCredentialDto { @IsEmail({ require_tld: false }) + @Transform(({ value }) => value?.toLowerCase()) + @IsNotEmpty() @ApiProperty({ example: 'testuser@email.com' }) - @Transform(({ value }) => value.toLowerCase()) email!: string; @IsString() diff --git a/server/src/domain/auth/dto/sign-up.dto.ts b/server/src/domain/auth/dto/sign-up.dto.ts index b6c6175871..66741eb7e6 100644 --- a/server/src/domain/auth/dto/sign-up.dto.ts +++ b/server/src/domain/auth/dto/sign-up.dto.ts @@ -4,8 +4,9 @@ import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; export class SignUpDto { @IsEmail({ require_tld: false }) + @Transform(({ value }) => value?.toLowerCase()) + @IsNotEmpty() @ApiProperty({ example: 'testuser@email.com' }) - @Transform(({ value }) => value.toLowerCase()) email!: string; @IsString() diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index c5a6c45b83..8f5951e6b8 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -1,6 +1,6 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID, ValidationOptions, ValidateIf } from 'class-validator'; import { basename, extname } from 'node:path'; import sanitize from 'sanitize-filename'; @@ -13,7 +13,7 @@ export function ValidateUUID({ optional, each }: Options = { optional: false, ea return applyDecorators( IsUUID('4', { each }), ApiProperty({ format: 'uuid' }), - optional ? IsOptional() : IsNotEmpty(), + optional ? Optional() : IsNotEmpty(), each ? IsArray() : IsString(), ); } @@ -92,3 +92,23 @@ export async function* usePagination( yield result.items; } } + +export interface OptionalOptions extends ValidationOptions { + nullable?: boolean; +} + +/** + * Checks if value is missing and if so, ignores all validators. + * + * @param validationOptions {@link OptionalOptions} + * + * @see IsOptional exported from `class-validator. + */ +// https://stackoverflow.com/a/71353929 +export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) { + if (nullable === true) { + return IsOptional(validationOptions); + } + + return ValidateIf((obj: any, v: any) => v !== undefined, validationOptions); +} diff --git a/server/src/domain/job/job.dto.ts b/server/src/domain/job/job.dto.ts index 1c59409567..588a09a393 100644 --- a/server/src/domain/job/job.dto.ts +++ b/server/src/domain/job/job.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; +import { IsBoolean, IsEnum, IsNotEmpty } from 'class-validator'; import { JobCommand, QueueName } from './job.constants'; +import { Optional } from '../domain.util'; export class JobIdParamDto { @IsNotEmpty() @@ -15,7 +16,7 @@ export class JobCommandDto { @ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' }) command!: JobCommand; - @IsOptional() + @Optional() @IsBoolean() force!: boolean; } diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index 71fe0bd41f..e90587a26d 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,47 +1,38 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { - IsArray, - IsBoolean, - IsDate, - IsNotEmpty, - IsOptional, - IsString, - ValidateIf, - ValidateNested, -} from 'class-validator'; -import { toBoolean, ValidateUUID } from '../domain.util'; +import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { Optional, toBoolean, ValidateUUID } from '../domain.util'; export class PersonUpdateDto { /** * Person name. */ - @IsOptional() + @Optional() @IsString() name?: string; /** * Person date of birth. + * Note: the mobile app cannot currently set the birth date to null. */ - @IsOptional() + @Optional({ nullable: true }) @IsDate() @Type(() => Date) - @ValidateIf((value) => value !== null) @ApiProperty({ format: 'date' }) birthDate?: Date | null; /** * Asset is used to get the feature face thumbnail. */ - @IsOptional() + @Optional() @IsString() featureFaceAssetId?: string; /** * Person visibility */ - @IsOptional() + @Optional() @IsBoolean() isHidden?: boolean; } @@ -53,43 +44,13 @@ export class PeopleUpdateDto { people!: PeopleUpdateItem[]; } -export class PeopleUpdateItem { +export class PeopleUpdateItem extends PersonUpdateDto { /** * Person id. */ @IsString() @IsNotEmpty() id!: string; - - /** - * Person name. - */ - @IsOptional() - @IsString() - name?: string; - - /** - * Person date of birth. - */ - @IsOptional() - @IsDate() - @Type(() => Date) - @ApiProperty({ format: 'date' }) - birthDate?: Date | null; - - /** - * Asset is used to get the feature face thumbnail. - */ - @IsOptional() - @IsString() - featureFaceAssetId?: string; - - /** - * Person visibility - */ - @IsOptional() - @IsBoolean() - isHidden?: boolean; } export class MergePersonDto { diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index bb38a383b2..05d6909ef8 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,87 +1,87 @@ import { AssetType } from '@app/infra/entities'; import { Transform } from 'class-transformer'; -import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { toBoolean } from '../../domain.util'; +import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { toBoolean, Optional } from '../../domain.util'; export class SearchDto { @IsString() @IsNotEmpty() - @IsOptional() + @Optional() q?: string; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() query?: string; @IsBoolean() - @IsOptional() + @Optional() @Transform(toBoolean) clip?: boolean; @IsEnum(AssetType) - @IsOptional() + @Optional() type?: AssetType; @IsBoolean() - @IsOptional() + @Optional() @Transform(toBoolean) isFavorite?: boolean; @IsBoolean() - @IsOptional() + @Optional() @Transform(toBoolean) isArchived?: boolean; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() 'exifInfo.city'?: string; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() 'exifInfo.state'?: string; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() 'exifInfo.country'?: string; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() 'exifInfo.make'?: string; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() 'exifInfo.model'?: string; @IsString() @IsNotEmpty() - @IsOptional() + @Optional() 'exifInfo.projectionType'?: string; @IsString({ each: true }) @IsArray() - @IsOptional() + @Optional() @Transform(({ value }) => value.split(',')) 'smartInfo.objects'?: string[]; @IsString({ each: true }) @IsArray() - @IsOptional() + @Optional() @Transform(({ value }) => value.split(',')) 'smartInfo.tags'?: string[]; @IsBoolean() - @IsOptional() + @Optional() @Transform(toBoolean) recent?: boolean; @IsBoolean() - @IsOptional() + @Optional() @Transform(toBoolean) motion?: boolean; } diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index 982c1fbe0f..a51d354baa 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -1,8 +1,8 @@ import { SharedLinkType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsEnum, IsOptional, IsString } from 'class-validator'; -import { ValidateUUID } from '../domain.util'; +import { IsBoolean, IsDate, IsEnum, IsString } from 'class-validator'; +import { ValidateUUID, Optional } from '../domain.util'; export class SharedLinkCreateDto { @IsEnum(SharedLinkType) @@ -16,40 +16,40 @@ export class SharedLinkCreateDto { albumId?: string; @IsString() - @IsOptional() + @Optional() description?: string; @IsDate() @Type(() => Date) - @IsOptional() + @Optional({ nullable: true }) expiresAt?: Date | null = null; - @IsOptional() + @Optional() @IsBoolean() allowUpload?: boolean = false; - @IsOptional() + @Optional() @IsBoolean() allowDownload?: boolean = true; - @IsOptional() + @Optional() @IsBoolean() showExif?: boolean = true; } export class SharedLinkEditDto { - @IsOptional() + @Optional() description?: string; - @IsOptional() + @Optional({ nullable: true }) expiresAt?: Date | null; - @IsOptional() + @Optional() allowUpload?: boolean; - @IsOptional() + @Optional() allowDownload?: boolean; - @IsOptional() + @Optional() showExif?: boolean; } diff --git a/server/src/domain/smart-info/dto/model-config.dto.ts b/server/src/domain/smart-info/dto/model-config.dto.ts index 40da07ab9e..7df9d29773 100644 --- a/server/src/domain/smart-info/dto/model-config.dto.ts +++ b/server/src/domain/smart-info/dto/model-config.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; import { CLIPMode, ModelType } from '../machine-learning.interface'; +import { Optional } from '../../domain.util'; export class ModelConfig { @IsBoolean() @@ -12,7 +13,7 @@ export class ModelConfig { modelName!: string; @IsEnum(ModelType) - @IsOptional() + @Optional() @ApiProperty({ enumName: 'ModelType', enum: ModelType }) modelType?: ModelType; } @@ -28,7 +29,7 @@ export class ClassificationConfig extends ModelConfig { export class CLIPConfig extends ModelConfig { @IsEnum(CLIPMode) - @IsOptional() + @Optional() @ApiProperty({ enumName: 'CLIPMode', enum: CLIPMode }) mode?: CLIPMode; } diff --git a/server/src/domain/tag/tag.dto.ts b/server/src/domain/tag/tag.dto.ts index 4cce47530f..900aac9bd6 100644 --- a/server/src/domain/tag/tag.dto.ts +++ b/server/src/domain/tag/tag.dto.ts @@ -1,6 +1,7 @@ import { TagType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { Optional } from '../domain.util'; export class CreateTagDto { @IsString() @@ -15,6 +16,6 @@ export class CreateTagDto { export class UpdateTagDto { @IsString() - @IsOptional() + @Optional() name?: string; } diff --git a/server/src/domain/user/dto/create-user.dto.ts b/server/src/domain/user/dto/create-user.dto.ts index c9a9945af4..94294371d1 100644 --- a/server/src/domain/user/dto/create-user.dto.ts +++ b/server/src/domain/user/dto/create-user.dto.ts @@ -1,6 +1,6 @@ import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { toEmail, toSanitized } from '../../domain.util'; +import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { toEmail, toSanitized, Optional } from '../../domain.util'; export class CreateUserDto { @IsEmail({ require_tld: false }) @@ -19,16 +19,16 @@ export class CreateUserDto { @IsString() lastName!: string; - @IsOptional() + @Optional({ nullable: true }) @IsString() @Transform(toSanitized) storageLabel?: string | null; - @IsOptional() + @Optional({ nullable: true }) @IsString() externalPath?: string | null; - @IsOptional() + @Optional() @IsBoolean() memoriesEnabled?: boolean; } diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts index a1e053855e..46d89c6b7b 100644 --- a/server/src/domain/user/dto/update-user.dto.ts +++ b/server/src/domain/user/dto/update-user.dto.ts @@ -1,35 +1,35 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; -import { toEmail, toSanitized } from '../../domain.util'; +import { IsBoolean, IsEmail, IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { toEmail, toSanitized, Optional } from '../../domain.util'; export class UpdateUserDto { - @IsOptional() + @Optional() @IsEmail({ require_tld: false }) @Transform(toEmail) email?: string; - @IsOptional() + @Optional() @IsNotEmpty() @IsString() password?: string; - @IsOptional() + @Optional() @IsString() @IsNotEmpty() firstName?: string; - @IsOptional() + @Optional() @IsString() @IsNotEmpty() lastName?: string; - @IsOptional() + @Optional() @IsString() @Transform(toSanitized) storageLabel?: string; - @IsOptional() + @Optional() @IsString() externalPath?: string; @@ -38,15 +38,15 @@ export class UpdateUserDto { @ApiProperty({ format: 'uuid' }) id!: string; - @IsOptional() + @Optional() @IsBoolean() isAdmin?: boolean; - @IsOptional() + @Optional() @IsBoolean() shouldChangePassword?: boolean; - @IsOptional() + @Optional() @IsBoolean() memoriesEnabled?: boolean; } diff --git a/server/src/domain/user/dto/user-count.dto.ts b/server/src/domain/user/dto/user-count.dto.ts index 7853ede3c9..8b4bd6eda2 100644 --- a/server/src/domain/user/dto/user-count.dto.ts +++ b/server/src/domain/user/dto/user-count.dto.ts @@ -1,9 +1,10 @@ +import { Optional } from '../../domain.util'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean } from 'class-validator'; export class UserCountDto { @IsBoolean() - @IsOptional() + @Optional() @Transform(({ value }) => value === 'true') /** * When true, return the number of admins accounts diff --git a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts index 52aee7c373..3067edebe1 100644 --- a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts +++ b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts @@ -1,31 +1,31 @@ -import { toBoolean } from '@app/domain'; +import { Optional, toBoolean } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { IsBoolean, IsDate, IsNotEmpty, IsNumber, IsUUID } from 'class-validator'; export class AssetSearchDto { - @IsOptional() + @Optional() @IsNotEmpty() @IsBoolean() @Transform(toBoolean) isFavorite?: boolean; - @IsOptional() + @Optional() @IsNotEmpty() @IsBoolean() @Transform(toBoolean) isArchived?: boolean; - @IsOptional() + @Optional() @IsNumber() skip?: number; - @IsOptional() + @Optional() @IsUUID('4') @ApiProperty({ format: 'uuid' }) userId?: string; - @IsOptional() + @Optional() @IsDate() @Type(() => Date) updatedAfter?: Date; diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index 512da5605f..6f0601672b 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -1,7 +1,7 @@ -import { toBoolean, toSanitized, UploadFieldName } from '@app/domain'; +import { Optional, toBoolean, toSanitized, UploadFieldName } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; export class CreateAssetBase { @IsNotEmpty() @@ -19,20 +19,20 @@ export class CreateAssetBase { @IsNotEmpty() isFavorite!: boolean; - @IsOptional() + @Optional() @IsBoolean() isArchived?: boolean; - @IsOptional() + @Optional() @IsBoolean() isVisible?: boolean; - @IsOptional() + @Optional() duration?: string; } export class CreateAssetDto extends CreateAssetBase { - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) isReadOnly?: boolean = false; @@ -50,7 +50,7 @@ export class CreateAssetDto extends CreateAssetBase { } export class ImportAssetDto extends CreateAssetBase { - @IsOptional() + @Optional() @Transform(toBoolean) isReadOnly?: boolean = true; @@ -60,7 +60,7 @@ export class ImportAssetDto extends CreateAssetBase { assetPath!: string; @IsString() - @IsOptional() + @Optional() @IsNotEmpty() @Transform(toSanitized) sidecarPath?: string; diff --git a/server/src/immich/api-v1/asset/dto/create-exif.dto.ts b/server/src/immich/api-v1/asset/dto/create-exif.dto.ts deleted file mode 100644 index c2594c47ba..0000000000 --- a/server/src/immich/api-v1/asset/dto/create-exif.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IsNotEmpty, IsOptional } from 'class-validator'; - -export class CreateExifDto { - @IsNotEmpty() - assetId!: string; - - @IsOptional() - make?: string; - - @IsOptional() - model?: string; - - @IsOptional() - exifImageWidth?: number; - - @IsOptional() - exifImageHeight?: number; - - @IsOptional() - fileSizeInByte?: number; - - @IsOptional() - orientation?: string; - - @IsOptional() - dateTimeOriginal?: Date; - - @IsOptional() - modifiedDate?: Date; - - @IsOptional() - lensModel?: string; - - @IsOptional() - fNumber?: number; - - @IsOptional() - focalLenght?: number; - - @IsOptional() - iso?: number; - - @IsOptional() - exposureTime?: number; -} diff --git a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts index ad0e755d6a..da5661e0d0 100644 --- a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts +++ b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts @@ -1,5 +1,6 @@ +import { Optional } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum } from 'class-validator'; export enum GetAssetThumbnailFormatEnum { JPEG = 'JPEG', @@ -7,7 +8,7 @@ export enum GetAssetThumbnailFormatEnum { } export class GetAssetThumbnailDto { - @IsOptional() + @Optional() @IsEnum(GetAssetThumbnailFormatEnum) @ApiProperty({ type: String, diff --git a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts index 45ab4f2b4a..dd13c6dfbc 100644 --- a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts +++ b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts @@ -1,16 +1,16 @@ -import { toBoolean } from '@app/domain'; +import { Optional, toBoolean } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean } from 'class-validator'; export class ServeFileDto { - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) @ApiProperty({ type: Boolean, title: 'Is serve thumbnail (resize) file' }) isThumb?: boolean; - @IsOptional() + @Optional() @IsBoolean() @Transform(toBoolean) @ApiProperty({ type: Boolean, title: 'Is request made from web' }) diff --git a/server/src/immich/api-v1/asset/dto/update-asset.dto.ts b/server/src/immich/api-v1/asset/dto/update-asset.dto.ts index c180c256bb..37eb3cb9fc 100644 --- a/server/src/immich/api-v1/asset/dto/update-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/update-asset.dto.ts @@ -1,16 +1,17 @@ +import { Optional } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsNotEmpty, IsString } from 'class-validator'; export class UpdateAssetDto { - @IsOptional() + @Optional() @IsBoolean() isFavorite?: boolean; - @IsOptional() + @Optional() @IsBoolean() isArchived?: boolean; - @IsOptional() + @Optional() @IsArray() @IsString({ each: true }) @IsNotEmpty({ each: true }) @@ -26,7 +27,7 @@ export class UpdateAssetDto { }) tagIds?: string[]; - @IsOptional() + @Optional() @IsString() description?: string; } diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts index a0350ad942..04595a6055 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/server/test/e2e/auth.e2e-spec.ts @@ -2,7 +2,16 @@ import { AppModule, AuthController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; -import { deviceStub, errorStub, loginResponseStub, signupResponseStub, signupStub, uuidStub } from '../fixtures'; +import { + changePasswordStub, + deviceStub, + errorStub, + loginResponseStub, + loginStub, + signupResponseStub, + adminSignupStub, + uuidStub, +} from '../fixtures'; import { api, db } from '../test-utils'; const firstName = 'Immich'; @@ -64,7 +73,7 @@ describe(`${AuthController.name} (e2e)`, () => { it('should sign up the admin with a local domain', async () => { const { status, body } = await request(server) .post('/auth/admin-sign-up') - .send({ ...signupStub, email: 'admin@local' }); + .send({ ...adminSignupStub, email: 'admin@local' }); expect(status).toEqual(201); expect(body).toEqual({ ...signupResponseStub, email: 'admin@local' }); }); @@ -72,7 +81,7 @@ describe(`${AuthController.name} (e2e)`, () => { it('should transform email to lower case', async () => { const { status, body } = await request(server) .post('/auth/admin-sign-up') - .send({ ...signupStub, email: 'aDmIn@IMMICH.app' }); + .send({ ...adminSignupStub, email: 'aDmIn@IMMICH.app' }); expect(status).toEqual(201); expect(body).toEqual(signupResponseStub); }); @@ -80,11 +89,21 @@ describe(`${AuthController.name} (e2e)`, () => { it('should not allow a second admin to sign up', async () => { await api.adminSignUp(server); - const { status, body } = await request(server).post('/auth/admin-sign-up').send(signupStub); + const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); expect(status).toBe(400); expect(body).toEqual(errorStub.alreadyHasAdmin); }); + + for (const key of Object.keys(adminSignupStub)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(server) + .post('/auth/admin-sign-up') + .send({ ...adminSignupStub, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + } }); describe(`POST /auth/login`, () => { @@ -94,6 +113,16 @@ describe(`${AuthController.name} (e2e)`, () => { expect(status).toBe(401); }); + for (const key of Object.keys(loginStub.admin)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(server) + .post('/auth/login') + .send({ ...loginStub.admin, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + } + it('should accept a correct password', async () => { const { status, body, headers } = await request(server).post('/auth/login').send({ email, password }); expect(status).toBe(201); @@ -183,17 +212,26 @@ describe(`${AuthController.name} (e2e)`, () => { describe('POST /auth/change-password', () => { it('should require authentication', async () => { - const { status, body } = await request(server) - .post(`/auth/change-password`) - .send({ password: 'Password123', newPassword: 'Password1234' }); + const { status, body } = await request(server).post(`/auth/change-password`).send(changePasswordStub); expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); + for (const key of Object.keys(changePasswordStub)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(server) + .post('/auth/change-password') + .send({ ...changePasswordStub, [key]: null }) + .set('Authorization', `Bearer ${accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + } + it('should require the current password', async () => { const { status, body } = await request(server) .post(`/auth/change-password`) - .send({ password: 'wrong-password', newPassword: 'Password1234' }) + .send({ ...changePasswordStub, password: 'wrong-password' }) .set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorStub.wrongPassword); @@ -202,7 +240,7 @@ describe(`${AuthController.name} (e2e)`, () => { it('should change the password', async () => { const { status } = await request(server) .post(`/auth/change-password`) - .send({ password: 'Password123', newPassword: 'Password1234' }) + .send(changePasswordStub) .set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(200); diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts index 6395e78b0f..88a5b07fa7 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/test/e2e/person.e2e-spec.ts @@ -40,7 +40,20 @@ describe(`${PersonController.name}`, () => { expect(body).toEqual(errorStub.unauthorized); }); - it('should not accept invalid dates', async () => { + for (const key of ['name', 'featureFaceAssetId', 'isHidden']) { + it(`should not allow null ${key}`, async () => { + const personRepository = app.get(IPersonRepository); + const person = await personRepository.create({ ownerId: loginResponse.userId }); + const { status, body } = await request(server) + .put(`/person/${person.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + } + + it('should not accept invalid birth dates', async () => { for (const birthDate of [false, 'false', '123567', 123456]) { const { status, body } = await request(server) .put(`/person/${uuidStub.notFound}`) @@ -50,6 +63,7 @@ describe(`${PersonController.name}`, () => { expect(body).toEqual(errorStub.badRequest); } }); + it('should update a date of birth', async () => { const personRepository = app.get(IPersonRepository); const person = await personRepository.create({ ownerId: loginResponse.userId }); diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts index 90c79ed966..aa1cc9d1d7 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/server/test/e2e/user.e2e-spec.ts @@ -3,7 +3,7 @@ import { AppModule, UserController } from '@app/immich'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; -import { errorStub } from '../fixtures'; +import { errorStub, userSignupStub, userStub } from '../fixtures'; import { api, db } from '../test-utils'; describe(`${UserController.name}`, () => { @@ -118,13 +118,22 @@ describe(`${UserController.name}`, () => { describe('POST /user', () => { it('should require authentication', async () => { - const { status, body } = await request(server) - .post(`/user`) - .send({ email: 'user1@immich.app', password: 'Password123', firstName: 'Immich', lastName: 'User' }); + const { status, body } = await request(server).post(`/user`).send(userSignupStub); expect(status).toBe(401); expect(body).toEqual(errorStub.unauthorized); }); + for (const key of Object.keys(userSignupStub)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(server) + .post(`/user`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ ...userSignupStub, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + } + it('should ignore `isAdmin`', async () => { const { status, body } = await request(server) .post(`/user`) @@ -170,6 +179,17 @@ describe(`${UserController.name}`, () => { expect(body).toEqual(errorStub.unauthorized); }); + for (const key of Object.keys(userStub.admin)) { + it(`should not allow null ${key}`, async () => { + const { status, body } = await request(server) + .put(`/user`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ ...userStub.admin, [key]: null }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + }); + } + it('should not allow a non-admin to become an admin', async () => { const user = await api.userApi.create(server, accessToken, { email: 'user1@immich.app', diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 561ca60960..154ba0f676 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,12 +1,17 @@ import { AuthUserDto } from '@app/domain'; -export const signupStub = { +export const adminSignupStub = { firstName: 'Immich', lastName: 'Admin', email: 'admin@immich.app', password: 'Password123', }; +export const userSignupStub = { + ...adminSignupStub, + memoriesEnabled: true, +}; + export const signupResponseStub = { id: expect.any(String), email: 'admin@immich.app', @@ -22,6 +27,11 @@ export const loginStub = { }, }; +export const changePasswordStub = { + password: 'Password123', + newPassword: 'Password1234', +}; + export const authStub = { admin: Object.freeze({ id: 'admin_id', diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 1ddaadfaa4..4137f2b81e 100644 --- a/server/test/test-utils.ts +++ b/server/test/test-utils.ts @@ -14,7 +14,7 @@ import { } from '@app/domain'; import { dataSource } from '@app/infra'; import request from 'supertest'; -import { loginResponseStub, loginStub, signupResponseStub, signupStub } from './fixtures'; +import { loginResponseStub, loginStub, signupResponseStub, adminSignupStub } from './fixtures'; export const db = { reset: async () => { @@ -49,7 +49,7 @@ export function getAuthUser(): AuthUserDto { export const api = { adminSignUp: async (server: any) => { - const { status, body } = await request(server).post('/auth/admin-sign-up').send(signupStub); + const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); expect(status).toBe(201); expect(body).toEqual(signupResponseStub); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 70cd8fa297..3cdf87a742 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1982,7 +1982,7 @@ export interface PeopleUpdateDto { */ export interface PeopleUpdateItem { /** - * Person date of birth. + * Person date of birth. Note: the mobile app cannot currently set the birth date to null. * @type {string} * @memberof PeopleUpdateItem */ @@ -2056,7 +2056,7 @@ export interface PersonResponseDto { */ export interface PersonUpdateDto { /** - * Person date of birth. + * Person date of birth. Note: the mobile app cannot currently set the birth date to null. * @type {string} * @memberof PersonUpdateDto */