From fcd23ee04333e0348a0b8e37a26e2fa5b052d2c4 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Thu, 23 Apr 2026 14:21:40 +0200 Subject: [PATCH] refactor: use zod codec for response DTO serialization --- .../openapi/lib/model/album_response_dto.dart | 30 ++++++--- .../openapi/lib/model/asset_response_dto.dart | 30 ++++++--- .../openapi/lib/model/exif_response_dto.dart | 12 ++-- .../lib/model/partner_response_dto.dart | 6 +- .../lib/model/person_response_dto.dart | 12 ++-- .../model/person_with_faces_response_dto.dart | 12 ++-- .../openapi/lib/model/tag_response_dto.dart | 12 ++-- .../lib/model/user_admin_response_dto.dart | 6 +- .../openapi/lib/model/user_response_dto.dart | 6 +- open-api/immich-openapi-specs.json | 42 +++++++++++++ server/src/app.module.ts | 8 +-- server/src/controllers/album.controller.ts | 6 ++ .../src/controllers/asset.controller.spec.ts | 7 +++ server/src/controllers/asset.controller.ts | 3 + .../src/controllers/auth.controller.spec.ts | 4 ++ server/src/controllers/auth.controller.ts | 3 + server/src/controllers/face.controller.ts | 2 + server/src/controllers/oauth.controller.ts | 3 + server/src/controllers/person.controller.ts | 5 ++ server/src/controllers/search.controller.ts | 5 ++ server/src/controllers/tag.controller.ts | 6 ++ .../src/controllers/user-admin.controller.ts | 7 +++ server/src/controllers/user.controller.ts | 5 ++ server/src/controllers/view.controller.ts | 2 + server/src/dtos/album-response.dto.spec.ts | 4 +- server/src/dtos/album.dto.ts | 32 ++++------ server/src/dtos/asset-response.dto.ts | 62 +++++++------------ server/src/dtos/auth.dto.ts | 5 +- server/src/dtos/exif.dto.ts | 14 ++--- server/src/dtos/person.dto.ts | 19 +++--- server/src/dtos/tag.dto.ts | 15 ++--- server/src/dtos/user.dto.ts | 22 ++++--- server/src/services/album.service.ts | 13 ++-- server/src/services/person.service.spec.ts | 10 +-- server/src/utils/date.ts | 20 ------ server/src/utils/duplicate.spec.ts | 12 ++-- server/src/validation.ts | 12 ++-- server/test/fixtures/tag.stub.ts | 8 +-- 38 files changed, 286 insertions(+), 196 deletions(-) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index fd90f23e3a..0a89148ac1 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -157,10 +157,14 @@ class AlbumResponseDto { json[r'albumUsers'] = this.albumUsers; json[r'assetCount'] = this.assetCount; json[r'contributorCounts'] = this.contributorCounts; - json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.createdAt.millisecondsSinceEpoch + : this.createdAt.toUtc().toIso8601String(); json[r'description'] = this.description; if (this.endDate != null) { - json[r'endDate'] = this.endDate!.toUtc().toIso8601String(); + json[r'endDate'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.endDate!.millisecondsSinceEpoch + : this.endDate!.toUtc().toIso8601String(); } else { // json[r'endDate'] = null; } @@ -168,7 +172,9 @@ class AlbumResponseDto { json[r'id'] = this.id; json[r'isActivityEnabled'] = this.isActivityEnabled; if (this.lastModifiedAssetTimestamp != null) { - json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); + json[r'lastModifiedAssetTimestamp'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.lastModifiedAssetTimestamp!.millisecondsSinceEpoch + : this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); } else { // json[r'lastModifiedAssetTimestamp'] = null; } @@ -179,11 +185,15 @@ class AlbumResponseDto { } json[r'shared'] = this.shared; if (this.startDate != null) { - json[r'startDate'] = this.startDate!.toUtc().toIso8601String(); + json[r'startDate'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.startDate!.millisecondsSinceEpoch + : this.startDate!.toUtc().toIso8601String(); } else { // json[r'startDate'] = null; } - json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.updatedAt.millisecondsSinceEpoch + : this.updatedAt.toUtc().toIso8601String(); return json; } @@ -201,17 +211,17 @@ class AlbumResponseDto { albumUsers: AlbumUserResponseDto.listFromJson(json[r'albumUsers']), assetCount: mapValueOfType(json, r'assetCount')!, contributorCounts: ContributorCountResponseDto.listFromJson(json[r'contributorCounts']), - createdAt: mapDateTime(json, r'createdAt', r'')!, + createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, description: mapValueOfType(json, r'description')!, - endDate: mapDateTime(json, r'endDate', r''), + endDate: mapDateTime(json, r'endDate', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), hasSharedLink: mapValueOfType(json, r'hasSharedLink')!, id: mapValueOfType(json, r'id')!, isActivityEnabled: mapValueOfType(json, r'isActivityEnabled')!, - lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), + lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), order: AssetOrder.fromJson(json[r'order']), shared: mapValueOfType(json, r'shared')!, - startDate: mapDateTime(json, r'startDate', r''), - updatedAt: mapDateTime(json, r'updatedAt', r'')!, + startDate: mapDateTime(json, r'startDate', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, ); } return null; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 324d12fcbf..8b71a38b21 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -246,7 +246,9 @@ class AssetResponseDto { Map toJson() { final json = {}; json[r'checksum'] = this.checksum; - json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.createdAt.millisecondsSinceEpoch + : this.createdAt.toUtc().toIso8601String(); if (this.duplicateId != null) { json[r'duplicateId'] = this.duplicateId; } else { @@ -262,8 +264,12 @@ class AssetResponseDto { } else { // json[r'exifInfo'] = null; } - json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); - json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); + json[r'fileCreatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.fileCreatedAt.millisecondsSinceEpoch + : this.fileCreatedAt.toUtc().toIso8601String(); + json[r'fileModifiedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.fileModifiedAt.millisecondsSinceEpoch + : this.fileModifiedAt.toUtc().toIso8601String(); json[r'hasMetadata'] = this.hasMetadata; if (this.height != null) { json[r'height'] = this.height; @@ -286,7 +292,9 @@ class AssetResponseDto { } else { // json[r'livePhotoVideoId'] = null; } - json[r'localDateTime'] = this.localDateTime.toUtc().toIso8601String(); + json[r'localDateTime'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.localDateTime.millisecondsSinceEpoch + : this.localDateTime.toUtc().toIso8601String(); json[r'originalFileName'] = this.originalFileName; if (this.originalMimeType != null) { json[r'originalMimeType'] = this.originalMimeType; @@ -319,7 +327,9 @@ class AssetResponseDto { } json[r'type'] = this.type; json[r'unassignedFaces'] = this.unassignedFaces; - json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.updatedAt.millisecondsSinceEpoch + : this.updatedAt.toUtc().toIso8601String(); json[r'visibility'] = this.visibility; if (this.width != null) { json[r'width'] = this.width; @@ -339,12 +349,12 @@ class AssetResponseDto { return AssetResponseDto( checksum: mapValueOfType(json, r'checksum')!, - createdAt: mapDateTime(json, r'createdAt', r'')!, + createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, duplicateId: mapValueOfType(json, r'duplicateId'), duration: mapValueOfType(json, r'duration'), exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), - fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, - fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, hasMetadata: mapValueOfType(json, r'hasMetadata')!, height: json[r'height'] == null ? null @@ -357,7 +367,7 @@ class AssetResponseDto { isTrashed: mapValueOfType(json, r'isTrashed')!, libraryId: mapValueOfType(json, r'libraryId'), livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), - localDateTime: mapDateTime(json, r'localDateTime', r'')!, + localDateTime: mapDateTime(json, r'localDateTime', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, originalFileName: mapValueOfType(json, r'originalFileName')!, originalMimeType: mapValueOfType(json, r'originalMimeType'), originalPath: mapValueOfType(json, r'originalPath')!, @@ -370,7 +380,7 @@ class AssetResponseDto { thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), - updatedAt: mapDateTime(json, r'updatedAt', r'')!, + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, width: json[r'width'] == null ? null diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 64a5a73bed..bf9a4e4393 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -177,7 +177,9 @@ class ExifResponseDto { // json[r'country'] = null; } if (this.dateTimeOriginal != null) { - json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String(); + json[r'dateTimeOriginal'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.dateTimeOriginal!.millisecondsSinceEpoch + : this.dateTimeOriginal!.toUtc().toIso8601String(); } else { // json[r'dateTimeOriginal'] = null; } @@ -247,7 +249,9 @@ class ExifResponseDto { // json[r'model'] = null; } if (this.modifyDate != null) { - json[r'modifyDate'] = this.modifyDate!.toUtc().toIso8601String(); + json[r'modifyDate'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.modifyDate!.millisecondsSinceEpoch + : this.modifyDate!.toUtc().toIso8601String(); } else { // json[r'modifyDate'] = null; } @@ -290,7 +294,7 @@ class ExifResponseDto { return ExifResponseDto( city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), - dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), + dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), description: mapValueOfType(json, r'description'), exifImageHeight: json[r'exifImageHeight'] == null ? null @@ -318,7 +322,7 @@ class ExifResponseDto { : num.parse('${json[r'longitude']}'), make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), - modifyDate: mapDateTime(json, r'modifyDate', r''), + modifyDate: mapDateTime(json, r'modifyDate', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), orientation: mapValueOfType(json, r'orientation'), projectionType: mapValueOfType(json, r'projectionType'), rating: json[r'rating'] == null diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index f4612cc98a..7f5e7025e0 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -83,7 +83,9 @@ class PartnerResponseDto { // json[r'inTimeline'] = null; } json[r'name'] = this.name; - json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); + json[r'profileChangedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.profileChangedAt.millisecondsSinceEpoch + : this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; return json; } @@ -102,7 +104,7 @@ class PartnerResponseDto { id: mapValueOfType(json, r'id')!, inTimeline: mapValueOfType(json, r'inTimeline'), name: mapValueOfType(json, r'name')!, - profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, ); } diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 455dfb98d6..d6ae66faf1 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -94,7 +94,9 @@ class PersonResponseDto { Map toJson() { final json = {}; if (this.birthDate != null) { - json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + json[r'birthDate'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$/') + ? this.birthDate!.millisecondsSinceEpoch + : _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; } @@ -113,7 +115,9 @@ class PersonResponseDto { json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; if (this.updatedAt != null) { - json[r'updatedAt'] = this.updatedAt!.toUtc().toIso8601String(); + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.updatedAt!.millisecondsSinceEpoch + : this.updatedAt!.toUtc().toIso8601String(); } else { // json[r'updatedAt'] = null; } @@ -129,14 +133,14 @@ class PersonResponseDto { final json = value.cast(); return PersonResponseDto( - birthDate: mapDateTime(json, r'birthDate', r''), + birthDate: mapDateTime(json, r'birthDate', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$/'), color: mapValueOfType(json, r'color'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, - updatedAt: mapDateTime(json, r'updatedAt', r''), + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), ); } return null; diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index f710dff8b9..f08853e8a4 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -99,7 +99,9 @@ class PersonWithFacesResponseDto { Map toJson() { final json = {}; if (this.birthDate != null) { - json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + json[r'birthDate'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$/') + ? this.birthDate!.millisecondsSinceEpoch + : _dateFormatter.format(this.birthDate!.toUtc()); } else { // json[r'birthDate'] = null; } @@ -119,7 +121,9 @@ class PersonWithFacesResponseDto { json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; if (this.updatedAt != null) { - json[r'updatedAt'] = this.updatedAt!.toUtc().toIso8601String(); + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.updatedAt!.millisecondsSinceEpoch + : this.updatedAt!.toUtc().toIso8601String(); } else { // json[r'updatedAt'] = null; } @@ -135,7 +139,7 @@ class PersonWithFacesResponseDto { final json = value.cast(); return PersonWithFacesResponseDto( - birthDate: mapDateTime(json, r'birthDate', r''), + birthDate: mapDateTime(json, r'birthDate', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$/'), color: mapValueOfType(json, r'color'), faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), id: mapValueOfType(json, r'id')!, @@ -143,7 +147,7 @@ class PersonWithFacesResponseDto { isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, - updatedAt: mapDateTime(json, r'updatedAt', r''), + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), ); } return null; diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 9a71912153..6440690527 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -86,7 +86,9 @@ class TagResponseDto { } else { // json[r'color'] = null; } - json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.createdAt.millisecondsSinceEpoch + : this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; if (this.parentId != null) { @@ -94,7 +96,9 @@ class TagResponseDto { } else { // json[r'parentId'] = null; } - json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.updatedAt.millisecondsSinceEpoch + : this.updatedAt.toUtc().toIso8601String(); json[r'value'] = this.value; return json; } @@ -109,11 +113,11 @@ class TagResponseDto { return TagResponseDto( color: mapValueOfType(json, r'color'), - createdAt: mapDateTime(json, r'createdAt', r'')!, + createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, parentId: mapValueOfType(json, r'parentId'), - updatedAt: mapDateTime(json, r'updatedAt', r'')!, + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 09f8cedce4..30b07ca4af 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -153,7 +153,9 @@ class UserAdminResponseDto { } json[r'name'] = this.name; json[r'oauthId'] = this.oauthId; - json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); + json[r'profileChangedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.profileChangedAt.millisecondsSinceEpoch + : this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; if (this.quotaSizeInBytes != null) { json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; @@ -196,7 +198,7 @@ class UserAdminResponseDto { license: UserLicense.fromJson(json[r'license']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, - profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index f671072c72..896725be19 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -66,7 +66,9 @@ class UserResponseDto { json[r'email'] = this.email; json[r'id'] = this.id; json[r'name'] = this.name; - json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); + json[r'profileChangedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.profileChangedAt.millisecondsSinceEpoch + : this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; return json; } @@ -84,7 +86,7 @@ class UserResponseDto { email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, - profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ac1de35252..d0bfa6a0df 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15300,7 +15300,9 @@ }, "createdAt": { "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "description": { @@ -15309,7 +15311,9 @@ }, "endDate": { "description": "End date (latest asset)", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "hasSharedLink": { @@ -15326,7 +15330,9 @@ }, "lastModifiedAssetTimestamp": { "description": "Last modified asset timestamp", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "order": { @@ -15338,12 +15344,16 @@ }, "startDate": { "description": "Start date (earliest asset)", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "updatedAt": { "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" } }, @@ -16618,7 +16628,9 @@ }, "createdAt": { "description": "The UTC timestamp when the asset was originally uploaded to Immich.", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "duplicateId": { @@ -16636,12 +16648,16 @@ }, "fileCreatedAt": { "description": "The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "fileModifiedAt": { "description": "The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "hasMetadata": { @@ -16714,7 +16730,9 @@ }, "localDateTime": { "description": "The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months.", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "originalFileName": { @@ -16787,7 +16805,9 @@ }, "updatedAt": { "description": "The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "visibility": { @@ -17614,8 +17634,10 @@ "dateTimeOriginal": { "default": null, "description": "Original date/time", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "description": { @@ -17703,8 +17725,10 @@ "modifyDate": { "default": null, "description": "Modification date/time", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "orientation": { @@ -19245,7 +19269,9 @@ }, "profileChangedAt": { "description": "Profile change date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "profileImagePath": { @@ -19600,8 +19626,10 @@ "properties": { "birthDate": { "description": "Person date of birth", + "example": "2024-01-01", "format": "date", "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", "type": "string" }, "color": { @@ -19652,7 +19680,9 @@ }, "updatedAt": { "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string", "x-immich-history": [ { @@ -19729,8 +19759,10 @@ "properties": { "birthDate": { "description": "Person date of birth", + "example": "2024-01-01", "format": "date", "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", "type": "string" }, "color": { @@ -19787,7 +19819,9 @@ }, "updatedAt": { "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string", "x-immich-history": [ { @@ -24814,7 +24848,9 @@ }, "createdAt": { "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "id": { @@ -24831,7 +24867,9 @@ }, "updatedAt": { "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "value": { @@ -25485,7 +25523,9 @@ }, "profileChangedAt": { "description": "Profile change date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "profileImagePath": { @@ -25767,7 +25807,9 @@ }, "profileChangedAt": { "description": "Profile change date", + "example": "2024-01-01T00:00:00.000Z", "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, "profileImagePath": { diff --git a/server/src/app.module.ts b/server/src/app.module.ts index ae930762d0..b271469ef6 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -42,19 +42,19 @@ import { configureUserAgent } from 'src/utils/fetch'; const common = [...repositories, ...services, GlobalExceptionFilter]; +const configRepository = new ConfigRepository(); +const { bull, cls, database, otel } = configRepository.getEnv(); + const commonMiddleware = [ { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useClass: ZodValidationPipe }, - { provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor }, + ...(configRepository.isDev() ? [{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor }] : []), { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, ]; const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }]; -const configRepository = new ConfigRepository(); -const { bull, cls, database, otel } = configRepository.getEnv(); - const commonImports = [ ClsModule.forRoot(cls.config), KyselyModule.forRoot(getKyselyConfig(database.config)), diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 90a8fa5a25..4b93628c56 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AddUsersDto, @@ -27,6 +28,7 @@ export class AlbumController { @Get() @Authenticated({ permission: Permission.AlbumRead }) + @ZodSerializerDto([AlbumResponseDto]) @Endpoint({ summary: 'List all albums', description: 'Retrieve a list of albums available to the authenticated user.', @@ -38,6 +40,7 @@ export class AlbumController { @Post() @Authenticated({ permission: Permission.AlbumCreate }) + @ZodSerializerDto(AlbumResponseDto) @Endpoint({ summary: 'Create an album', description: 'Create a new album. The album can also be created with initial users and assets.', @@ -60,6 +63,7 @@ export class AlbumController { @Authenticated({ permission: Permission.AlbumRead, sharedLink: true }) @Get(':id') + @ZodSerializerDto(AlbumResponseDto) @Endpoint({ summary: 'Retrieve an album', description: 'Retrieve information about a specific album by its ID.', @@ -71,6 +75,7 @@ export class AlbumController { @Patch(':id') @Authenticated({ permission: Permission.AlbumUpdate }) + @ZodSerializerDto(AlbumResponseDto) @Endpoint({ summary: 'Update an album', description: @@ -152,6 +157,7 @@ export class AlbumController { @Put(':id/users') @Authenticated({ permission: Permission.AlbumUserCreate }) + @ZodSerializerDto(AlbumResponseDto) @Endpoint({ summary: 'Share album with users', description: 'Share an album with multiple users. Each user can be given a specific role in the album.', diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 3c01e3d0a9..b15a0376f4 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -1,7 +1,10 @@ import { AssetController } from 'src/controllers/asset.controller'; +import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetMetadataKey } from 'src/enum'; import { AssetService } from 'src/services/asset.service'; import request from 'supertest'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { getForAsset } from 'test/mappers'; import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; @@ -183,6 +186,10 @@ describe(AssetController.name, () => { }); describe('PUT /assets/:id', () => { + beforeEach(() => { + service.update.mockResolvedValue(mapAsset(getForAsset(AssetFactory.create()))); + }); + it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/assets/123`); expect(ctx.authenticate).toHaveBeenCalled(); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 1c9afebc1b..3ab8323729 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { @@ -79,6 +80,7 @@ export class AssetController { @Get(':id') @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) + @ZodSerializerDto(AssetResponseDto) @Endpoint({ summary: 'Retrieve an asset', description: 'Retrieve detailed information about a specific asset.', @@ -128,6 +130,7 @@ export class AssetController { @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) + @ZodSerializerDto(AssetResponseDto) @Endpoint({ summary: 'Update an asset', description: 'Update information of a specific asset.', diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index a61397e75c..cf965e9532 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -1,7 +1,9 @@ import { AuthController } from 'src/controllers/auth.controller'; import { LoginResponseDto } from 'src/dtos/auth.dto'; +import { mapUserAdmin } from 'src/dtos/user.dto'; import { AuthService } from 'src/services/auth.service'; import request from 'supertest'; +import { UserFactory } from 'test/factories/user.factory'; import { mediumFactory } from 'test/medium.factory'; import { errorDto } from 'test/medium/responses'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; @@ -53,6 +55,7 @@ describe(AuthController.name, () => { it('should transform email to lower case', async () => { service.adminSignUp.mockReset(); + service.adminSignUp.mockResolvedValue(mapUserAdmin(UserFactory.create())); const { status } = await request(ctx.getHttpServer()) .post('/auth/admin-sign-up') .send({ name: 'admin', password: 'password', email: 'aDmIn@IMMICH.cloud' }); @@ -61,6 +64,7 @@ describe(AuthController.name, () => { }); it('should accept an email with a local domain', async () => { + service.adminSignUp.mockResolvedValue(mapUserAdmin(UserFactory.create())); const { status } = await request(ctx.getHttpServer()) .post('/auth/admin-sign-up') .send({ name: 'admin', password: 'password', email: 'admin@local' }); diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 63cdce4f32..9e36688f16 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto, @@ -50,6 +51,7 @@ export class AuthController { } @Post('admin-sign-up') + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Register admin', description: 'Create the first admin user in the system.', @@ -74,6 +76,7 @@ export class AuthController { @Post('change-password') @Authenticated({ permission: Permission.AuthChangePassword }) @HttpCode(HttpStatus.OK) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Change password', description: 'Change the password of the current user.', diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index a1c1d6ee4d..eed902e224 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -44,6 +45,7 @@ export class FaceController { @Put(':id') @Authenticated({ permission: Permission.FaceUpdate }) + @ZodSerializerDto(PersonResponseDto) @Endpoint({ summary: 'Re-assign a face to another person', description: 'Re-assign the face provided in the body to the person identified by the id in the path parameter.', diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 7f2313a058..69b3ef4d2c 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiConsumes, ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto, @@ -89,6 +90,7 @@ export class OAuthController { @Post('link') @Authenticated() @HttpCode(HttpStatus.OK) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Link OAuth account', description: 'Link an OAuth account to the authenticated user.', @@ -105,6 +107,7 @@ export class OAuthController { @Post('unlink') @Authenticated() @HttpCode(HttpStatus.OK) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Unlink OAuth account', description: 'Unlink the OAuth account from the authenticated user.', diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 5abd6eb1b4..af137bc0b0 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -14,6 +14,7 @@ import { } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -58,6 +59,7 @@ export class PersonController { @Post() @Authenticated({ permission: Permission.PersonCreate }) + @ZodSerializerDto(PersonResponseDto) @Endpoint({ summary: 'Create a person', description: 'Create a new person that can have multiple faces assigned to them.', @@ -92,6 +94,7 @@ export class PersonController { @Get(':id') @Authenticated({ permission: Permission.PersonRead }) + @ZodSerializerDto(PersonResponseDto) @Endpoint({ summary: 'Get a person', description: 'Retrieve a person by id.', @@ -103,6 +106,7 @@ export class PersonController { @Put(':id') @Authenticated({ permission: Permission.PersonUpdate }) + @ZodSerializerDto(PersonResponseDto) @Endpoint({ summary: 'Update person', description: 'Update an individual person.', @@ -158,6 +162,7 @@ export class PersonController { @Put(':id/reassign') @Authenticated({ permission: Permission.PersonReassign }) + @ZodSerializerDto([PersonResponseDto]) @Endpoint({ summary: 'Reassign faces', description: 'Bulk reassign a list of faces to a different person.', diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 439a7a5118..31811b91c6 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -54,6 +55,7 @@ export class SearchController { @Post('random') @Authenticated({ permission: Permission.AssetRead }) @HttpCode(HttpStatus.OK) + @ZodSerializerDto([AssetResponseDto]) @Endpoint({ summary: 'Search random assets', description: 'Retrieve a random selection of assets based on the provided criteria.', @@ -66,6 +68,7 @@ export class SearchController { @Post('large-assets') @Authenticated({ permission: Permission.AssetRead }) @HttpCode(HttpStatus.OK) + @ZodSerializerDto([AssetResponseDto]) @Endpoint({ summary: 'Search large assets', description: 'Search for assets that are considered large based on specified criteria.', @@ -100,6 +103,7 @@ export class SearchController { @Get('person') @Authenticated({ permission: Permission.PersonRead }) + @ZodSerializerDto([PersonResponseDto]) @Endpoint({ summary: 'Search people', description: 'Search for people by name.', @@ -122,6 +126,7 @@ export class SearchController { @Get('cities') @Authenticated({ permission: Permission.AssetRead }) + @ZodSerializerDto([AssetResponseDto]) @Endpoint({ summary: 'Retrieve assets by city', description: diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 101e89f3a5..f1dcdde527 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -23,6 +24,7 @@ export class TagController { @Post() @Authenticated({ permission: Permission.TagCreate }) + @ZodSerializerDto(TagResponseDto) @Endpoint({ summary: 'Create a tag', description: 'Create a new tag by providing a name and optional color.', @@ -34,6 +36,7 @@ export class TagController { @Get() @Authenticated({ permission: Permission.TagRead }) + @ZodSerializerDto([TagResponseDto]) @Endpoint({ summary: 'Retrieve tags', description: 'Retrieve a list of all tags.', @@ -45,6 +48,7 @@ export class TagController { @Put() @Authenticated({ permission: Permission.TagCreate }) + @ZodSerializerDto([TagResponseDto]) @Endpoint({ summary: 'Upsert tags', description: 'Create or update multiple tags in a single request.', @@ -67,6 +71,7 @@ export class TagController { @Get(':id') @Authenticated({ permission: Permission.TagRead }) + @ZodSerializerDto(TagResponseDto) @Endpoint({ summary: 'Retrieve a tag', description: 'Retrieve a specific tag by its ID.', @@ -78,6 +83,7 @@ export class TagController { @Put(':id') @Authenticated({ permission: Permission.TagUpdate }) + @ZodSerializerDto(TagResponseDto) @Endpoint({ summary: 'Update a tag', description: 'Update an existing tag identified by its ID.', diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 6dd919e193..bc1e5f944e 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -24,6 +25,7 @@ export class UserAdminController { @Get() @Authenticated({ permission: Permission.AdminUserRead, admin: true }) + @ZodSerializerDto([UserAdminResponseDto]) @Endpoint({ summary: 'Search users', description: 'Search for users.', @@ -35,6 +37,7 @@ export class UserAdminController { @Post() @Authenticated({ permission: Permission.AdminUserCreate, admin: true }) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Create a user', description: 'Create a new user.', @@ -46,6 +49,7 @@ export class UserAdminController { @Get(':id') @Authenticated({ permission: Permission.AdminUserRead, admin: true }) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Retrieve a user', description: 'Retrieve a specific user by their ID.', @@ -57,6 +61,7 @@ export class UserAdminController { @Put(':id') @Authenticated({ permission: Permission.AdminUserUpdate, admin: true }) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Update a user', description: 'Update an existing user.', @@ -72,6 +77,7 @@ export class UserAdminController { @Delete(':id') @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Delete a user', description: 'Delete a user.', @@ -140,6 +146,7 @@ export class UserAdminController { @Post(':id/restore') @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) @HttpCode(HttpStatus.OK) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Restore a deleted user', description: 'Restore a previously deleted user.', diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 2db0ca182b..b14b165555 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -15,6 +15,7 @@ import { } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; @@ -40,6 +41,7 @@ export class UserController { @Get() @Authenticated({ permission: Permission.UserRead }) + @ZodSerializerDto([UserResponseDto]) @Endpoint({ summary: 'Get all users', description: 'Retrieve a list of all users on the server.', @@ -51,6 +53,7 @@ export class UserController { @Get('me') @Authenticated({ permission: Permission.UserRead }) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Get current user', description: 'Retrieve information about the user making the API request.', @@ -62,6 +65,7 @@ export class UserController { @Put('me') @Authenticated({ permission: Permission.UserUpdate }) + @ZodSerializerDto(UserAdminResponseDto) @Endpoint({ summary: 'Update current user', description: 'Update the current user making the API request.', @@ -166,6 +170,7 @@ export class UserController { @Get(':id') @Authenticated({ permission: Permission.UserRead }) + @ZodSerializerDto(UserResponseDto) @Endpoint({ summary: 'Retrieve a user', description: 'Retrieve a specific user by their ID.', diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts index b07d83fe58..49dc57dabc 100644 --- a/server/src/controllers/view.controller.ts +++ b/server/src/controllers/view.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ZodSerializerDto } from 'nestjs-zod'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -25,6 +26,7 @@ export class ViewController { @Get('folder') @Authenticated({ permission: Permission.FolderRead }) + @ZodSerializerDto([AssetResponseDto]) @Endpoint({ summary: 'Retrieve assets by original path', description: 'Retrieve assets that are children of a specific folder.', diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index c03662288a..26cc5936d4 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -11,8 +11,8 @@ describe('mapAlbum', () => { .asset({ localDateTime: startDate }, (builder) => builder.exif()) .build(); const dto = mapAlbum(getForAlbum(album)); - expect(dto.startDate).toEqual(startDate.toISOString()); - expect(dto.endDate).toEqual(endDate.toISOString()); + expect(dto.startDate).toEqual(startDate); + expect(dto.endDate).toEqual(endDate); }); it('should not set start and end dates for empty assets', () => { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 33870cd6fc..07d92b6e7c 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -6,8 +6,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto'; import { UserResponseSchema, mapUser } from 'src/dtos/user.dto'; import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum'; import { MaybeDehydrated } from 'src/types'; -import { asDateString } from 'src/utils/date'; -import { stringToBool } from 'src/validation'; +import { isoDatetimeToDate, stringToBool } from 'src/validation'; import z from 'zod'; const AlbumUserAddSchema = z @@ -105,10 +104,8 @@ export const AlbumResponseSchema = z id: z.string().describe('Album ID'), albumName: z.string().describe('Album name'), description: z.string().describe('Album description'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'), + createdAt: isoDatetimeToDate.describe('Creation date'), + updatedAt: isoDatetimeToDate.describe('Last update date'), albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'), shared: z.boolean().describe('Is shared album'), albumUsers: z @@ -119,16 +116,9 @@ export const AlbumResponseSchema = z ), hasSharedLink: z.boolean().describe('Has shared link'), assetCount: z.int().min(0).describe('Number of assets'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - lastModifiedAssetTimestamp: z - .string() - .meta({ format: 'date-time' }) - .optional() - .describe('Last modified asset timestamp'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - startDate: z.string().meta({ format: 'date-time' }).optional().describe('Start date (earliest asset)'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - endDate: z.string().meta({ format: 'date-time' }).optional().describe('End date (latest asset)'), + lastModifiedAssetTimestamp: isoDatetimeToDate.optional().describe('Last modified asset timestamp'), + startDate: isoDatetimeToDate.optional().describe('Start date (earliest asset)'), + endDate: isoDatetimeToDate.optional().describe('End date (latest asset)'), isActivityEnabled: z.boolean().describe('Activity feed enabled'), order: AssetOrderSchema.optional(), contributorCounts: z.array(ContributorCountResponseSchema).optional(), @@ -144,7 +134,7 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {} export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {} export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {} export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {} -export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {} +export class AlbumResponseDto extends createZodDto(AlbumResponseSchema, { codec: true }) {} class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {} export type MapAlbumDto = { @@ -190,14 +180,14 @@ export const mapAlbum = (entity: MaybeDehydrated): AlbumResponseDto albumName: entity.albumName, description: entity.description, albumThumbnailAssetId: entity.albumThumbnailAssetId, - createdAt: asDateString(entity.createdAt), - updatedAt: asDateString(entity.updatedAt), + createdAt: new Date(entity.createdAt), + updatedAt: new Date(entity.updatedAt), id: entity.id, albumUsers, shared: hasSharedUser || hasSharedLink, hasSharedLink, - startDate: asDateString(startDate), - endDate: asDateString(endDate), + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, order: entity.order, diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index faa1db4afb..505cf79fb1 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -25,8 +25,8 @@ import { import { ImageDimensions, MaybeDehydrated } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; -import { asDateString } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; +import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; const SanitizedAssetResponseSchema = z @@ -40,13 +40,9 @@ const SanitizedAssetResponseSchema = z ) .nullable(), originalMimeType: z.string().optional().describe('Original MIME type'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - localDateTime: z - .string() - .meta({ format: 'date-time' }) - .describe( - 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', - ), + localDateTime: isoDatetimeToDate.describe( + 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', + ), duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'), hasMetadata: z.boolean().describe('Whether asset has metadata'), @@ -55,7 +51,7 @@ const SanitizedAssetResponseSchema = z }) .meta({ id: 'SanitizedAssetResponseDto' }); -export class SanitizedAssetResponseDto extends createZodDto(SanitizedAssetResponseSchema) {} +export class SanitizedAssetResponseDto extends createZodDto(SanitizedAssetResponseSchema, { codec: true }) {} const AssetStackResponseSchema = z .object({ @@ -67,11 +63,7 @@ const AssetStackResponseSchema = z export const AssetResponseSchema = SanitizedAssetResponseSchema.extend( z.object({ - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - createdAt: z - .string() - .meta({ format: 'date-time' }) - .describe('The UTC timestamp when the asset was originally uploaded to Immich.'), + createdAt: isoDatetimeToDate.describe('The UTC timestamp when the asset was originally uploaded to Immich.'), ownerId: z.string().describe('Owner user ID'), owner: UserResponseSchema.optional(), libraryId: z @@ -81,25 +73,15 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend( .meta(new HistoryBuilder().added('v1').deprecated('v1').getExtensions()), originalPath: z.string().describe('Original file path'), originalFileName: z.string().describe('Original file name'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - fileCreatedAt: z - .string() - .meta({ format: 'date-time' }) - .describe( - 'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.', - ), - fileModifiedAt: z - .string() - .meta({ format: 'date-time' }) - .describe( - 'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.', - ), - updatedAt: z - .string() - .meta({ format: 'date-time' }) - .describe( - 'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.', - ), + fileCreatedAt: isoDatetimeToDate.describe( + 'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.', + ), + fileModifiedAt: isoDatetimeToDate.describe( + 'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.', + ), + updatedAt: isoDatetimeToDate.describe( + 'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.', + ), isFavorite: z.boolean().describe('Is favorite'), isArchived: z.boolean().describe('Is archived'), isTrashed: z.boolean().describe('Is trashed'), @@ -124,7 +106,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend( }).shape, ).meta({ id: 'AssetResponseDto' }); -export class AssetResponseDto extends createZodDto(AssetResponseSchema) {} +export class AssetResponseDto extends createZodDto(AssetResponseSchema, { codec: true }) {} export type MapAsset = { createdAt: Date; @@ -220,7 +202,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt type: entity.type, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - localDateTime: asDateString(entity.localDateTime), + localDateTime: new Date(entity.localDateTime), duration: entity.duration, livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -234,7 +216,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt return { id: entity.id, - createdAt: asDateString(entity.createdAt), + createdAt: new Date(entity.createdAt), ownerId: entity.ownerId, owner: entity.owner ? mapUser(entity.owner) : undefined, libraryId: entity.libraryId, @@ -243,10 +225,10 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - fileCreatedAt: asDateString(entity.fileCreatedAt), - fileModifiedAt: asDateString(entity.fileModifiedAt), - localDateTime: asDateString(entity.localDateTime), - updatedAt: asDateString(entity.updatedAt), + fileCreatedAt: new Date(entity.fileCreatedAt), + fileModifiedAt: new Date(entity.fileModifiedAt), + localDateTime: new Date(entity.localDateTime), + updatedAt: new Date(entity.updatedAt), isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite, isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 1f75401e33..00237fec2c 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -21,7 +21,10 @@ export type AuthDto = { const LoginCredentialSchema = z .object({ - email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }), + email: toEmail + .transform((val) => val.toLowerCase()) + .describe('User email') + .meta({ example: 'testuser@email.com' }), password: z.string().describe('User password').meta({ example: 'password' }), }) .meta({ id: 'LoginCredentialDto' }); diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index c3e1ab36c8..dc16426688 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,7 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import { Exif } from 'src/database'; import { MaybeDehydrated } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; export const ExifResponseSchema = z @@ -12,10 +12,8 @@ export const ExifResponseSchema = z exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'), fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'), orientation: z.string().nullish().default(null).describe('Image orientation'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - dateTimeOriginal: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Original date/time'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - modifyDate: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Modification date/time'), + dateTimeOriginal: isoDatetimeToDate.nullish().default(null).describe('Original date/time'), + modifyDate: isoDatetimeToDate.nullish().default(null).describe('Modification date/time'), timeZone: z.string().nullish().default(null).describe('Time zone'), lensModel: z.string().nullish().default(null).describe('Lens model'), fNumber: z.number().nullish().default(null).describe('F-number (aperture)'), @@ -34,7 +32,7 @@ export const ExifResponseSchema = z .describe('EXIF response') .meta({ id: 'ExifResponseDto' }); -class ExifResponseDto extends createZodDto(ExifResponseSchema) {} +class ExifResponseDto extends createZodDto(ExifResponseSchema, { codec: true }) {} export function mapExif(entity: MaybeDehydrated): ExifResponseDto { return { @@ -44,8 +42,8 @@ export function mapExif(entity: MaybeDehydrated): ExifResponseDto { exifImageHeight: entity.exifImageHeight, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, - dateTimeOriginal: asDateString(entity.dateTimeOriginal), - modifyDate: asDateString(entity.modifyDate), + dateTimeOriginal: entity.dateTimeOriginal ? new Date(entity.dateTimeOriginal) : null, + modifyDate: entity.modifyDate ? new Date(entity.modifyDate) : null, timeZone: entity.timeZone, lensModel: entity.lensModel, fNumber: entity.fNumber, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 1f8f080905..c60c461a9a 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -7,9 +7,8 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceTypeSchema } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { ImageDimensions, MaybeDehydrated } from 'src/types'; -import { asBirthDateString, asDateString } from 'src/utils/date'; import { transformFaceBoundingBox } from 'src/utils/transform'; -import { emptyStringToNull, hexColor, stringToBool } from 'src/validation'; +import { emptyStringToNull, hexColor, isoDateToDate, isoDatetimeToDate, stringToBool } from 'src/validation'; import z from 'zod'; const PersonCreateSchema = z @@ -60,14 +59,10 @@ const PersonResponseSchema = z .object({ id: z.string().describe('Person ID'), name: z.string().describe('Person name'), - // TODO: use `isoDateToDate` when using `ZodSerializerDto` on the controllers. - birthDate: z.string().meta({ format: 'date' }).describe('Person date of birth').nullable(), + birthDate: isoDateToDate.nullable().describe('Person date of birth'), thumbnailPath: z.string().describe('Thumbnail path'), isHidden: z.boolean().describe('Is hidden'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - updatedAt: z - .string() - .meta({ format: 'date-time' }) + updatedAt: isoDatetimeToDate .optional() .describe('Last update date') .meta(new HistoryBuilder().added('v1.107.0').stable('v2').getExtensions()), @@ -89,7 +84,7 @@ export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {} export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {} export class MergePersonDto extends createZodDto(MergePersonSchema) {} export class PersonSearchDto extends createZodDto(PersonSearchSchema) {} -export class PersonResponseDto extends createZodDto(PersonResponseSchema) {} +export class PersonResponseDto extends createZodDto(PersonResponseSchema, { codec: true }) {} export const AssetFaceWithoutPersonResponseSchema = z .object({ @@ -111,7 +106,7 @@ export const PersonWithFacesResponseSchema = PersonResponseSchema.extend({ faces: z.array(AssetFaceWithoutPersonResponseSchema), }).meta({ id: 'PersonWithFacesResponseDto' }); -export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema) {} +export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema, { codec: true }) {} const AssetFaceResponseSchema = AssetFaceWithoutPersonResponseSchema.extend({ person: PersonResponseSchema.nullable(), @@ -184,12 +179,12 @@ export function mapPerson(person: MaybeDehydrated): PersonResponseDto { return { id: person.id, name: person.name, - birthDate: asBirthDateString(person.birthDate), + birthDate: person.birthDate ? new Date(person.birthDate) : null, thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, isFavorite: person.isFavorite, color: person.color ?? undefined, - updatedAt: asDateString(person.updatedAt), + updatedAt: person.updatedAt ? new Date(person.updatedAt) : undefined, }; } diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 67dbca9914..3d5d283b17 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,8 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import { Tag } from 'src/database'; import { MaybeDehydrated } from 'src/types'; -import { asDateString } from 'src/utils/date'; -import { emptyStringToNull, hexColor } from 'src/validation'; +import { emptyStringToNull, hexColor, isoDatetimeToDate } from 'src/validation'; import z from 'zod'; const TagCreateSchema = z @@ -44,10 +43,8 @@ export const TagResponseSchema = z parentId: z.string().optional().describe('Parent tag ID'), name: z.string().describe('Tag name'), value: z.string().describe('Tag value (full path)'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - createdAt: z.string().meta({ format: 'date-time' }).describe('Creation date'), - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'), + createdAt: isoDatetimeToDate.describe('Creation date'), + updatedAt: isoDatetimeToDate.describe('Last update date'), color: z.string().optional().describe('Tag color (hex)'), }) .meta({ id: 'TagResponseDto' }); @@ -57,7 +54,7 @@ export class TagUpdateDto extends createZodDto(TagUpdateSchema) {} export class TagUpsertDto extends createZodDto(TagUpsertSchema) {} export class TagBulkAssetsDto extends createZodDto(TagBulkAssetsSchema) {} export class TagBulkAssetsResponseDto extends createZodDto(TagBulkAssetsResponseSchema) {} -export class TagResponseDto extends createZodDto(TagResponseSchema) {} +export class TagResponseDto extends createZodDto(TagResponseSchema, { codec: true }) {} export function mapTag(entity: MaybeDehydrated): TagResponseDto { return { @@ -65,8 +62,8 @@ export function mapTag(entity: MaybeDehydrated): TagResponseDto { parentId: entity.parentId ?? undefined, name: entity.value.split('/').at(-1) as string, value: entity.value, - createdAt: asDateString(entity.createdAt), - updatedAt: asDateString(entity.updatedAt), + createdAt: new Date(entity.createdAt), + updatedAt: new Date(entity.updatedAt), color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 75256b9e1a..f2ddfb3d71 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -3,13 +3,15 @@ import { User, UserAdmin } from 'src/database'; import { pinCodeRegex } from 'src/dtos/auth.dto'; import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum'; import { MaybeDehydrated, UserMetadataItem } from 'src/types'; -import { asDateString } from 'src/utils/date'; import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation'; import z from 'zod'; export const UserUpdateMeSchema = z .object({ - email: toEmail.optional().describe('User email'), + email: toEmail + .transform((val) => val.toLowerCase()) + .optional() + .describe('User email'), password: z .string() .optional() @@ -29,12 +31,11 @@ export const UserResponseSchema = z email: toEmail.describe('User email'), profileImagePath: z.string().describe('Profile image path'), avatarColor: UserAvatarColorSchema, - // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. - profileChangedAt: z.string().meta({ format: 'date-time' }).describe('Profile change date'), + profileChangedAt: isoDatetimeToDate.describe('Profile change date'), }) .meta({ id: 'UserResponseDto' }); -export class UserResponseDto extends createZodDto(UserResponseSchema) {} +export class UserResponseDto extends createZodDto(UserResponseSchema, { codec: true }) {} const licenseKeyRegex = /^IM(SV|CL)(-[\dA-Za-z]{4}){8}$/; @@ -61,7 +62,7 @@ export const mapUser = (entity: MaybeDehydrated): UserResponse name: entity.name, profileImagePath: entity.profileImagePath, avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email), - profileChangedAt: asDateString(entity.profileChangedAt), + profileChangedAt: new Date(entity.profileChangedAt), }; }; @@ -76,7 +77,7 @@ export class UserAdminSearchDto extends createZodDto(UserAdminSearchSchema) {} export const UserAdminCreateSchema = z .object({ - email: toEmail.describe('User email'), + email: toEmail.transform((val) => val.toLowerCase()).describe('User email'), password: z.string().describe('User password'), name: z.string().describe('User name'), avatarColor: UserAvatarColorSchema.nullish(), @@ -96,7 +97,10 @@ export class UserAdminCreateDto extends createZodDto(UserAdminCreateSchema) {} const UserAdminUpdateSchema = z .object({ - email: toEmail.optional().describe('User email'), + email: toEmail + .transform((val) => val.toLowerCase()) + .optional() + .describe('User email'), password: z.string().optional().describe('User password'), pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable()) .optional() @@ -135,7 +139,7 @@ const UserAdminResponseSchema = UserResponseSchema.extend({ license: UserLicenseSchema.nullable(), }).meta({ id: 'UserAdminResponseDto' }); -export class UserAdminResponseDto extends createZodDto(UserAdminResponseSchema) {} +export class UserAdminResponseDto extends createZodDto(UserAdminResponseSchema, { codec: true }) {} export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const metadata = entity.metadata || []; diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index ef8a31dcb5..755340c1ae 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -19,7 +19,6 @@ import { AlbumUserRole, Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; -import { asDateString } from 'src/utils/date'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -63,11 +62,11 @@ export class AlbumService extends BaseService { return albums.map((album) => ({ ...mapAlbum(album), sharedLinks: undefined, - startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), - endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), + startDate: albumMetadata[album.id]?.startDate ?? undefined, + endDate: albumMetadata[album.id]?.endDate ?? undefined, assetCount: albumMetadata[album.id]?.assetCount ?? 0, // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need - lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined), + lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined, })); } @@ -83,10 +82,10 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album), - startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), - endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), + startDate: albumMetadataForIds?.startDate ?? undefined, + endDate: albumMetadataForIds?.endDate ?? undefined, assetCount: albumMetadataForIds?.assetCount ?? 0, - lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined), + lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined, contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined, }; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8b303d04f6..6966281adc 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -211,11 +211,12 @@ describe(PersonService.name, () => { await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({ id: person.id, name: person.name, - birthDate: '1976-06-30', + birthDate: new Date('1976-06-30'), thumbnailPath: person.thumbnailPath, isHidden: false, isFavorite: false, - updatedAt: expect.any(String), + color: undefined, + updatedAt: expect.any(Date), }); expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' }); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -485,10 +486,11 @@ describe(PersonService.name, () => { birthDate: person.birthDate, isHidden: person.isHidden, isFavorite: person.isFavorite, + color: undefined, id: person.id, name: person.name, thumbnailPath: person.thumbnailPath, - updatedAt: expect.any(String), + updatedAt: expect.any(Date), }); expect(mocks.job.queue).not.toHaveBeenCalledWith(); @@ -848,7 +850,7 @@ describe(PersonService.name, () => { facesRecognizedAt: expect.any(Date), }); const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; - expect(facesRecognizedAt.getTime()).toBeGreaterThan(start); + expect(facesRecognizedAt.getTime()).toBeGreaterThanOrEqual(start); }); it('should create a face with no person and queue recognition job', async () => { diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts index d4de1eba86..c84e49ab2f 100644 --- a/server/src/utils/date.ts +++ b/server/src/utils/date.ts @@ -1,25 +1,5 @@ import { DateTime } from 'luxon'; -/** - * Convert a date to a ISO 8601 datetime string. - * @param x - The date to convert. - * @returns The ISO 8601 datetime string. - * @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDatetimeToDate` in validation.ts will handle the conversion instead. - */ -export const asDateString = (x: T) => { - return x instanceof Date ? x.toISOString() : (x as Exclude); -}; - -/** - * Convert a date to a date string. - * @param x - The date to convert. - * @returns The date string. - * @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDateToDate` in validation.ts will handle the conversion instead. - */ -export const asBirthDateString = (x: Date | string | null): string | null => { - return x instanceof Date ? x.toISOString().split('T')[0] : x; -}; - export const extractTimeZone = (dateTimeOriginal?: string | null) => { const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; return extractedTimeZone?.type === 'fixed' ? extractedTimeZone : undefined; diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts index d63f0d3e32..67a7ab57a8 100644 --- a/server/src/utils/duplicate.spec.ts +++ b/server/src/utils/duplicate.spec.ts @@ -5,7 +5,7 @@ import { getExifCount, suggestDuplicate, suggestDuplicateKeepAssetIds } from 'sr import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; -type ExifInfoInput = Partial>; +type ExifInfoInput = Partial>; const createAsset = ( id: string, @@ -15,18 +15,18 @@ const createAsset = ( id, type: AssetType.Image, thumbhash: null, - localDateTime: new Date().toISOString(), + localDateTime: new Date(), duration: '0:00:00.00000', hasMetadata: true, width: 1920, height: 1080, - createdAt: new Date().toISOString(), + createdAt: new Date(), ownerId: 'owner-1', originalPath: '/path/to/asset', originalFileName: 'asset.jpg', - fileCreatedAt: new Date().toISOString(), - fileModifiedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + fileCreatedAt: new Date(), + fileModifiedAt: new Date(), + updatedAt: new Date(), isFavorite: false, isArchived: false, isTrashed: false, diff --git a/server/src/validation.ts b/server/src/validation.ts index 59131b3abe..deeb2cc3ea 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -135,15 +135,13 @@ export const isValidInteger = (value: number, options: { min?: number; max?: num * Converts email strings to lowercase and validates against HTML5 email regex * @docs https://zod.dev/api?id=email */ -export const toEmail = z - .email({ - pattern: z.regexes.html5Email, - error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`, - }) - .transform((val) => val.toLowerCase()); +export const toEmail = z.email({ + pattern: z.regexes.html5Email, + error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`, +}); /** - * Parse ISO 8601 datetime strings to Date objects + * Parse ISO 8601 datetime strings to Date objects. Requires `{ codec: true }` when using `createZodDto`. * @docs https://zod.dev/api?id=codec */ export const isoDatetimeToDate = z diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index 8382dec142..2694baee39 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -55,15 +55,15 @@ export const tagStub = { export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', - createdAt: '2021-01-01T00:00:00.000Z', - updatedAt: '2021-01-01T00:00:00.000Z', + createdAt: new Date('2021-01-01T00:00:00.000Z'), + updatedAt: new Date('2021-01-01T00:00:00.000Z'), name: 'Tag1', value: 'Tag1', }), color1: Object.freeze({ id: 'tag-1', - createdAt: '2021-01-01T00:00:00.000Z', - updatedAt: '2021-01-01T00:00:00.000Z', + createdAt: new Date('2021-01-01T00:00:00.000Z'), + updatedAt: new Date('2021-01-01T00:00:00.000Z'), color: '#000000', name: 'Tag1', value: 'Tag1',