瀏覽代碼

fix(server): non-nullable `IsOptional` (#3939)

* custom `IsOptional`

* added link to source

* formatting

* Update server/src/domain/domain.util.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* nullable birth date endpoint

* made `nullable` a property

* formatting

* removed unused dto

* updated decorator arg

* fixed album e2e tests

* add null tests for auth e2e

* add null test for person e2e

* fixed tests

* added null test for user e2e

* removed unusued import

* log key in test name

* chore: add note about mobile not being able to use the endpoint

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Mert 1 年之前
父節點
當前提交
9539a361e4
共有 43 個文件被更改,包括 271 次插入243 次删除
  1. 2 2
      cli/src/api/open-api/api.ts
  2. 1 1
      mobile/openapi/doc/PeopleUpdateItem.md
  3. 1 1
      mobile/openapi/doc/PersonUpdateDto.md
  4. 1 1
      mobile/openapi/lib/model/people_update_item.dart
  5. 1 1
      mobile/openapi/lib/model/person_update_dto.dart
  6. 1 1
      mobile/openapi/test/people_update_item_test.dart
  7. 1 1
      mobile/openapi/test/person_update_dto_test.dart
  8. 2 2
      server/immich-openapi-specs.json
  9. 3 3
      server/src/domain/album/dto/album-create.dto.ts
  10. 4 4
      server/src/domain/album/dto/album-update.dto.ts
  11. 3 3
      server/src/domain/album/dto/album.dto.ts
  12. 3 3
      server/src/domain/album/dto/get-albums.dto.ts
  13. 3 3
      server/src/domain/api-key/api-key.dto.ts
  14. 4 4
      server/src/domain/asset/dto/asset-statistics.dto.ts
  15. 4 3
      server/src/domain/asset/dto/asset.dto.ts
  16. 3 3
      server/src/domain/asset/dto/download.dto.ts
  17. 5 5
      server/src/domain/asset/dto/map-marker.dto.ts
  18. 4 4
      server/src/domain/asset/dto/time-bucket.dto.ts
  19. 3 2
      server/src/domain/audit/audit.dto.ts
  20. 2 1
      server/src/domain/auth/dto/login-credential.dto.ts
  21. 2 1
      server/src/domain/auth/dto/sign-up.dto.ts
  22. 22 2
      server/src/domain/domain.util.ts
  23. 3 2
      server/src/domain/job/job.dto.ts
  24. 8 47
      server/src/domain/person/person.dto.ts
  25. 18 18
      server/src/domain/search/dto/search.dto.ts
  26. 12 12
      server/src/domain/shared-link/shared-link.dto.ts
  27. 4 3
      server/src/domain/smart-info/dto/model-config.dto.ts
  28. 3 2
      server/src/domain/tag/tag.dto.ts
  29. 5 5
      server/src/domain/user/dto/create-user.dto.ts
  30. 11 11
      server/src/domain/user/dto/update-user.dto.ts
  31. 3 2
      server/src/domain/user/dto/user-count.dto.ts
  32. 7 7
      server/src/immich/api-v1/asset/dto/asset-search.dto.ts
  33. 8 8
      server/src/immich/api-v1/asset/dto/create-asset.dto.ts
  34. 0 45
      server/src/immich/api-v1/asset/dto/create-exif.dto.ts
  35. 3 2
      server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts
  36. 4 4
      server/src/immich/api-v1/asset/dto/serve-file.dto.ts
  37. 6 5
      server/src/immich/api-v1/asset/dto/update-asset.dto.ts
  38. 47 9
      server/test/e2e/auth.e2e-spec.ts
  39. 15 1
      server/test/e2e/person.e2e-spec.ts
  40. 24 4
      server/test/e2e/user.e2e-spec.ts
  41. 11 1
      server/test/fixtures/auth.stub.ts
  42. 2 2
      server/test/test-utils.ts
  43. 2 2
      web/src/api/open-api/api.ts

+ 2 - 2
cli/src/api/open-api/api.ts

@@ -1982,7 +1982,7 @@ export interface PeopleUpdateDto {
  */
  */
 export interface PeopleUpdateItem {
 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}
      * @type {string}
      * @memberof PeopleUpdateItem
      * @memberof PeopleUpdateItem
      */
      */
@@ -2056,7 +2056,7 @@ export interface PersonResponseDto {
  */
  */
 export interface PersonUpdateDto {
 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}
      * @type {string}
      * @memberof PersonUpdateDto
      * @memberof PersonUpdateDto
      */
      */

+ 1 - 1
mobile/openapi/doc/PeopleUpdateItem.md

@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
-**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. | [optional] 
+**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. Note: the mobile app cannot currently set the birth date to null. | [optional] 
 **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] 
 **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] 
 **id** | **String** | Person id. | 
 **id** | **String** | Person id. | 
 **isHidden** | **bool** | Person visibility | [optional] 
 **isHidden** | **bool** | Person visibility | [optional] 

+ 1 - 1
mobile/openapi/doc/PersonUpdateDto.md

@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
-**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. | [optional] 
+**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. Note: the mobile app cannot currently set the birth date to null. | [optional] 
 **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] 
 **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] 
 **isHidden** | **bool** | Person visibility | [optional] 
 **isHidden** | **bool** | Person visibility | [optional] 
 **name** | **String** | Person name. | [optional] 
 **name** | **String** | Person name. | [optional] 

+ 1 - 1
mobile/openapi/lib/model/people_update_item.dart

@@ -20,7 +20,7 @@ class PeopleUpdateItem {
     this.name,
     this.name,
   });
   });
 
 
-  /// Person date of birth.
+  /// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
   DateTime? birthDate;
   DateTime? birthDate;
 
 
   /// Asset is used to get the feature face thumbnail.
   /// Asset is used to get the feature face thumbnail.

+ 1 - 1
mobile/openapi/lib/model/person_update_dto.dart

@@ -19,7 +19,7 @@ class PersonUpdateDto {
     this.name,
     this.name,
   });
   });
 
 
-  /// Person date of birth.
+  /// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
   DateTime? birthDate;
   DateTime? birthDate;
 
 
   /// Asset is used to get the feature face thumbnail.
   /// Asset is used to get the feature face thumbnail.

+ 1 - 1
mobile/openapi/test/people_update_item_test.dart

@@ -16,7 +16,7 @@ void main() {
   // final instance = PeopleUpdateItem();
   // final instance = PeopleUpdateItem();
 
 
   group('test PeopleUpdateItem', () {
   group('test PeopleUpdateItem', () {
-    // Person date of birth.
+    // Person date of birth. Note: the mobile app cannot currently set the birth date to null.
     // DateTime birthDate
     // DateTime birthDate
     test('to test the property `birthDate`', () async {
     test('to test the property `birthDate`', () async {
       // TODO
       // TODO

+ 1 - 1
mobile/openapi/test/person_update_dto_test.dart

@@ -16,7 +16,7 @@ void main() {
   // final instance = PersonUpdateDto();
   // final instance = PersonUpdateDto();
 
 
   group('test PersonUpdateDto', () {
   group('test PersonUpdateDto', () {
-    // Person date of birth.
+    // Person date of birth. Note: the mobile app cannot currently set the birth date to null.
     // DateTime birthDate
     // DateTime birthDate
     test('to test the property `birthDate`', () async {
     test('to test the property `birthDate`', () async {
       // TODO
       // TODO

+ 2 - 2
server/immich-openapi-specs.json

@@ -6331,7 +6331,7 @@
       "PeopleUpdateItem": {
       "PeopleUpdateItem": {
         "properties": {
         "properties": {
           "birthDate": {
           "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",
             "format": "date",
             "nullable": true,
             "nullable": true,
             "type": "string"
             "type": "string"
@@ -6390,7 +6390,7 @@
       "PersonUpdateDto": {
       "PersonUpdateDto": {
         "properties": {
         "properties": {
           "birthDate": {
           "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",
             "format": "date",
             "nullable": true,
             "nullable": true,
             "type": "string"
             "type": "string"

+ 3 - 3
server/src/domain/album/dto/album-create.dto.ts

@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 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 {
 export class CreateAlbumDto {
   @IsNotEmpty()
   @IsNotEmpty()
@@ -9,7 +9,7 @@ export class CreateAlbumDto {
   albumName!: string;
   albumName!: string;
 
 
   @IsString()
   @IsString()
-  @IsOptional()
+  @Optional()
   description?: string;
   description?: string;
 
 
   @ValidateUUID({ optional: true, each: true })
   @ValidateUUID({ optional: true, each: true })

+ 4 - 4
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 {
 export class UpdateAlbumDto {
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   albumName?: string;
   albumName?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   description?: string;
   description?: string;
 
 

+ 3 - 3
server/src/domain/album/dto/album.dto.ts

@@ -1,9 +1,9 @@
 import { Transform } from 'class-transformer';
 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 {
 export class AlbumInfoDto {
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   withoutAssets?: boolean;
   withoutAssets?: boolean;

+ 3 - 3
server/src/domain/album/dto/get-albums.dto.ts

@@ -1,10 +1,10 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 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 {
 export class GetAlbumsDto {
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   @ApiProperty()
   @ApiProperty()

+ 3 - 3
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 {
 export class APIKeyCreateDto {
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   name?: string;
   name?: string;
 }
 }
 
 

+ 4 - 4
server/src/domain/asset/dto/asset-statistics.dto.ts

@@ -1,19 +1,19 @@
 import { AssetType } from '@app/infra/entities';
 import { AssetType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 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';
 import { AssetStats } from '../asset.repository';
 
 
 export class AssetStatsDto {
 export class AssetStatsDto {
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
-  @IsOptional()
+  @Optional()
   isArchived?: boolean;
   isArchived?: boolean;
 
 
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
-  @IsOptional()
+  @Optional()
   isFavorite?: boolean;
   isFavorite?: boolean;
 }
 }
 
 

+ 4 - 3
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 { BulkIdsDto } from '../response-dto';
+import { Optional } from '../../domain.util';
 
 
 export class AssetBulkUpdateDto extends BulkIdsDto {
 export class AssetBulkUpdateDto extends BulkIdsDto {
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isFavorite?: boolean;
   isFavorite?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isArchived?: boolean;
   isArchived?: boolean;
 }
 }

+ 3 - 3
server/src/domain/asset/dto/download.dto.ts

@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 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 {
 export class DownloadInfoDto {
   @ValidateUUID({ each: true, optional: true })
   @ValidateUUID({ each: true, optional: true })
@@ -14,7 +14,7 @@ export class DownloadInfoDto {
 
 
   @IsInt()
   @IsInt()
   @IsPositive()
   @IsPositive()
-  @IsOptional()
+  @Optional()
   @ApiProperty({ type: 'integer' })
   @ApiProperty({ type: 'integer' })
   archiveSize?: number;
   archiveSize?: number;
 }
 }

+ 5 - 5
server/src/domain/asset/dto/map-marker.dto.ts

@@ -1,21 +1,21 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform, Type } from 'class-transformer';
 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 {
 export class MapMarkerDto {
   @ApiProperty()
   @ApiProperty()
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isFavorite?: boolean;
   isFavorite?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsDate()
   @IsDate()
   @Type(() => Date)
   @Type(() => Date)
   fileCreatedAfter?: Date;
   fileCreatedAfter?: Date;
 
 
-  @IsOptional()
+  @Optional()
   @IsDate()
   @IsDate()
   @Type(() => Date)
   @Type(() => Date)
   fileCreatedBefore?: Date;
   fileCreatedBefore?: Date;

+ 4 - 4
server/src/domain/asset/dto/time-bucket.dto.ts

@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 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';
 import { TimeBucketSize } from '../asset.repository';
 
 
 export class TimeBucketDto {
 export class TimeBucketDto {
@@ -19,12 +19,12 @@ export class TimeBucketDto {
   @ValidateUUID({ optional: true })
   @ValidateUUID({ optional: true })
   personId?: string;
   personId?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isArchived?: boolean;
   isArchived?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isFavorite?: boolean;
   isFavorite?: boolean;

+ 3 - 2
server/src/domain/audit/audit.dto.ts

@@ -1,7 +1,8 @@
 import { EntityType } from '@app/infra/entities';
 import { EntityType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 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 {
 export class AuditDeletesDto {
   @IsDate()
   @IsDate()
@@ -12,7 +13,7 @@ export class AuditDeletesDto {
   @IsEnum(EntityType)
   @IsEnum(EntityType)
   entityType!: EntityType;
   entityType!: EntityType;
 
 
-  @IsOptional()
+  @Optional()
   @IsUUID('4')
   @IsUUID('4')
   @ApiProperty({ format: 'uuid' })
   @ApiProperty({ format: 'uuid' })
   userId?: string;
   userId?: string;

+ 2 - 1
server/src/domain/auth/dto/login-credential.dto.ts

@@ -4,8 +4,9 @@ import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
 
 
 export class LoginCredentialDto {
 export class LoginCredentialDto {
   @IsEmail({ require_tld: false })
   @IsEmail({ require_tld: false })
+  @Transform(({ value }) => value?.toLowerCase())
+  @IsNotEmpty()
   @ApiProperty({ example: 'testuser@email.com' })
   @ApiProperty({ example: 'testuser@email.com' })
-  @Transform(({ value }) => value.toLowerCase())
   email!: string;
   email!: string;
 
 
   @IsString()
   @IsString()

+ 2 - 1
server/src/domain/auth/dto/sign-up.dto.ts

@@ -4,8 +4,9 @@ import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
 
 
 export class SignUpDto {
 export class SignUpDto {
   @IsEmail({ require_tld: false })
   @IsEmail({ require_tld: false })
+  @Transform(({ value }) => value?.toLowerCase())
+  @IsNotEmpty()
   @ApiProperty({ example: 'testuser@email.com' })
   @ApiProperty({ example: 'testuser@email.com' })
-  @Transform(({ value }) => value.toLowerCase())
   email!: string;
   email!: string;
 
 
   @IsString()
   @IsString()

+ 22 - 2
server/src/domain/domain.util.ts

@@ -1,6 +1,6 @@
 import { applyDecorators } from '@nestjs/common';
 import { applyDecorators } from '@nestjs/common';
 import { ApiProperty } from '@nestjs/swagger';
 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 { basename, extname } from 'node:path';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 
 
@@ -13,7 +13,7 @@ export function ValidateUUID({ optional, each }: Options = { optional: false, ea
   return applyDecorators(
   return applyDecorators(
     IsUUID('4', { each }),
     IsUUID('4', { each }),
     ApiProperty({ format: 'uuid' }),
     ApiProperty({ format: 'uuid' }),
-    optional ? IsOptional() : IsNotEmpty(),
+    optional ? Optional() : IsNotEmpty(),
     each ? IsArray() : IsString(),
     each ? IsArray() : IsString(),
   );
   );
 }
 }
@@ -92,3 +92,23 @@ export async function* usePagination<T>(
     yield result.items;
     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);
+}

+ 3 - 2
server/src/domain/job/job.dto.ts

@@ -1,6 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 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 { JobCommand, QueueName } from './job.constants';
+import { Optional } from '../domain.util';
 
 
 export class JobIdParamDto {
 export class JobIdParamDto {
   @IsNotEmpty()
   @IsNotEmpty()
@@ -15,7 +16,7 @@ export class JobCommandDto {
   @ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' })
   @ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' })
   command!: JobCommand;
   command!: JobCommand;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   force!: boolean;
   force!: boolean;
 }
 }

+ 8 - 47
server/src/domain/person/person.dto.ts

@@ -1,47 +1,38 @@
 import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
 import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform, Type } from 'class-transformer';
 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 {
 export class PersonUpdateDto {
   /**
   /**
    * Person name.
    * Person name.
    */
    */
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   name?: string;
   name?: string;
 
 
   /**
   /**
    * Person date of birth.
    * Person date of birth.
+   * Note: the mobile app cannot currently set the birth date to null.
    */
    */
-  @IsOptional()
+  @Optional({ nullable: true })
   @IsDate()
   @IsDate()
   @Type(() => Date)
   @Type(() => Date)
-  @ValidateIf((value) => value !== null)
   @ApiProperty({ format: 'date' })
   @ApiProperty({ format: 'date' })
   birthDate?: Date | null;
   birthDate?: Date | null;
 
 
   /**
   /**
    * Asset is used to get the feature face thumbnail.
    * Asset is used to get the feature face thumbnail.
    */
    */
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   featureFaceAssetId?: string;
   featureFaceAssetId?: string;
 
 
   /**
   /**
    * Person visibility
    * Person visibility
    */
    */
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isHidden?: boolean;
   isHidden?: boolean;
 }
 }
@@ -53,43 +44,13 @@ export class PeopleUpdateDto {
   people!: PeopleUpdateItem[];
   people!: PeopleUpdateItem[];
 }
 }
 
 
-export class PeopleUpdateItem {
+export class PeopleUpdateItem extends PersonUpdateDto {
   /**
   /**
    * Person id.
    * Person id.
    */
    */
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
   id!: string;
   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 {
 export class MergePersonDto {

+ 18 - 18
server/src/domain/search/dto/search.dto.ts

@@ -1,87 +1,87 @@
 import { AssetType } from '@app/infra/entities';
 import { AssetType } from '@app/infra/entities';
 import { Transform } from 'class-transformer';
 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 {
 export class SearchDto {
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   q?: string;
   q?: string;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   query?: string;
   query?: string;
 
 
   @IsBoolean()
   @IsBoolean()
-  @IsOptional()
+  @Optional()
   @Transform(toBoolean)
   @Transform(toBoolean)
   clip?: boolean;
   clip?: boolean;
 
 
   @IsEnum(AssetType)
   @IsEnum(AssetType)
-  @IsOptional()
+  @Optional()
   type?: AssetType;
   type?: AssetType;
 
 
   @IsBoolean()
   @IsBoolean()
-  @IsOptional()
+  @Optional()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isFavorite?: boolean;
   isFavorite?: boolean;
 
 
   @IsBoolean()
   @IsBoolean()
-  @IsOptional()
+  @Optional()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isArchived?: boolean;
   isArchived?: boolean;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   'exifInfo.city'?: string;
   'exifInfo.city'?: string;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   'exifInfo.state'?: string;
   'exifInfo.state'?: string;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   'exifInfo.country'?: string;
   'exifInfo.country'?: string;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   'exifInfo.make'?: string;
   'exifInfo.make'?: string;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   'exifInfo.model'?: string;
   'exifInfo.model'?: string;
 
 
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
-  @IsOptional()
+  @Optional()
   'exifInfo.projectionType'?: string;
   'exifInfo.projectionType'?: string;
 
 
   @IsString({ each: true })
   @IsString({ each: true })
   @IsArray()
   @IsArray()
-  @IsOptional()
+  @Optional()
   @Transform(({ value }) => value.split(','))
   @Transform(({ value }) => value.split(','))
   'smartInfo.objects'?: string[];
   'smartInfo.objects'?: string[];
 
 
   @IsString({ each: true })
   @IsString({ each: true })
   @IsArray()
   @IsArray()
-  @IsOptional()
+  @Optional()
   @Transform(({ value }) => value.split(','))
   @Transform(({ value }) => value.split(','))
   'smartInfo.tags'?: string[];
   'smartInfo.tags'?: string[];
 
 
   @IsBoolean()
   @IsBoolean()
-  @IsOptional()
+  @Optional()
   @Transform(toBoolean)
   @Transform(toBoolean)
   recent?: boolean;
   recent?: boolean;
 
 
   @IsBoolean()
   @IsBoolean()
-  @IsOptional()
+  @Optional()
   @Transform(toBoolean)
   @Transform(toBoolean)
   motion?: boolean;
   motion?: boolean;
 }
 }

+ 12 - 12
server/src/domain/shared-link/shared-link.dto.ts

@@ -1,8 +1,8 @@
 import { SharedLinkType } from '@app/infra/entities';
 import { SharedLinkType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 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 {
 export class SharedLinkCreateDto {
   @IsEnum(SharedLinkType)
   @IsEnum(SharedLinkType)
@@ -16,40 +16,40 @@ export class SharedLinkCreateDto {
   albumId?: string;
   albumId?: string;
 
 
   @IsString()
   @IsString()
-  @IsOptional()
+  @Optional()
   description?: string;
   description?: string;
 
 
   @IsDate()
   @IsDate()
   @Type(() => Date)
   @Type(() => Date)
-  @IsOptional()
+  @Optional({ nullable: true })
   expiresAt?: Date | null = null;
   expiresAt?: Date | null = null;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   allowUpload?: boolean = false;
   allowUpload?: boolean = false;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   allowDownload?: boolean = true;
   allowDownload?: boolean = true;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   showExif?: boolean = true;
   showExif?: boolean = true;
 }
 }
 
 
 export class SharedLinkEditDto {
 export class SharedLinkEditDto {
-  @IsOptional()
+  @Optional()
   description?: string;
   description?: string;
 
 
-  @IsOptional()
+  @Optional({ nullable: true })
   expiresAt?: Date | null;
   expiresAt?: Date | null;
 
 
-  @IsOptional()
+  @Optional()
   allowUpload?: boolean;
   allowUpload?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   allowDownload?: boolean;
   allowDownload?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   showExif?: boolean;
   showExif?: boolean;
 }
 }

+ 4 - 3
server/src/domain/smart-info/dto/model-config.dto.ts

@@ -1,7 +1,8 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 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 { CLIPMode, ModelType } from '../machine-learning.interface';
+import { Optional } from '../../domain.util';
 
 
 export class ModelConfig {
 export class ModelConfig {
   @IsBoolean()
   @IsBoolean()
@@ -12,7 +13,7 @@ export class ModelConfig {
   modelName!: string;
   modelName!: string;
 
 
   @IsEnum(ModelType)
   @IsEnum(ModelType)
-  @IsOptional()
+  @Optional()
   @ApiProperty({ enumName: 'ModelType', enum: ModelType })
   @ApiProperty({ enumName: 'ModelType', enum: ModelType })
   modelType?: ModelType;
   modelType?: ModelType;
 }
 }
@@ -28,7 +29,7 @@ export class ClassificationConfig extends ModelConfig {
 
 
 export class CLIPConfig extends ModelConfig {
 export class CLIPConfig extends ModelConfig {
   @IsEnum(CLIPMode)
   @IsEnum(CLIPMode)
-  @IsOptional()
+  @Optional()
   @ApiProperty({ enumName: 'CLIPMode', enum: CLIPMode })
   @ApiProperty({ enumName: 'CLIPMode', enum: CLIPMode })
   mode?: CLIPMode;
   mode?: CLIPMode;
 }
 }

+ 3 - 2
server/src/domain/tag/tag.dto.ts

@@ -1,6 +1,7 @@
 import { TagType } from '@app/infra/entities';
 import { TagType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 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 {
 export class CreateTagDto {
   @IsString()
   @IsString()
@@ -15,6 +16,6 @@ export class CreateTagDto {
 
 
 export class UpdateTagDto {
 export class UpdateTagDto {
   @IsString()
   @IsString()
-  @IsOptional()
+  @Optional()
   name?: string;
   name?: string;
 }
 }

+ 5 - 5
server/src/domain/user/dto/create-user.dto.ts

@@ -1,6 +1,6 @@
 import { Transform } from 'class-transformer';
 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 {
 export class CreateUserDto {
   @IsEmail({ require_tld: false })
   @IsEmail({ require_tld: false })
@@ -19,16 +19,16 @@ export class CreateUserDto {
   @IsString()
   @IsString()
   lastName!: string;
   lastName!: string;
 
 
-  @IsOptional()
+  @Optional({ nullable: true })
   @IsString()
   @IsString()
   @Transform(toSanitized)
   @Transform(toSanitized)
   storageLabel?: string | null;
   storageLabel?: string | null;
 
 
-  @IsOptional()
+  @Optional({ nullable: true })
   @IsString()
   @IsString()
   externalPath?: string | null;
   externalPath?: string | null;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   memoriesEnabled?: boolean;
   memoriesEnabled?: boolean;
 }
 }

+ 11 - 11
server/src/domain/user/dto/update-user.dto.ts

@@ -1,35 +1,35 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 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 {
 export class UpdateUserDto {
-  @IsOptional()
+  @Optional()
   @IsEmail({ require_tld: false })
   @IsEmail({ require_tld: false })
   @Transform(toEmail)
   @Transform(toEmail)
   email?: string;
   email?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsNotEmpty()
   @IsNotEmpty()
   @IsString()
   @IsString()
   password?: string;
   password?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
   firstName?: string;
   firstName?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   @IsNotEmpty()
   @IsNotEmpty()
   lastName?: string;
   lastName?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   @Transform(toSanitized)
   @Transform(toSanitized)
   storageLabel?: string;
   storageLabel?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   externalPath?: string;
   externalPath?: string;
 
 
@@ -38,15 +38,15 @@ export class UpdateUserDto {
   @ApiProperty({ format: 'uuid' })
   @ApiProperty({ format: 'uuid' })
   id!: string;
   id!: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isAdmin?: boolean;
   isAdmin?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   shouldChangePassword?: boolean;
   shouldChangePassword?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   memoriesEnabled?: boolean;
   memoriesEnabled?: boolean;
 }
 }

+ 3 - 2
server/src/domain/user/dto/user-count.dto.ts

@@ -1,9 +1,10 @@
+import { Optional } from '../../domain.util';
 import { Transform } from 'class-transformer';
 import { Transform } from 'class-transformer';
-import { IsBoolean, IsOptional } from 'class-validator';
+import { IsBoolean } from 'class-validator';
 
 
 export class UserCountDto {
 export class UserCountDto {
   @IsBoolean()
   @IsBoolean()
-  @IsOptional()
+  @Optional()
   @Transform(({ value }) => value === 'true')
   @Transform(({ value }) => value === 'true')
   /**
   /**
    * When true, return the number of admins accounts
    * When true, return the number of admins accounts

+ 7 - 7
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 { ApiProperty } from '@nestjs/swagger';
 import { Transform, Type } from 'class-transformer';
 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 {
 export class AssetSearchDto {
-  @IsOptional()
+  @Optional()
   @IsNotEmpty()
   @IsNotEmpty()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isFavorite?: boolean;
   isFavorite?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsNotEmpty()
   @IsNotEmpty()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isArchived?: boolean;
   isArchived?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsNumber()
   @IsNumber()
   skip?: number;
   skip?: number;
 
 
-  @IsOptional()
+  @Optional()
   @IsUUID('4')
   @IsUUID('4')
   @ApiProperty({ format: 'uuid' })
   @ApiProperty({ format: 'uuid' })
   userId?: string;
   userId?: string;
 
 
-  @IsOptional()
+  @Optional()
   @IsDate()
   @IsDate()
   @Type(() => Date)
   @Type(() => Date)
   updatedAfter?: Date;
   updatedAfter?: Date;

+ 8 - 8
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 { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 import { Transform } from 'class-transformer';
-import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
 
 
 export class CreateAssetBase {
 export class CreateAssetBase {
   @IsNotEmpty()
   @IsNotEmpty()
@@ -19,20 +19,20 @@ export class CreateAssetBase {
   @IsNotEmpty()
   @IsNotEmpty()
   isFavorite!: boolean;
   isFavorite!: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isArchived?: boolean;
   isArchived?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isVisible?: boolean;
   isVisible?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   duration?: string;
   duration?: string;
 }
 }
 
 
 export class CreateAssetDto extends CreateAssetBase {
 export class CreateAssetDto extends CreateAssetBase {
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isReadOnly?: boolean = false;
   isReadOnly?: boolean = false;
@@ -50,7 +50,7 @@ export class CreateAssetDto extends CreateAssetBase {
 }
 }
 
 
 export class ImportAssetDto extends CreateAssetBase {
 export class ImportAssetDto extends CreateAssetBase {
-  @IsOptional()
+  @Optional()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isReadOnly?: boolean = true;
   isReadOnly?: boolean = true;
 
 
@@ -60,7 +60,7 @@ export class ImportAssetDto extends CreateAssetBase {
   assetPath!: string;
   assetPath!: string;
 
 
   @IsString()
   @IsString()
-  @IsOptional()
+  @Optional()
   @IsNotEmpty()
   @IsNotEmpty()
   @Transform(toSanitized)
   @Transform(toSanitized)
   sidecarPath?: string;
   sidecarPath?: string;

+ 0 - 45
server/src/immich/api-v1/asset/dto/create-exif.dto.ts

@@ -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;
-}

+ 3 - 2
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 { ApiProperty } from '@nestjs/swagger';
-import { IsEnum, IsOptional } from 'class-validator';
+import { IsEnum } from 'class-validator';
 
 
 export enum GetAssetThumbnailFormatEnum {
 export enum GetAssetThumbnailFormatEnum {
   JPEG = 'JPEG',
   JPEG = 'JPEG',
@@ -7,7 +8,7 @@ export enum GetAssetThumbnailFormatEnum {
 }
 }
 
 
 export class GetAssetThumbnailDto {
 export class GetAssetThumbnailDto {
-  @IsOptional()
+  @Optional()
   @IsEnum(GetAssetThumbnailFormatEnum)
   @IsEnum(GetAssetThumbnailFormatEnum)
   @ApiProperty({
   @ApiProperty({
     type: String,
     type: String,

+ 4 - 4
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 { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 import { Transform } from 'class-transformer';
-import { IsBoolean, IsOptional } from 'class-validator';
+import { IsBoolean } from 'class-validator';
 
 
 export class ServeFileDto {
 export class ServeFileDto {
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   @ApiProperty({ type: Boolean, title: 'Is serve thumbnail (resize) file' })
   @ApiProperty({ type: Boolean, title: 'Is serve thumbnail (resize) file' })
   isThumb?: boolean;
   isThumb?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   @ApiProperty({ type: Boolean, title: 'Is request made from web' })
   @ApiProperty({ type: Boolean, title: 'Is request made from web' })

+ 6 - 5
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 { ApiProperty } from '@nestjs/swagger';
-import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { IsArray, IsBoolean, IsNotEmpty, IsString } from 'class-validator';
 
 
 export class UpdateAssetDto {
 export class UpdateAssetDto {
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isFavorite?: boolean;
   isFavorite?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsBoolean()
   @IsBoolean()
   isArchived?: boolean;
   isArchived?: boolean;
 
 
-  @IsOptional()
+  @Optional()
   @IsArray()
   @IsArray()
   @IsString({ each: true })
   @IsString({ each: true })
   @IsNotEmpty({ each: true })
   @IsNotEmpty({ each: true })
@@ -26,7 +27,7 @@ export class UpdateAssetDto {
   })
   })
   tagIds?: string[];
   tagIds?: string[];
 
 
-  @IsOptional()
+  @Optional()
   @IsString()
   @IsString()
   description?: string;
   description?: string;
 }
 }

+ 47 - 9
server/test/e2e/auth.e2e-spec.ts

@@ -2,7 +2,16 @@ import { AppModule, AuthController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
 import { Test, TestingModule } from '@nestjs/testing';
 import request from 'supertest';
 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';
 import { api, db } from '../test-utils';
 
 
 const firstName = 'Immich';
 const firstName = 'Immich';
@@ -64,7 +73,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     it('should sign up the admin with a local domain', async () => {
     it('should sign up the admin with a local domain', async () => {
       const { status, body } = await request(server)
       const { status, body } = await request(server)
         .post('/auth/admin-sign-up')
         .post('/auth/admin-sign-up')
-        .send({ ...signupStub, email: 'admin@local' });
+        .send({ ...adminSignupStub, email: 'admin@local' });
       expect(status).toEqual(201);
       expect(status).toEqual(201);
       expect(body).toEqual({ ...signupResponseStub, email: 'admin@local' });
       expect(body).toEqual({ ...signupResponseStub, email: 'admin@local' });
     });
     });
@@ -72,7 +81,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     it('should transform email to lower case', async () => {
     it('should transform email to lower case', async () => {
       const { status, body } = await request(server)
       const { status, body } = await request(server)
         .post('/auth/admin-sign-up')
         .post('/auth/admin-sign-up')
-        .send({ ...signupStub, email: 'aDmIn@IMMICH.app' });
+        .send({ ...adminSignupStub, email: 'aDmIn@IMMICH.app' });
       expect(status).toEqual(201);
       expect(status).toEqual(201);
       expect(body).toEqual(signupResponseStub);
       expect(body).toEqual(signupResponseStub);
     });
     });
@@ -80,11 +89,21 @@ describe(`${AuthController.name} (e2e)`, () => {
     it('should not allow a second admin to sign up', async () => {
     it('should not allow a second admin to sign up', async () => {
       await api.adminSignUp(server);
       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(status).toBe(400);
       expect(body).toEqual(errorStub.alreadyHasAdmin);
       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`, () => {
   describe(`POST /auth/login`, () => {
@@ -94,6 +113,16 @@ describe(`${AuthController.name} (e2e)`, () => {
       expect(status).toBe(401);
       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 () => {
     it('should accept a correct password', async () => {
       const { status, body, headers } = await request(server).post('/auth/login').send({ email, password });
       const { status, body, headers } = await request(server).post('/auth/login').send({ email, password });
       expect(status).toBe(201);
       expect(status).toBe(201);
@@ -183,17 +212,26 @@ describe(`${AuthController.name} (e2e)`, () => {
 
 
   describe('POST /auth/change-password', () => {
   describe('POST /auth/change-password', () => {
     it('should require authentication', async () => {
     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(status).toBe(401);
       expect(body).toEqual(errorStub.unauthorized);
       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 () => {
     it('should require the current password', async () => {
       const { status, body } = await request(server)
       const { status, body } = await request(server)
         .post(`/auth/change-password`)
         .post(`/auth/change-password`)
-        .send({ password: 'wrong-password', newPassword: 'Password1234' })
+        .send({ ...changePasswordStub, password: 'wrong-password' })
         .set('Authorization', `Bearer ${accessToken}`);
         .set('Authorization', `Bearer ${accessToken}`);
       expect(status).toBe(400);
       expect(status).toBe(400);
       expect(body).toEqual(errorStub.wrongPassword);
       expect(body).toEqual(errorStub.wrongPassword);
@@ -202,7 +240,7 @@ describe(`${AuthController.name} (e2e)`, () => {
     it('should change the password', async () => {
     it('should change the password', async () => {
       const { status } = await request(server)
       const { status } = await request(server)
         .post(`/auth/change-password`)
         .post(`/auth/change-password`)
-        .send({ password: 'Password123', newPassword: 'Password1234' })
+        .send(changePasswordStub)
         .set('Authorization', `Bearer ${accessToken}`);
         .set('Authorization', `Bearer ${accessToken}`);
       expect(status).toBe(200);
       expect(status).toBe(200);
 
 

+ 15 - 1
server/test/e2e/person.e2e-spec.ts

@@ -40,7 +40,20 @@ describe(`${PersonController.name}`, () => {
       expect(body).toEqual(errorStub.unauthorized);
       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>(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]) {
       for (const birthDate of [false, 'false', '123567', 123456]) {
         const { status, body } = await request(server)
         const { status, body } = await request(server)
           .put(`/person/${uuidStub.notFound}`)
           .put(`/person/${uuidStub.notFound}`)
@@ -50,6 +63,7 @@ describe(`${PersonController.name}`, () => {
         expect(body).toEqual(errorStub.badRequest);
         expect(body).toEqual(errorStub.badRequest);
       }
       }
     });
     });
+
     it('should update a date of birth', async () => {
     it('should update a date of birth', async () => {
       const personRepository = app.get<IPersonRepository>(IPersonRepository);
       const personRepository = app.get<IPersonRepository>(IPersonRepository);
       const person = await personRepository.create({ ownerId: loginResponse.userId });
       const person = await personRepository.create({ ownerId: loginResponse.userId });

+ 24 - 4
server/test/e2e/user.e2e-spec.ts

@@ -3,7 +3,7 @@ import { AppModule, UserController } from '@app/immich';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
 import { Test, TestingModule } from '@nestjs/testing';
 import request from 'supertest';
 import request from 'supertest';
-import { errorStub } from '../fixtures';
+import { errorStub, userSignupStub, userStub } from '../fixtures';
 import { api, db } from '../test-utils';
 import { api, db } from '../test-utils';
 
 
 describe(`${UserController.name}`, () => {
 describe(`${UserController.name}`, () => {
@@ -118,13 +118,22 @@ describe(`${UserController.name}`, () => {
 
 
   describe('POST /user', () => {
   describe('POST /user', () => {
     it('should require authentication', async () => {
     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(status).toBe(401);
       expect(body).toEqual(errorStub.unauthorized);
       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 () => {
     it('should ignore `isAdmin`', async () => {
       const { status, body } = await request(server)
       const { status, body } = await request(server)
         .post(`/user`)
         .post(`/user`)
@@ -170,6 +179,17 @@ describe(`${UserController.name}`, () => {
       expect(body).toEqual(errorStub.unauthorized);
       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 () => {
     it('should not allow a non-admin to become an admin', async () => {
       const user = await api.userApi.create(server, accessToken, {
       const user = await api.userApi.create(server, accessToken, {
         email: 'user1@immich.app',
         email: 'user1@immich.app',

+ 11 - 1
server/test/fixtures/auth.stub.ts

@@ -1,12 +1,17 @@
 import { AuthUserDto } from '@app/domain';
 import { AuthUserDto } from '@app/domain';
 
 
-export const signupStub = {
+export const adminSignupStub = {
   firstName: 'Immich',
   firstName: 'Immich',
   lastName: 'Admin',
   lastName: 'Admin',
   email: 'admin@immich.app',
   email: 'admin@immich.app',
   password: 'Password123',
   password: 'Password123',
 };
 };
 
 
+export const userSignupStub = {
+  ...adminSignupStub,
+  memoriesEnabled: true,
+};
+
 export const signupResponseStub = {
 export const signupResponseStub = {
   id: expect.any(String),
   id: expect.any(String),
   email: 'admin@immich.app',
   email: 'admin@immich.app',
@@ -22,6 +27,11 @@ export const loginStub = {
   },
   },
 };
 };
 
 
+export const changePasswordStub = {
+  password: 'Password123',
+  newPassword: 'Password1234',
+};
+
 export const authStub = {
 export const authStub = {
   admin: Object.freeze<AuthUserDto>({
   admin: Object.freeze<AuthUserDto>({
     id: 'admin_id',
     id: 'admin_id',

+ 2 - 2
server/test/test-utils.ts

@@ -14,7 +14,7 @@ import {
 } from '@app/domain';
 } from '@app/domain';
 import { dataSource } from '@app/infra';
 import { dataSource } from '@app/infra';
 import request from 'supertest';
 import request from 'supertest';
-import { loginResponseStub, loginStub, signupResponseStub, signupStub } from './fixtures';
+import { loginResponseStub, loginStub, signupResponseStub, adminSignupStub } from './fixtures';
 
 
 export const db = {
 export const db = {
   reset: async () => {
   reset: async () => {
@@ -49,7 +49,7 @@ export function getAuthUser(): AuthUserDto {
 
 
 export const api = {
 export const api = {
   adminSignUp: async (server: any) => {
   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(status).toBe(201);
     expect(body).toEqual(signupResponseStub);
     expect(body).toEqual(signupResponseStub);

+ 2 - 2
web/src/api/open-api/api.ts

@@ -1982,7 +1982,7 @@ export interface PeopleUpdateDto {
  */
  */
 export interface PeopleUpdateItem {
 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}
      * @type {string}
      * @memberof PeopleUpdateItem
      * @memberof PeopleUpdateItem
      */
      */
@@ -2056,7 +2056,7 @@ export interface PersonResponseDto {
  */
  */
 export interface PersonUpdateDto {
 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}
      * @type {string}
      * @memberof PersonUpdateDto
      * @memberof PersonUpdateDto
      */
      */