mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server/web): album description (#3558)
* feat(server): add album description * chore: open api * fix: tests * show and edit description on the web * fix test * remove unused code * type event * format fix --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							
								
								
									
										18
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -210,6 +210,12 @@ export interface AlbumResponseDto { | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'createdAt': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'description': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -865,6 +871,12 @@ export interface CreateAlbumDto { | ||||
|      * @memberof CreateAlbumDto | ||||
|      */ | ||||
|     'assetIds'?: Array<string>; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof CreateAlbumDto | ||||
|      */ | ||||
|     'description'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {Array<string>} | ||||
| @@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto { | ||||
|      * @memberof UpdateAlbumDto | ||||
|      */ | ||||
|     'albumThumbnailAssetId'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UpdateAlbumDto | ||||
|      */ | ||||
|     'description'?: string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/AlbumResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/AlbumResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -13,6 +13,7 @@ Name | Type | Description | Notes | ||||
| **assetCount** | **int** |  |  | ||||
| **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []] | ||||
| **createdAt** | [**DateTime**](DateTime.md) |  |  | ||||
| **description** | **String** |  |  | ||||
| **id** | **String** |  |  | ||||
| **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [optional]  | ||||
| **owner** | [**UserResponseDto**](UserResponseDto.md) |  |  | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/CreateAlbumDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/CreateAlbumDto.md
									
									
									
										generated
									
									
									
								
							| @@ -10,6 +10,7 @@ Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **albumName** | **String** |  |  | ||||
| **assetIds** | **List<String>** |  | [optional] [default to const []] | ||||
| **description** | **String** |  | [optional]  | ||||
| **sharedWithUserIds** | **List<String>** |  | [optional] [default to const []] | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UpdateAlbumDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UpdateAlbumDto.md
									
									
									
										generated
									
									
									
								
							| @@ -10,6 +10,7 @@ Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **albumName** | **String** |  | [optional]  | ||||
| **albumThumbnailAssetId** | **String** |  | [optional]  | ||||
| **description** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										10
									
								
								mobile/openapi/lib/model/album_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								mobile/openapi/lib/model/album_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -18,6 +18,7 @@ class AlbumResponseDto { | ||||
|     required this.assetCount, | ||||
|     this.assets = const [], | ||||
|     required this.createdAt, | ||||
|     required this.description, | ||||
|     required this.id, | ||||
|     this.lastModifiedAssetTimestamp, | ||||
|     required this.owner, | ||||
| @@ -37,6 +38,8 @@ class AlbumResponseDto { | ||||
| 
 | ||||
|   DateTime createdAt; | ||||
| 
 | ||||
|   String description; | ||||
| 
 | ||||
|   String id; | ||||
| 
 | ||||
|   /// | ||||
| @@ -64,6 +67,7 @@ class AlbumResponseDto { | ||||
|      other.assetCount == assetCount && | ||||
|      other.assets == assets && | ||||
|      other.createdAt == createdAt && | ||||
|      other.description == description && | ||||
|      other.id == id && | ||||
|      other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && | ||||
|      other.owner == owner && | ||||
| @@ -80,6 +84,7 @@ class AlbumResponseDto { | ||||
|     (assetCount.hashCode) + | ||||
|     (assets.hashCode) + | ||||
|     (createdAt.hashCode) + | ||||
|     (description.hashCode) + | ||||
|     (id.hashCode) + | ||||
|     (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + | ||||
|     (owner.hashCode) + | ||||
| @@ -89,7 +94,7 @@ class AlbumResponseDto { | ||||
|     (updatedAt.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; | ||||
|   String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -102,6 +107,7 @@ class AlbumResponseDto { | ||||
|       json[r'assetCount'] = this.assetCount; | ||||
|       json[r'assets'] = this.assets; | ||||
|       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); | ||||
|       json[r'description'] = this.description; | ||||
|       json[r'id'] = this.id; | ||||
|     if (this.lastModifiedAssetTimestamp != null) { | ||||
|       json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); | ||||
| @@ -129,6 +135,7 @@ class AlbumResponseDto { | ||||
|         assetCount: mapValueOfType<int>(json, r'assetCount')!, | ||||
|         assets: AssetResponseDto.listFromJson(json[r'assets']), | ||||
|         createdAt: mapDateTime(json, r'createdAt', r'')!, | ||||
|         description: mapValueOfType<String>(json, r'description')!, | ||||
|         id: mapValueOfType<String>(json, r'id')!, | ||||
|         lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), | ||||
|         owner: UserResponseDto.fromJson(json[r'owner'])!, | ||||
| @@ -188,6 +195,7 @@ class AlbumResponseDto { | ||||
|     'assetCount', | ||||
|     'assets', | ||||
|     'createdAt', | ||||
|     'description', | ||||
|     'id', | ||||
|     'owner', | ||||
|     'ownerId', | ||||
|   | ||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/create_album_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/create_album_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -15,6 +15,7 @@ class CreateAlbumDto { | ||||
|   CreateAlbumDto({ | ||||
|     required this.albumName, | ||||
|     this.assetIds = const [], | ||||
|     this.description, | ||||
|     this.sharedWithUserIds = const [], | ||||
|   }); | ||||
| 
 | ||||
| @@ -22,12 +23,21 @@ class CreateAlbumDto { | ||||
| 
 | ||||
|   List<String> assetIds; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? description; | ||||
| 
 | ||||
|   List<String> sharedWithUserIds; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is CreateAlbumDto && | ||||
|      other.albumName == albumName && | ||||
|      other.assetIds == assetIds && | ||||
|      other.description == description && | ||||
|      other.sharedWithUserIds == sharedWithUserIds; | ||||
| 
 | ||||
|   @override | ||||
| @@ -35,15 +45,21 @@ class CreateAlbumDto { | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (albumName.hashCode) + | ||||
|     (assetIds.hashCode) + | ||||
|     (description == null ? 0 : description!.hashCode) + | ||||
|     (sharedWithUserIds.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, sharedWithUserIds=$sharedWithUserIds]'; | ||||
|   String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, description=$description, sharedWithUserIds=$sharedWithUserIds]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'albumName'] = this.albumName; | ||||
|       json[r'assetIds'] = this.assetIds; | ||||
|     if (this.description != null) { | ||||
|       json[r'description'] = this.description; | ||||
|     } else { | ||||
|     //  json[r'description'] = null; | ||||
|     } | ||||
|       json[r'sharedWithUserIds'] = this.sharedWithUserIds; | ||||
|     return json; | ||||
|   } | ||||
| @@ -60,6 +76,7 @@ class CreateAlbumDto { | ||||
|         assetIds: json[r'assetIds'] is Iterable | ||||
|             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false) | ||||
|             : const [], | ||||
|         description: mapValueOfType<String>(json, r'description'), | ||||
|         sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable | ||||
|             ? (json[r'sharedWithUserIds'] as Iterable).cast<String>().toList(growable: false) | ||||
|             : const [], | ||||
|   | ||||
							
								
								
									
										23
									
								
								mobile/openapi/lib/model/update_album_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										23
									
								
								mobile/openapi/lib/model/update_album_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -15,6 +15,7 @@ class UpdateAlbumDto { | ||||
|   UpdateAlbumDto({ | ||||
|     this.albumName, | ||||
|     this.albumThumbnailAssetId, | ||||
|     this.description, | ||||
|   }); | ||||
| 
 | ||||
|   /// | ||||
| @@ -33,19 +34,29 @@ class UpdateAlbumDto { | ||||
|   /// | ||||
|   String? albumThumbnailAssetId; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? description; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && | ||||
|      other.albumName == albumName && | ||||
|      other.albumThumbnailAssetId == albumThumbnailAssetId; | ||||
|      other.albumThumbnailAssetId == albumThumbnailAssetId && | ||||
|      other.description == description; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (albumName == null ? 0 : albumName!.hashCode) + | ||||
|     (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode); | ||||
|     (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + | ||||
|     (description == null ? 0 : description!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId]'; | ||||
|   String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -59,6 +70,11 @@ class UpdateAlbumDto { | ||||
|     } else { | ||||
|     //  json[r'albumThumbnailAssetId'] = null; | ||||
|     } | ||||
|     if (this.description != null) { | ||||
|       json[r'description'] = this.description; | ||||
|     } else { | ||||
|     //  json[r'description'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
| @@ -72,6 +88,7 @@ class UpdateAlbumDto { | ||||
|       return UpdateAlbumDto( | ||||
|         albumName: mapValueOfType<String>(json, r'albumName'), | ||||
|         albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'), | ||||
|         description: mapValueOfType<String>(json, r'description'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/album_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/album_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -41,6 +41,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String description | ||||
|     test('to test the property `description`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String id | ||||
|     test('to test the property `id`', () async { | ||||
|       // TODO | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/create_album_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/create_album_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -26,6 +26,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String description | ||||
|     test('to test the property `description`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // List<String> sharedWithUserIds (default value: const []) | ||||
|     test('to test the property `sharedWithUserIds`', () async { | ||||
|       // TODO | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/update_album_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/update_album_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -26,6 +26,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String description | ||||
|     test('to test the property `description`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
|   | ||||
| @@ -4754,6 +4754,9 @@ | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "id": { | ||||
|             "type": "string" | ||||
|           }, | ||||
| @@ -4786,6 +4789,7 @@ | ||||
|           "id", | ||||
|           "ownerId", | ||||
|           "albumName", | ||||
|           "description", | ||||
|           "createdAt", | ||||
|           "updatedAt", | ||||
|           "albumThumbnailAssetId", | ||||
| @@ -5264,6 +5268,9 @@ | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "sharedWithUserIds": { | ||||
|             "items": { | ||||
|               "format": "uuid", | ||||
| @@ -6903,6 +6910,9 @@ | ||||
|           "albumThumbnailAssetId": { | ||||
|             "format": "uuid", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "type": "object" | ||||
|   | ||||
| @@ -7,6 +7,7 @@ export class AlbumResponseDto { | ||||
|   id!: string; | ||||
|   ownerId!: string; | ||||
|   albumName!: string; | ||||
|   description!: string; | ||||
|   createdAt!: Date; | ||||
|   updatedAt!: Date; | ||||
|   albumThumbnailAssetId!: string | null; | ||||
| @@ -19,7 +20,7 @@ export class AlbumResponseDto { | ||||
|   lastModifiedAssetTimestamp?: Date; | ||||
| } | ||||
|  | ||||
| export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { | ||||
| const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { | ||||
|   const sharedUsers: UserResponseDto[] = []; | ||||
|  | ||||
|   entity.sharedUsers?.forEach((user) => { | ||||
| @@ -29,6 +30,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { | ||||
|  | ||||
|   return { | ||||
|     albumName: entity.albumName, | ||||
|     description: entity.description, | ||||
|     albumThumbnailAssetId: entity.albumThumbnailAssetId, | ||||
|     createdAt: entity.createdAt, | ||||
|     updatedAt: entity.updatedAt, | ||||
| @@ -37,33 +39,13 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { | ||||
|     owner: mapUser(entity.owner), | ||||
|     sharedUsers, | ||||
|     shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, | ||||
|     assets: entity.assets?.map((asset) => mapAsset(asset)) || [], | ||||
|     assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [], | ||||
|     assetCount: entity.assets?.length || 0, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto { | ||||
|   const sharedUsers: UserResponseDto[] = []; | ||||
|  | ||||
|   entity.sharedUsers?.forEach((user) => { | ||||
|     const userDto = mapUser(user); | ||||
|     sharedUsers.push(userDto); | ||||
|   }); | ||||
|  | ||||
|   return { | ||||
|     albumName: entity.albumName, | ||||
|     albumThumbnailAssetId: entity.albumThumbnailAssetId, | ||||
|     createdAt: entity.createdAt, | ||||
|     updatedAt: entity.updatedAt, | ||||
|     id: entity.id, | ||||
|     ownerId: entity.ownerId, | ||||
|     owner: mapUser(entity.owner), | ||||
|     sharedUsers, | ||||
|     shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, | ||||
|     assets: [], | ||||
|     assetCount: entity.assets?.length || 0, | ||||
| }; | ||||
| } | ||||
|  | ||||
| export const mapAlbum = (entity: AlbumEntity) => _map(entity, true); | ||||
| export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false); | ||||
|  | ||||
| export class AlbumCountResponseDto { | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   | ||||
| @@ -156,6 +156,7 @@ describe(AlbumService.name, () => { | ||||
|  | ||||
|       await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ | ||||
|         albumName: 'Empty album', | ||||
|         description: '', | ||||
|         albumThumbnailAssetId: null, | ||||
|         assetCount: 0, | ||||
|         assets: [], | ||||
|   | ||||
| @@ -94,6 +94,7 @@ export class AlbumService { | ||||
|     const album = await this.albumRepository.create({ | ||||
|       ownerId: authUser.id, | ||||
|       albumName: dto.albumName, | ||||
|       description: dto.description, | ||||
|       sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [], | ||||
|       assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)), | ||||
|       albumThumbnailAssetId: dto.assetIds?.[0] || null, | ||||
| @@ -118,6 +119,7 @@ export class AlbumService { | ||||
|     const updatedAlbum = await this.albumRepository.update({ | ||||
|       id: album.id, | ||||
|       albumName: dto.albumName, | ||||
|       description: dto.description, | ||||
|       albumThumbnailAssetId: dto.albumThumbnailAssetId, | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsNotEmpty, IsString } from 'class-validator'; | ||||
| import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||||
| import { ValidateUUID } from '../../domain.util'; | ||||
|  | ||||
| export class CreateAlbumDto { | ||||
| @@ -8,6 +8,10 @@ export class CreateAlbumDto { | ||||
|   @ApiProperty() | ||||
|   albumName!: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsOptional() | ||||
|   description?: string; | ||||
|  | ||||
|   @ValidateUUID({ optional: true, each: true }) | ||||
|   sharedWithUserIds?: string[]; | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsOptional } from 'class-validator'; | ||||
| import { IsOptional, IsString } from 'class-validator'; | ||||
| import { ValidateUUID } from '../../domain.util'; | ||||
|  | ||||
| export class UpdateAlbumDto { | ||||
|   @IsOptional() | ||||
|   @ApiProperty() | ||||
|   @IsString() | ||||
|   albumName?: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   @IsString() | ||||
|   description?: string; | ||||
|  | ||||
|   @ValidateUUID({ optional: true }) | ||||
|   albumThumbnailAssetId?: string; | ||||
| } | ||||
|   | ||||
| @@ -5,8 +5,8 @@ import { | ||||
|   AuthUserDto, | ||||
|   BulkIdResponseDto, | ||||
|   BulkIdsDto, | ||||
|   CreateAlbumDto, | ||||
|   UpdateAlbumDto, | ||||
|   CreateAlbumDto as CreateDto, | ||||
|   UpdateAlbumDto as UpdateDto, | ||||
| } from '@app/domain'; | ||||
| import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; | ||||
| import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; | ||||
| @@ -34,7 +34,7 @@ export class AlbumController { | ||||
|   } | ||||
|  | ||||
|   @Post() | ||||
|   createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) { | ||||
|   createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) { | ||||
|     return this.service.create(authUser, dto); | ||||
|   } | ||||
|  | ||||
| @@ -45,7 +45,7 @@ export class AlbumController { | ||||
|   } | ||||
|  | ||||
|   @Patch(':id') | ||||
|   updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { | ||||
|   updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) { | ||||
|     return this.service.update(authUser, id, dto); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -27,6 +27,9 @@ export class AlbumEntity { | ||||
|   @Column({ default: 'Untitled Album' }) | ||||
|   albumName!: string; | ||||
|  | ||||
|   @Column({ type: 'text', default: '' }) | ||||
|   description!: string; | ||||
|  | ||||
|   @CreateDateColumn({ type: 'timestamptz' }) | ||||
|   createdAt!: Date; | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,13 @@ | ||||
| import { MigrationInterface, QueryRunner } from 'typeorm'; | ||||
|  | ||||
| export class AddAlbumDescription1691209138541 implements MigrationInterface { | ||||
|   name = 'AddAlbumDescription1691209138541'; | ||||
|  | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`); | ||||
|   } | ||||
|  | ||||
|   public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`); | ||||
|   } | ||||
| } | ||||
| @@ -234,7 +234,7 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|       .documents() | ||||
|       .search({ | ||||
|         q: query, | ||||
|         query_by: 'albumName', | ||||
|         query_by: ['albumName', 'description'].join(','), | ||||
|         filter_by: this.getAlbumFilters(filters), | ||||
|       }); | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; | ||||
|  | ||||
| export const albumSchemaVersion = 1; | ||||
| export const albumSchemaVersion = 2; | ||||
| export const albumSchema: CollectionCreateSchema = { | ||||
|   name: `albums-v${albumSchemaVersion}`, | ||||
|   fields: [ | ||||
|     { name: 'ownerId', type: 'string', facet: false }, | ||||
|     { name: 'albumName', type: 'string', facet: false, sort: true }, | ||||
|     { name: 'description', type: 'string', facet: false }, | ||||
|     { name: 'createdAt', type: 'string', facet: false, sort: true }, | ||||
|     { name: 'updatedAt', type: 'string', facet: false, sort: true }, | ||||
|   ], | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { SharedLinkType } from '@app/infra/entities'; | ||||
| import { INestApplication } from '@nestjs/common'; | ||||
| import { Test, TestingModule } from '@nestjs/testing'; | ||||
| import request from 'supertest'; | ||||
| import { errorStub } from '../fixtures'; | ||||
| import { errorStub, uuidStub } from '../fixtures'; | ||||
| import { api, db } from '../test-utils'; | ||||
|  | ||||
| const user1SharedUser = 'user1SharedUser'; | ||||
| @@ -193,6 +193,7 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|         updatedAt: expect.any(String), | ||||
|         ownerId: user1.userId, | ||||
|         albumName: 'New album', | ||||
|         description: '', | ||||
|         albumThumbnailAssetId: null, | ||||
|         shared: false, | ||||
|         sharedUsers: [], | ||||
| @@ -202,4 +203,32 @@ describe(`${AlbumController.name} (e2e)`, () => { | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('PATCH /album/:id', () => { | ||||
|     it('should require authentication', async () => { | ||||
|       const { status, body } = await request(server) | ||||
|         .patch(`/album/${uuidStub.notFound}`) | ||||
|         .send({ albumName: 'New album name' }); | ||||
|       expect(status).toBe(401); | ||||
|       expect(body).toEqual(errorStub.unauthorized); | ||||
|     }); | ||||
|  | ||||
|     it('should update an album', async () => { | ||||
|       const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); | ||||
|       const { status, body } = await request(server) | ||||
|         .patch(`/album/${album.id}`) | ||||
|         .set('Authorization', `Bearer ${user1.accessToken}`) | ||||
|         .send({ | ||||
|           albumName: 'New album name', | ||||
|           description: 'An album description', | ||||
|         }); | ||||
|       expect(status).toBe(200); | ||||
|       expect(body).toEqual({ | ||||
|         ...album, | ||||
|         updatedAt: expect.any(String), | ||||
|         albumName: 'New album name', | ||||
|         description: 'An album description', | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										10
									
								
								server/test/fixtures/album.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								server/test/fixtures/album.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -7,6 +7,7 @@ export const albumStub = { | ||||
|   empty: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-1', | ||||
|     albumName: 'Empty album', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -20,6 +21,7 @@ export const albumStub = { | ||||
|   sharedWithUser: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-2', | ||||
|     albumName: 'Empty album shared with user', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -33,6 +35,7 @@ export const albumStub = { | ||||
|   sharedWithMultiple: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-3', | ||||
|     albumName: 'Empty album shared with users', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -46,6 +49,7 @@ export const albumStub = { | ||||
|   sharedWithAdmin: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-3', | ||||
|     albumName: 'Empty album shared with admin', | ||||
|     description: '', | ||||
|     ownerId: authStub.user1.id, | ||||
|     owner: userStub.user1, | ||||
|     assets: [], | ||||
| @@ -59,6 +63,7 @@ export const albumStub = { | ||||
|   oneAsset: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-4', | ||||
|     albumName: 'Album with one asset', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image], | ||||
| @@ -72,6 +77,7 @@ export const albumStub = { | ||||
|   twoAssets: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-4a', | ||||
|     albumName: 'Album with two assets', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image, assetStub.withLocation], | ||||
| @@ -85,6 +91,7 @@ export const albumStub = { | ||||
|   emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-5', | ||||
|     albumName: 'Empty album with invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -98,6 +105,7 @@ export const albumStub = { | ||||
|   emptyWithValidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-5', | ||||
|     albumName: 'Empty album with invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [], | ||||
| @@ -111,6 +119,7 @@ export const albumStub = { | ||||
|   oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-6', | ||||
|     albumName: 'Album with one asset and invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image], | ||||
| @@ -124,6 +133,7 @@ export const albumStub = { | ||||
|   oneAssetValidThumbnail: Object.freeze<AlbumEntity>({ | ||||
|     id: 'album-6', | ||||
|     albumName: 'Album with one asset and invalid thumbnail', | ||||
|     description: '', | ||||
|     ownerId: authStub.admin.id, | ||||
|     owner: userStub.admin, | ||||
|     assets: [assetStub.image], | ||||
|   | ||||
							
								
								
									
										2
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -68,6 +68,7 @@ const assetResponse: AssetResponseDto = { | ||||
|  | ||||
| const albumResponse: AlbumResponseDto = { | ||||
|   albumName: 'Test Album', | ||||
|   description: '', | ||||
|   albumThumbnailAssetId: null, | ||||
|   createdAt: today, | ||||
|   updatedAt: today, | ||||
| @@ -146,6 +147,7 @@ export const sharedLinkStub = { | ||||
|       ownerId: authStub.admin.id, | ||||
|       owner: userStub.admin, | ||||
|       albumName: 'Test Album', | ||||
|       description: '', | ||||
|       createdAt: today, | ||||
|       updatedAt: today, | ||||
|       albumThumbnailAsset: null, | ||||
|   | ||||
							
								
								
									
										18
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -210,6 +210,12 @@ export interface AlbumResponseDto { | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'createdAt': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AlbumResponseDto | ||||
|      */ | ||||
|     'description': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -865,6 +871,12 @@ export interface CreateAlbumDto { | ||||
|      * @memberof CreateAlbumDto | ||||
|      */ | ||||
|     'assetIds'?: Array<string>; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof CreateAlbumDto | ||||
|      */ | ||||
|     'description'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {Array<string>} | ||||
| @@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto { | ||||
|      * @memberof UpdateAlbumDto | ||||
|      */ | ||||
|     'albumThumbnailAssetId'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UpdateAlbumDto | ||||
|      */ | ||||
|     'description'?: string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|   | ||||
| @@ -44,6 +44,7 @@ | ||||
|   import { handleError } from '../../utils/handle-error'; | ||||
|   import { downloadArchive } from '../../utils/asset-utils'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import EditDescriptionModal from './edit-description-modal.svelte'; | ||||
|  | ||||
|   export let album: AlbumResponseDto; | ||||
|   export let sharedLink: SharedLinkResponseDto | undefined = undefined; | ||||
| @@ -73,6 +74,7 @@ | ||||
|   let isShowAlbumOptions = false; | ||||
|   let isShowThumbnailSelection = false; | ||||
|   let isShowDeleteConfirmation = false; | ||||
|   let isEditingDescription = false; | ||||
|  | ||||
|   let backUrl = '/albums'; | ||||
|   let currentAlbumName = ''; | ||||
| @@ -298,6 +300,27 @@ | ||||
|   const handleSelectAll = () => { | ||||
|     multiSelectAsset = new Set(album.assets); | ||||
|   }; | ||||
|  | ||||
|   const descriptionUpdatedHandler = (description: string) => { | ||||
|     try { | ||||
|       api.albumApi.updateAlbumInfo({ | ||||
|         id: album.id, | ||||
|         updateAlbumDto: { | ||||
|           description, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       album.description = description; | ||||
|     } catch (e) { | ||||
|       console.error('Error [descriptionUpdatedHandler] ', e); | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Error, | ||||
|         message: 'Error setting album description, check console for more details', | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     isEditingDescription = false; | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}> | ||||
| @@ -405,6 +428,7 @@ | ||||
|   {/if} | ||||
|  | ||||
|   <section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40"> | ||||
|     <!-- ALBUM TITLE --> | ||||
|     <input | ||||
|       on:keydown={(e) => { | ||||
|         if (e.key == 'Enter') { | ||||
| @@ -421,8 +445,10 @@ | ||||
|       bind:value={album.albumName} | ||||
|       disabled={!isOwned} | ||||
|       bind:this={titleInput} | ||||
|       title="Edit Title" | ||||
|     /> | ||||
|  | ||||
|     <!-- ALBUM SUMMARY --> | ||||
|     {#if album.assetCount > 0} | ||||
|       <span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details"> | ||||
|         <p class="">{getDateRange()}</p> | ||||
| @@ -448,6 +474,17 @@ | ||||
|       </div> | ||||
|     {/if} | ||||
|  | ||||
|     <!-- ALBUM DESCRIPTION --> | ||||
|     <button | ||||
|       class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300" | ||||
|       on:click={() => (isEditingDescription = true)} | ||||
|       class:hover:border-gray-400={isOwned} | ||||
|       disabled={!isOwned} | ||||
|       title="Edit description" | ||||
|     > | ||||
|       {album.description || 'Add description'} | ||||
|     </button> | ||||
|  | ||||
|     {#if album.assetCount > 0 && !isShowAssetSelection} | ||||
|       <GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} /> | ||||
|     {:else} | ||||
| @@ -490,6 +527,7 @@ | ||||
| {#if isShowShareLinkModal} | ||||
|   <CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> | ||||
| {/if} | ||||
|  | ||||
| {#if isShowShareInfoModal} | ||||
|   <ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> | ||||
| {/if} | ||||
| @@ -515,3 +553,11 @@ | ||||
|     </svelte:fragment> | ||||
|   </ConfirmDialogue> | ||||
| {/if} | ||||
|  | ||||
| {#if isEditingDescription} | ||||
|   <EditDescriptionModal | ||||
|     {album} | ||||
|     on:close={() => (isEditingDescription = false)} | ||||
|     on:updated={({ detail: description }) => descriptionUpdatedHandler(description)} | ||||
|   /> | ||||
| {/if} | ||||
|   | ||||
| @@ -0,0 +1,43 @@ | ||||
| <script lang="ts"> | ||||
|   import { createEventDispatcher } from 'svelte'; | ||||
|   import type { AlbumResponseDto } from '@api'; | ||||
|   import FullScreenModal from '../shared-components/full-screen-modal.svelte'; | ||||
|   import Button from '../elements/buttons/button.svelte'; | ||||
|  | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     close: void; | ||||
|     updated: string; | ||||
|   }>(); | ||||
|   export let album: AlbumResponseDto; | ||||
|  | ||||
|   let description = album.description; | ||||
|  | ||||
|   const handleSave = () => { | ||||
|     dispatch('updated', description); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <FullScreenModal on:clickOutside={() => dispatch('close')}> | ||||
|   <div | ||||
|     class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg" | ||||
|   > | ||||
|     <div | ||||
|       class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary" | ||||
|     > | ||||
|       <h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit description</h1> | ||||
|     </div> | ||||
|  | ||||
|     <form on:submit|preventDefault={handleSave} autocomplete="off"> | ||||
|       <div class="m-4 flex flex-col gap-2"> | ||||
|         <label class="immich-form-label" for="email">Description</label> | ||||
|         <!-- svelte-ignore a11y-autofocus --> | ||||
|         <input class="immich-form-input" id="name" name="name" type="text" bind:value={description} autofocus /> | ||||
|       </div> | ||||
|  | ||||
|       <div class="mt-8 flex w-full gap-4 px-4"> | ||||
|         <Button color="gray" fullwidth on:click={() => dispatch('close')}>Cancel</Button> | ||||
|         <Button type="submit" fullwidth>Ok</Button> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| </FullScreenModal> | ||||
| @@ -5,6 +5,7 @@ import { userFactory } from './user-factory'; | ||||
|  | ||||
| export const albumFactory = Sync.makeFactory<AlbumResponseDto>({ | ||||
|   albumName: Sync.each(() => faker.commerce.product()), | ||||
|   description: '', | ||||
|   albumThumbnailAssetId: null, | ||||
|   assetCount: Sync.each((i) => i % 5), | ||||
|   assets: [], | ||||
|   | ||||
		Reference in New Issue
	
	Block a user