From 6876eb2f0517377214eb810704f6e52054c5e7bb Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sun, 29 Mar 2026 05:05:44 +0000 Subject: [PATCH] feat: favorite albums --- mobile/openapi/README.md | 4 + mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api/albums_api.dart | 61 +++++++ mobile/openapi/lib/api_client.dart | 6 + .../openapi/lib/model/album_response_dto.dart | 11 +- .../sync_album_user_metadata_delete_v1.dart | 109 +++++++++++++ .../model/sync_album_user_metadata_v1.dart | 118 ++++++++++++++ .../openapi/lib/model/sync_entity_type.dart | 6 + .../openapi/lib/model/sync_request_type.dart | 3 + .../model/update_album_user_metadata_dto.dart | 100 ++++++++++++ open-api/immich-openapi-specs.json | 125 +++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 39 +++++ .../src/controllers/album.controller.spec.ts | 15 ++ server/src/controllers/album.controller.ts | 16 ++ server/src/database.ts | 5 + server/src/dtos/album-response.dto.spec.ts | 10 ++ server/src/dtos/album.dto.ts | 9 ++ server/src/dtos/sync.dto.ts | 20 +++ server/src/enum.ts | 3 + server/src/queries/album.repository.sql | 78 +++++++-- .../album.user.metadata.repository.sql | 10 ++ server/src/queries/album.user.repository.sql | 8 + server/src/queries/sync.repository.sql | 29 ++++ .../album-user-metadata.repository.ts | 27 ++++ .../src/repositories/album-user.repository.ts | 25 ++- server/src/repositories/album.repository.ts | 31 +++- server/src/repositories/index.ts | 2 + server/src/repositories/sync.repository.ts | 25 +++ server/src/schema/functions.ts | 13 ++ server/src/schema/index.ts | 8 + ...774800000000-AlbumUserMetadataFavorites.ts | 66 ++++++++ .../tables/album-user-metadata-audit.table.ts | 17 ++ .../tables/album-user-metadata.table.ts | 48 ++++++ server/src/services/album.service.spec.ts | 151 +++++++++++++++++- server/src/services/album.service.ts | 21 ++- server/src/services/base.service.ts | 3 + server/src/services/sync.service.ts | 17 ++ server/test/mappers.ts | 1 + server/test/medium.factory.ts | 3 + .../album-user-metadata.repository.spec.ts | 104 ++++++++++++ .../sync/sync-album-user-metadata.spec.ts | 95 +++++++++++ server/test/utils.ts | 4 + web/src/test-data/factories/album-factory.ts | 1 + 43 files changed, 1421 insertions(+), 29 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_album_user_metadata_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_album_user_metadata_v1.dart create mode 100644 mobile/openapi/lib/model/update_album_user_metadata_dto.dart create mode 100644 server/src/queries/album.user.metadata.repository.sql create mode 100644 server/src/repositories/album-user-metadata.repository.ts create mode 100644 server/src/schema/migrations/1774800000000-AlbumUserMetadataFavorites.ts create mode 100644 server/src/schema/tables/album-user-metadata-audit.table.ts create mode 100644 server/src/schema/tables/album-user-metadata.table.ts create mode 100644 server/test/medium/specs/repositories/album-user-metadata.repository.spec.ts create mode 100644 server/test/medium/specs/sync/sync-album-user-metadata.spec.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e79a60f98b..de676e5d9f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -95,6 +95,7 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role +*AlbumsApi* | [**updateAlbumUserMetadata**](doc//AlbumsApi.md#updatealbumusermetadata) | **PATCH** /albums/{id}/user-metadata | Update album user metadata *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload *AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Check existing assets *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset @@ -576,6 +577,8 @@ Class | Method | HTTP request | Description - [SyncAlbumToAssetDeleteV1](doc//SyncAlbumToAssetDeleteV1.md) - [SyncAlbumToAssetV1](doc//SyncAlbumToAssetV1.md) - [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md) + - [SyncAlbumUserMetadataDeleteV1](doc//SyncAlbumUserMetadataDeleteV1.md) + - [SyncAlbumUserMetadataV1](doc//SyncAlbumUserMetadataV1.md) - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md) - [SyncAlbumV1](doc//SyncAlbumV1.md) - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) @@ -656,6 +659,7 @@ Class | Method | HTTP request | Description - [TrashResponseDto](doc//TrashResponseDto.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) + - [UpdateAlbumUserMetadataDto](doc//UpdateAlbumUserMetadataDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 6b554fb644..c73297fa0f 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -314,6 +314,8 @@ part 'model/sync_album_delete_v1.dart'; part 'model/sync_album_to_asset_delete_v1.dart'; part 'model/sync_album_to_asset_v1.dart'; part 'model/sync_album_user_delete_v1.dart'; +part 'model/sync_album_user_metadata_delete_v1.dart'; +part 'model/sync_album_user_metadata_v1.dart'; part 'model/sync_album_user_v1.dart'; part 'model/sync_album_v1.dart'; part 'model/sync_asset_delete_v1.dart'; @@ -394,6 +396,7 @@ part 'model/transcode_policy.dart'; part 'model/trash_response_dto.dart'; part 'model/update_album_dto.dart'; part 'model/update_album_user_dto.dart'; +part 'model/update_album_user_metadata_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/usage_by_user_dto.dart'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index e2db95b9e0..4e3dca5576 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -771,4 +771,65 @@ class AlbumsApi { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } } + + /// Update album user metadata + /// + /// Update metadata for the authenticated user on a specific album. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UpdateAlbumUserMetadataDto] updateAlbumUserMetadataDto (required): + Future updateAlbumUserMetadataWithHttpInfo(String id, UpdateAlbumUserMetadataDto updateAlbumUserMetadataDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/albums/{id}/user-metadata' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = updateAlbumUserMetadataDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PATCH', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Update album user metadata + /// + /// Update metadata for the authenticated user on a specific album. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UpdateAlbumUserMetadataDto] updateAlbumUserMetadataDto (required): + Future updateAlbumUserMetadata(String id, UpdateAlbumUserMetadataDto updateAlbumUserMetadataDto,) async { + final response = await updateAlbumUserMetadataWithHttpInfo(id, updateAlbumUserMetadataDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto; + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 48e5f5874b..3cf4da9734 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -674,6 +674,10 @@ class ApiClient { return SyncAlbumToAssetV1.fromJson(value); case 'SyncAlbumUserDeleteV1': return SyncAlbumUserDeleteV1.fromJson(value); + case 'SyncAlbumUserMetadataDeleteV1': + return SyncAlbumUserMetadataDeleteV1.fromJson(value); + case 'SyncAlbumUserMetadataV1': + return SyncAlbumUserMetadataV1.fromJson(value); case 'SyncAlbumUserV1': return SyncAlbumUserV1.fromJson(value); case 'SyncAlbumV1': @@ -834,6 +838,8 @@ class ApiClient { return UpdateAlbumDto.fromJson(value); case 'UpdateAlbumUserDto': return UpdateAlbumUserDto.fromJson(value); + case 'UpdateAlbumUserMetadataDto': + return UpdateAlbumUserMetadataDto.fromJson(value); case 'UpdateAssetDto': return UpdateAssetDto.fromJson(value); case 'UpdateLibraryDto': diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 43e686fbdc..9c28e6f507 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -25,6 +25,7 @@ class AlbumResponseDto { required this.hasSharedLink, required this.id, required this.isActivityEnabled, + required this.isFavorite, this.lastModifiedAssetTimestamp, this.order, required this.owner, @@ -73,6 +74,9 @@ class AlbumResponseDto { /// Activity feed enabled bool isActivityEnabled; + /// Is favorite + bool isFavorite; + /// Last modified asset timestamp /// /// Please note: This property should have been non-nullable! Since the specification file @@ -125,6 +129,7 @@ class AlbumResponseDto { other.hasSharedLink == hasSharedLink && other.id == id && other.isActivityEnabled == isActivityEnabled && + other.isFavorite == isFavorite && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.order == order && other.owner == owner && @@ -148,6 +153,7 @@ class AlbumResponseDto { (hasSharedLink.hashCode) + (id.hashCode) + (isActivityEnabled.hashCode) + + (isFavorite.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (order == null ? 0 : order!.hashCode) + (owner.hashCode) + @@ -157,7 +163,7 @@ class AlbumResponseDto { (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, isFavorite=$isFavorite, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -181,6 +187,7 @@ class AlbumResponseDto { json[r'hasSharedLink'] = this.hasSharedLink; json[r'id'] = this.id; json[r'isActivityEnabled'] = this.isActivityEnabled; + json[r'isFavorite'] = this.isFavorite; if (this.lastModifiedAssetTimestamp != null) { json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); } else { @@ -224,6 +231,7 @@ class AlbumResponseDto { hasSharedLink: mapValueOfType(json, r'hasSharedLink')!, id: mapValueOfType(json, r'id')!, isActivityEnabled: mapValueOfType(json, r'isActivityEnabled')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), order: AssetOrder.fromJson(json[r'order']), owner: UserResponseDto.fromJson(json[r'owner'])!, @@ -288,6 +296,7 @@ class AlbumResponseDto { 'hasSharedLink', 'id', 'isActivityEnabled', + 'isFavorite', 'owner', 'ownerId', 'shared', diff --git a/mobile/openapi/lib/model/sync_album_user_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_album_user_metadata_delete_v1.dart new file mode 100644 index 0000000000..8ae1b375a8 --- /dev/null +++ b/mobile/openapi/lib/model/sync_album_user_metadata_delete_v1.dart @@ -0,0 +1,109 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAlbumUserMetadataDeleteV1 { + /// Returns a new [SyncAlbumUserMetadataDeleteV1] instance. + SyncAlbumUserMetadataDeleteV1({ + required this.albumId, + required this.userId, + }); + + /// Album ID + String albumId; + + /// User ID + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserMetadataDeleteV1 && + other.albumId == albumId && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (userId.hashCode); + + @override + String toString() => 'SyncAlbumUserMetadataDeleteV1[albumId=$albumId, userId=$userId]'; + + Map toJson() { + final json = {}; + json[r'albumId'] = this.albumId; + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [SyncAlbumUserMetadataDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAlbumUserMetadataDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAlbumUserMetadataDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAlbumUserMetadataDeleteV1( + albumId: mapValueOfType(json, r'albumId')!, + userId: mapValueOfType(json, r'userId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAlbumUserMetadataDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAlbumUserMetadataDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAlbumUserMetadataDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAlbumUserMetadataDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_album_user_metadata_v1.dart b/mobile/openapi/lib/model/sync_album_user_metadata_v1.dart new file mode 100644 index 0000000000..b3429ee32a --- /dev/null +++ b/mobile/openapi/lib/model/sync_album_user_metadata_v1.dart @@ -0,0 +1,118 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAlbumUserMetadataV1 { + /// Returns a new [SyncAlbumUserMetadataV1] instance. + SyncAlbumUserMetadataV1({ + required this.albumId, + required this.isFavorite, + required this.userId, + }); + + /// Album ID + String albumId; + + /// Is favorite + bool isFavorite; + + /// User ID + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserMetadataV1 && + other.albumId == albumId && + other.isFavorite == isFavorite && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (isFavorite.hashCode) + + (userId.hashCode); + + @override + String toString() => 'SyncAlbumUserMetadataV1[albumId=$albumId, isFavorite=$isFavorite, userId=$userId]'; + + Map toJson() { + final json = {}; + json[r'albumId'] = this.albumId; + json[r'isFavorite'] = this.isFavorite; + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [SyncAlbumUserMetadataV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAlbumUserMetadataV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAlbumUserMetadataV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAlbumUserMetadataV1( + albumId: mapValueOfType(json, r'albumId')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + userId: mapValueOfType(json, r'userId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAlbumUserMetadataV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAlbumUserMetadataV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAlbumUserMetadataV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAlbumUserMetadataV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + 'isFavorite', + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index e8db2dc4d3..daa6ac60c7 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -48,6 +48,8 @@ class SyncEntityType { static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1'); static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); + static const albumUserMetadataV1 = SyncEntityType._(r'AlbumUserMetadataV1'); + static const albumUserMetadataDeleteV1 = SyncEntityType._(r'AlbumUserMetadataDeleteV1'); static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1'); static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1'); static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1'); @@ -101,6 +103,8 @@ class SyncEntityType { albumUserV1, albumUserBackfillV1, albumUserDeleteV1, + albumUserMetadataV1, + albumUserMetadataDeleteV1, albumAssetCreateV1, albumAssetUpdateV1, albumAssetBackfillV1, @@ -189,6 +193,8 @@ class SyncEntityTypeTypeTransformer { case r'AlbumUserV1': return SyncEntityType.albumUserV1; case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1; case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; + case r'AlbumUserMetadataV1': return SyncEntityType.albumUserMetadataV1; + case r'AlbumUserMetadataDeleteV1': return SyncEntityType.albumUserMetadataDeleteV1; case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1; case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1; case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 671081c0a5..784c07bf14 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -25,6 +25,7 @@ class SyncRequestType { static const albumsV1 = SyncRequestType._(r'AlbumsV1'); static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1'); + static const albumUserMetadataV1 = SyncRequestType._(r'AlbumUserMetadataV1'); static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1'); static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1'); static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); @@ -50,6 +51,7 @@ class SyncRequestType { static const values = [ albumsV1, albumUsersV1, + albumUserMetadataV1, albumToAssetsV1, albumAssetsV1, albumAssetExifsV1, @@ -110,6 +112,7 @@ class SyncRequestTypeTypeTransformer { switch (data) { case r'AlbumsV1': return SyncRequestType.albumsV1; case r'AlbumUsersV1': return SyncRequestType.albumUsersV1; + case r'AlbumUserMetadataV1': return SyncRequestType.albumUserMetadataV1; case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1; case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1; case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; diff --git a/mobile/openapi/lib/model/update_album_user_metadata_dto.dart b/mobile/openapi/lib/model/update_album_user_metadata_dto.dart new file mode 100644 index 0000000000..2f55dfa6a4 --- /dev/null +++ b/mobile/openapi/lib/model/update_album_user_metadata_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UpdateAlbumUserMetadataDto { + /// Returns a new [UpdateAlbumUserMetadataDto] instance. + UpdateAlbumUserMetadataDto({ + required this.isFavorite, + }); + + /// Favorite status + bool isFavorite; + + @override + bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserMetadataDto && + other.isFavorite == isFavorite; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (isFavorite.hashCode); + + @override + String toString() => 'UpdateAlbumUserMetadataDto[isFavorite=$isFavorite]'; + + Map toJson() { + final json = {}; + json[r'isFavorite'] = this.isFavorite; + return json; + } + + /// Returns a new [UpdateAlbumUserMetadataDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UpdateAlbumUserMetadataDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumUserMetadataDto"); + if (value is Map) { + final json = value.cast(); + + return UpdateAlbumUserMetadataDto( + isFavorite: mapValueOfType(json, r'isFavorite')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UpdateAlbumUserMetadataDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UpdateAlbumUserMetadataDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UpdateAlbumUserMetadataDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UpdateAlbumUserMetadataDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'isFavorite', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 19427413b0..b6fa6b91fc 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2221,6 +2221,72 @@ "x-immich-state": "Stable" } }, + "/albums/{id}/user-metadata": { + "patch": { + "description": "Update metadata for the authenticated user on a specific album.", + "operationId": "updateAlbumUserMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAlbumUserMetadataDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update album user metadata", + "tags": [ + "Albums" + ], + "x-immich-history": [ + { + "version": "v2.7.0", + "state": "Added" + }, + { + "version": "v2.7.0", + "state": "Beta" + } + ], + "x-immich-permission": "album.read", + "x-immich-state": "Beta" + } + }, "/albums/{id}/user/{userId}": { "delete": { "description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.", @@ -15670,6 +15736,10 @@ "description": "Activity feed enabled", "type": "boolean" }, + "isFavorite": { + "description": "Is favorite", + "type": "boolean" + }, "lastModifiedAssetTimestamp": { "description": "Last modified asset timestamp", "format": "date-time", @@ -15716,6 +15786,7 @@ "hasSharedLink", "id", "isActivityEnabled", + "isFavorite", "owner", "ownerId", "shared", @@ -22745,6 +22816,45 @@ ], "type": "object" }, + "SyncAlbumUserMetadataDeleteV1": { + "properties": { + "albumId": { + "description": "Album ID", + "type": "string" + }, + "userId": { + "description": "User ID", + "type": "string" + } + }, + "required": [ + "albumId", + "userId" + ], + "type": "object" + }, + "SyncAlbumUserMetadataV1": { + "properties": { + "albumId": { + "description": "Album ID", + "type": "string" + }, + "isFavorite": { + "description": "Is favorite", + "type": "boolean" + }, + "userId": { + "description": "User ID", + "type": "string" + } + }, + "required": [ + "albumId", + "isFavorite", + "userId" + ], + "type": "object" + }, "SyncAlbumUserV1": { "properties": { "albumId": { @@ -23451,6 +23561,8 @@ "AlbumUserV1", "AlbumUserBackfillV1", "AlbumUserDeleteV1", + "AlbumUserMetadataV1", + "AlbumUserMetadataDeleteV1", "AlbumAssetCreateV1", "AlbumAssetUpdateV1", "AlbumAssetBackfillV1", @@ -23726,6 +23838,7 @@ "enum": [ "AlbumsV1", "AlbumUsersV1", + "AlbumUserMetadataV1", "AlbumToAssetsV1", "AlbumAssetsV1", "AlbumAssetExifsV1", @@ -25407,6 +25520,18 @@ ], "type": "object" }, + "UpdateAlbumUserMetadataDto": { + "properties": { + "isFavorite": { + "description": "Favorite status", + "type": "boolean" + } + }, + "required": [ + "isFavorite" + ], + "type": "object" + }, "UpdateAssetDto": { "properties": { "dateTimeOriginal": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fc465ce529..55123ac8a0 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -656,6 +656,8 @@ export type AlbumResponseDto = { id: string; /** Activity feed enabled */ isActivityEnabled: boolean; + /** Is favorite */ + isFavorite: boolean; /** Last modified asset timestamp */ lastModifiedAssetTimestamp?: string; /** Asset sort order */ @@ -731,6 +733,10 @@ export type BulkIdResponseDto = { /** Whether operation succeeded */ success: boolean; }; +export type UpdateAlbumUserMetadataDto = { + /** Favorite status */ + isFavorite: boolean; +}; export type UpdateAlbumUserDto = { /** Album user role */ role: AlbumUserRole; @@ -2950,6 +2956,20 @@ export type SyncAlbumUserDeleteV1 = { /** User ID */ userId: string; }; +export type SyncAlbumUserMetadataDeleteV1 = { + /** Album ID */ + albumId: string; + /** User ID */ + userId: string; +}; +export type SyncAlbumUserMetadataV1 = { + /** Album ID */ + albumId: string; + /** Is favorite */ + isFavorite: boolean; + /** User ID */ + userId: string; +}; export type SyncAlbumUserV1 = { /** Album ID */ albumId: string; @@ -3824,6 +3844,22 @@ export function addAssetsToAlbum({ id, key, slug, bulkIdsDto }: { body: bulkIdsDto }))); } +/** + * Update album user metadata + */ +export function updateAlbumUserMetadata({ id, updateAlbumUserMetadataDto }: { + id: string; + updateAlbumUserMetadataDto: UpdateAlbumUserMetadataDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AlbumResponseDto; + }>(`/albums/${encodeURIComponent(id)}/user-metadata`, oazapfts.json({ + ...opts, + method: "PATCH", + body: updateAlbumUserMetadataDto + }))); +} /** * Remove user from album */ @@ -7289,6 +7325,8 @@ export enum SyncEntityType { AlbumUserV1 = "AlbumUserV1", AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1", + AlbumUserMetadataV1 = "AlbumUserMetadataV1", + AlbumUserMetadataDeleteV1 = "AlbumUserMetadataDeleteV1", AlbumAssetCreateV1 = "AlbumAssetCreateV1", AlbumAssetUpdateV1 = "AlbumAssetUpdateV1", AlbumAssetBackfillV1 = "AlbumAssetBackfillV1", @@ -7318,6 +7356,7 @@ export enum SyncEntityType { export enum SyncRequestType { AlbumsV1 = "AlbumsV1", AlbumUsersV1 = "AlbumUsersV1", + AlbumUserMetadataV1 = "AlbumUserMetadataV1", AlbumToAssetsV1 = "AlbumToAssetsV1", AlbumAssetsV1 = "AlbumAssetsV1", AlbumAssetExifsV1 = "AlbumAssetExifsV1", diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index d13227555b..5384895e6d 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -79,6 +79,21 @@ describe(AlbumController.name, () => { }); }); + describe('PATCH /albums/:id/user-metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}/user-metadata`).send({ isFavorite: true }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should reject an invalid favorite payload', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .patch(`/albums/${factory.uuid()}/user-metadata`) + .send({ isFavorite: 'invalid' }); + expect(status).toEqual(400); + expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value'])); + }); + }); + describe('DELETE /albums/:id/assets', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).delete(`/albums/${factory.uuid()}/assets`); diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index dad70257a7..66bdd91ec4 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -12,6 +12,7 @@ import { GetAlbumsDto, UpdateAlbumDto, UpdateAlbumUserDto, + UpdateAlbumUserMetadataDto, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -89,6 +90,21 @@ export class AlbumController { return this.service.update(auth, id, dto); } + @Patch(':id/user-metadata') + @Authenticated({ permission: Permission.AlbumRead }) + @Endpoint({ + summary: 'Update album user metadata', + description: 'Update metadata for the authenticated user on a specific album.', + history: new HistoryBuilder().added('v2.7.0').beta('v2.7.0'), + }) + updateAlbumUserMetadata( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UpdateAlbumUserMetadataDto, + ): Promise { + return this.service.updateAlbumUserMetadata(auth, id, dto); + } + @Delete(':id') @Authenticated({ permission: Permission.AlbumDelete }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/server/src/database.ts b/server/src/database.ts index 4f339624e6..ada3958b80 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -403,6 +403,11 @@ export const columns = { 'asset.isEdited', ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], + syncAlbumUserMetadata: [ + 'album_user_metadata.albumId as albumId', + 'album_user_metadata.userId as userId', + 'album_user_metadata.isFavorite', + ], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index e82067580b..be768e9d4f 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -20,4 +20,14 @@ describe('mapAlbum', () => { expect(dto.startDate).toBeUndefined(); expect(dto.endDate).toBeUndefined(); }); + + it('should default isFavorite to false', () => { + const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false); + expect(dto.isFavorite).toBe(false); + }); + + it('should preserve a provided favorite state', () => { + const dto = mapAlbum({ ...getForAlbum(AlbumFactory.create()), isFavorite: true }, false); + expect(dto.isFavorite).toBe(true); + }); }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index b270125b36..26a8567a60 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -102,6 +102,11 @@ export class UpdateAlbumDto { order?: AssetOrder; } +export class UpdateAlbumUserMetadataDto { + @ValidateBoolean({ description: 'Favorite status' }) + isFavorite!: boolean; +} + export class GetAlbumsDto { @ValidateBoolean({ optional: true, @@ -183,6 +188,8 @@ export class AlbumResponseDto { endDate?: string; @ApiProperty({ description: 'Activity feed enabled' }) isActivityEnabled!: boolean; + @ApiProperty({ description: 'Is favorite' }) + isFavorite!: boolean; @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true }) order?: AssetOrder; @@ -205,6 +212,7 @@ export type MapAlbumDto = { ownerId: string; owner: ShallowDehydrateObject; isActivityEnabled: boolean; + isFavorite?: boolean; order: AssetOrder; }; @@ -256,6 +264,7 @@ export const mapAlbum = ( assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })), assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, + isFavorite: auth?.sharedLink ? false : (entity.isFavorite ?? false), order: entity.order, }; }; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 9a1332d303..d20ba2bac0 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -279,6 +279,24 @@ export class SyncAlbumUserV1 { role!: AlbumUserRole; } +@ExtraModel() +export class SyncAlbumUserMetadataDeleteV1 { + @ApiProperty({ description: 'Album ID' }) + albumId!: string; + @ApiProperty({ description: 'User ID' }) + userId!: string; +} + +@ExtraModel() +export class SyncAlbumUserMetadataV1 { + @ApiProperty({ description: 'Album ID' }) + albumId!: string; + @ApiProperty({ description: 'User ID' }) + userId!: string; + @ApiProperty({ description: 'Is favorite' }) + isFavorite!: boolean; +} + @ExtraModel() export class SyncAlbumV1 { @ApiProperty({ description: 'Album ID' }) @@ -511,6 +529,8 @@ export type SyncItem = { [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; + [SyncEntityType.AlbumUserMetadataV1]: SyncAlbumUserMetadataV1; + [SyncEntityType.AlbumUserMetadataDeleteV1]: SyncAlbumUserMetadataDeleteV1; [SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1; [SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1; [SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 1277a39036..aa62c38c10 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -723,6 +723,7 @@ export enum ExitCode { export enum SyncRequestType { AlbumsV1 = 'AlbumsV1', AlbumUsersV1 = 'AlbumUsersV1', + AlbumUserMetadataV1 = 'AlbumUserMetadataV1', AlbumToAssetsV1 = 'AlbumToAssetsV1', AlbumAssetsV1 = 'AlbumAssetsV1', AlbumAssetExifsV1 = 'AlbumAssetExifsV1', @@ -777,6 +778,8 @@ export enum SyncEntityType { AlbumUserV1 = 'AlbumUserV1', AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', + AlbumUserMetadataV1 = 'AlbumUserMetadataV1', + AlbumUserMetadataDeleteV1 = 'AlbumUserMetadataDeleteV1', AlbumAssetCreateV1 = 'AlbumAssetCreateV1', AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1', diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index cc15260bdb..1057ed6494 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -21,6 +21,18 @@ select "user"."id" = "album"."ownerId" ) as obj ) as "owner", + coalesce( + ( + select + "album_user_metadata"."isFavorite" + from + "album_user_metadata" + where + "album_user_metadata"."albumId" = "album"."id" + and "album_user_metadata"."userId" = $1 + ), + false + ) as "isFavorite", ( select coalesce(json_agg(agg), '[]') @@ -88,12 +100,24 @@ select from "album" where - "album"."id" = $1 + "album"."id" = $2 and "album"."deletedAt" is null -- AlbumRepository.getByAssetId select "album".*, + coalesce( + ( + select + "album_user_metadata"."isFavorite" + from + "album_user_metadata" + where + "album_user_metadata"."albumId" = "album"."id" + and "album_user_metadata"."userId" = $1 + ), + false + ) as "isFavorite", ( select to_json(obj) @@ -148,17 +172,17 @@ from inner join "album_asset" on "album_asset"."albumId" = "album"."id" where ( - "album"."ownerId" = $1 + "album"."ownerId" = $2 or exists ( select from "album_user" where "album_user"."albumId" = "album"."id" - and "album_user"."userId" = $2 + and "album_user"."userId" = $3 ) ) - and "album_asset"."assetId" = $3 + and "album_asset"."assetId" = $4 and "album"."deletedAt" is null order by "album"."createdAt" desc, @@ -210,6 +234,18 @@ group by -- AlbumRepository.getOwned select "album".*, + coalesce( + ( + select + "album_user_metadata"."isFavorite" + from + "album_user_metadata" + where + "album_user_metadata"."albumId" = "album"."id" + and "album_user_metadata"."userId" = $1 + ), + false + ) as "isFavorite", ( select to_json(obj) @@ -275,7 +311,7 @@ select from "album" where - "album"."ownerId" = $1 + "album"."ownerId" = $2 and "album"."deletedAt" is null order by "album"."createdAt" desc @@ -283,6 +319,18 @@ order by -- AlbumRepository.getShared select "album".*, + coalesce( + ( + select + "album_user_metadata"."isFavorite" + from + "album_user_metadata" + where + "album_user_metadata"."albumId" = "album"."id" + and "album_user_metadata"."userId" = $1 + ), + false + ) as "isFavorite", ( select coalesce(json_agg(agg), '[]') @@ -356,8 +404,8 @@ where where "album_user"."albumId" = "album"."id" and ( - "album"."ownerId" = $1 - or "album_user"."userId" = $2 + "album"."ownerId" = $2 + or "album_user"."userId" = $3 ) ) or exists ( @@ -366,7 +414,7 @@ where "shared_link" where "shared_link"."albumId" = "album"."id" - and "shared_link"."userId" = $3 + and "shared_link"."userId" = $4 ) ) and "album"."deletedAt" is null @@ -376,6 +424,18 @@ order by -- AlbumRepository.getNotShared select "album".*, + coalesce( + ( + select + "album_user_metadata"."isFavorite" + from + "album_user_metadata" + where + "album_user_metadata"."albumId" = "album"."id" + and "album_user_metadata"."userId" = $1 + ), + false + ) as "isFavorite", ( select to_json(obj) @@ -397,7 +457,7 @@ select from "album" where - "album"."ownerId" = $1 + "album"."ownerId" = $2 and "album"."deletedAt" is null and not exists ( select diff --git a/server/src/queries/album.user.metadata.repository.sql b/server/src/queries/album.user.metadata.repository.sql new file mode 100644 index 0000000000..3febf7f26f --- /dev/null +++ b/server/src/queries/album.user.metadata.repository.sql @@ -0,0 +1,10 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- AlbumUserMetadataRepository.upsert +insert into + "album_user_metadata" ("albumId", "userId", "isFavorite") +values + ($1, $2, $3) +on conflict ("albumId", "userId") do update +set + "isFavorite" = "excluded"."isFavorite" diff --git a/server/src/queries/album.user.repository.sql b/server/src/queries/album.user.repository.sql index fc4a52bae2..450a1a6b4b 100644 --- a/server/src/queries/album.user.repository.sql +++ b/server/src/queries/album.user.repository.sql @@ -1,6 +1,7 @@ -- NOTE: This file is auto generated by ./sql-generator -- AlbumUserRepository.create +begin insert into "album_user" ("userId", "albumId") values @@ -9,6 +10,7 @@ returning "userId", "albumId", "role" +rollback -- AlbumUserRepository.update update "album_user" @@ -19,7 +21,13 @@ where and "albumId" = $3 -- AlbumUserRepository.delete +begin +delete from "album_user_metadata" +where + "userId" = $1 + and "albumId" = $2 delete from "album_user" where "userId" = $1 and "albumId" = $2 +commit diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index a4acc95e29..41dc49ec7a 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -285,6 +285,35 @@ where order by "album_asset"."updateId" asc +-- SyncRepository.albumUserMetadata.getDeletes +select + "id", + "albumId", + "userId" +from + "album_user_metadata_audit" as "album_user_metadata_audit" +where + "album_user_metadata_audit"."id" < $1 + and "album_user_metadata_audit"."id" > $2 + and "userId" = $3 +order by + "album_user_metadata_audit"."id" asc + +-- SyncRepository.albumUserMetadata.getUpserts +select + "album_user_metadata"."albumId" as "albumId", + "album_user_metadata"."userId" as "userId", + "album_user_metadata"."isFavorite", + "album_user_metadata"."updateId" +from + "album_user_metadata" as "album_user_metadata" +where + "album_user_metadata"."updateId" < $1 + and "album_user_metadata"."updateId" > $2 + and "userId" = $3 +order by + "album_user_metadata"."updateId" asc + -- SyncRepository.albumToAsset.getBackfill select "album_asset"."assetId" as "assetId", diff --git a/server/src/repositories/album-user-metadata.repository.ts b/server/src/repositories/album-user-metadata.repository.ts new file mode 100644 index 0000000000..805fc744f8 --- /dev/null +++ b/server/src/repositories/album-user-metadata.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { DB } from 'src/schema'; +import { AlbumUserMetadataTable } from 'src/schema/tables/album-user-metadata.table'; + +export type AlbumUserMetadataId = { + albumId: string; + userId: string; +}; + +@Injectable() +export class AlbumUserMetadataRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID, isFavorite: true }] }) + async upsert(dto: Insertable) { + await this.db + .insertInto('album_user_metadata') + .values(dto) + .onConflict((oc) => + oc.columns(['albumId', 'userId']).doUpdateSet((eb) => ({ isFavorite: eb.ref('excluded.isFavorite') })), + ) + .execute(); + } +} diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index 558a0c05d7..c4c05d19a6 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -17,11 +17,21 @@ export class AlbumUserRepository { @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) create(albumUser: Insertable) { - return this.db - .insertInto('album_user') - .values(albumUser) - .returning(['userId', 'albumId', 'role']) - .executeTakeFirstOrThrow(); + return this.db.transaction().execute(async (tx) => { + const result = await tx + .insertInto('album_user') + .values(albumUser) + .returning(['userId', 'albumId', 'role']) + .executeTakeFirstOrThrow(); + + await tx + .insertInto('album_user_metadata') + .values({ albumId: albumUser.albumId, userId: albumUser.userId, isFavorite: false }) + .onConflict((oc) => oc.columns(['albumId', 'userId']).doNothing()) + .execute(); + + return result; + }); } @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] }) @@ -36,6 +46,9 @@ export class AlbumUserRepository { @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) async delete({ userId, albumId }: AlbumPermissionId): Promise { - await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute(); + await this.db.transaction().execute(async (tx) => { + await tx.deleteFrom('album_user_metadata').where('userId', '=', userId).where('albumId', '=', albumId).execute(); + await tx.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute(); + }); } } diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index e4d802b93c..d7159c5101 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -31,6 +31,18 @@ export interface AlbumInfoOptions { withAssets: boolean; } +const withFavorite = (eb: ExpressionBuilder, userId?: string) => { + if (!userId) { + return sql`false`.as('isFavorite'); + } + + return sql`coalesce(${eb + .selectFrom('album_user_metadata') + .select('album_user_metadata.isFavorite') + .whereRef('album_user_metadata.albumId', '=', 'album.id') + .where('album_user_metadata.userId', '=', userId)}, false)`.as('isFavorite'); +}; + const withOwner = (eb: ExpressionBuilder) => { return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId')) .$notNull() @@ -84,14 +96,15 @@ const withAssets = (eb: ExpressionBuilder) => { export class AlbumRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] }) - async getById(id: string, options: AlbumInfoOptions) { + @GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] }) + async getById(id: string, options: AlbumInfoOptions, userId?: string) { return this.db .selectFrom('album') .selectAll('album') .where('album.id', '=', id) .where('album.deletedAt', 'is', null) .select(withOwner) + .select((eb) => withFavorite(eb, userId)) .select(withAlbumUsers) .select(withSharedLink) .$if(options.withAssets, (eb) => eb.select(withAssets)) @@ -119,6 +132,7 @@ export class AlbumRepository { .where('album_asset.assetId', '=', assetId) .where('album.deletedAt', 'is', null) .orderBy('album.createdAt', 'desc') + .select((eb) => withFavorite(eb, ownerId)) .select(withOwner) .select(withAlbumUsers) .orderBy('album.createdAt', 'desc') @@ -194,6 +208,7 @@ export class AlbumRepository { return this.db .selectFrom('album') .selectAll('album') + .select((eb) => withFavorite(eb, ownerId)) .select(withOwner) .select(withAlbumUsers) .select(withSharedLink) @@ -211,6 +226,7 @@ export class AlbumRepository { return this.db .selectFrom('album') .selectAll('album') + .select((eb) => withFavorite(eb, ownerId)) .where((eb) => eb.or([ eb.exists( @@ -243,6 +259,7 @@ export class AlbumRepository { return this.db .selectFrom('album') .selectAll('album') + .select((eb) => withFavorite(eb, ownerId)) .where('album.ownerId', '=', ownerId) .where('album.deletedAt', 'is', null) .where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id')))) @@ -318,6 +335,15 @@ export class AlbumRepository { throw new Error('Failed to create album'); } + await tx + .insertInto('album_user_metadata') + .values([ + { albumId: newAlbum.id, userId: album.ownerId, isFavorite: false }, + ...albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, isFavorite: false })), + ]) + .onConflict((oc) => oc.columns(['albumId', 'userId']).doNothing()) + .execute(); + if (assetIds.length > 0) { await this.addAssets(tx, newAlbum.id, assetIds); } @@ -335,6 +361,7 @@ export class AlbumRepository { .selectFrom('album') .selectAll('album') .where('id', '=', newAlbum.id) + .select((eb) => withFavorite(eb, album.ownerId)) .select(withOwner) .select(withAssets) .select(withAlbumUsers) diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 361a2e7179..6e3d7040cc 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,5 +1,6 @@ import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; @@ -55,6 +56,7 @@ export const repositories = [ AccessRepository, ActivityRepository, AlbumRepository, + AlbumUserMetadataRepository, AlbumUserRepository, AuditRepository, ApiKeyRepository, diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 486bf15163..c0df15a61a 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -49,6 +49,7 @@ export class SyncRepository { album: AlbumSync; albumAsset: AlbumAssetSync; albumAssetExif: AlbumAssetExifSync; + albumUserMetadata: AlbumUserMetadataSync; albumToAsset: AlbumToAssetSync; albumUser: AlbumUserSync; asset: AssetSync; @@ -72,6 +73,7 @@ export class SyncRepository { this.album = new AlbumSync(this.db); this.albumAsset = new AlbumAssetSync(this.db); this.albumAssetExif = new AlbumAssetExifSync(this.db); + this.albumUserMetadata = new AlbumUserMetadataSync(this.db); this.albumToAsset = new AlbumToAssetSync(this.db); this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); @@ -385,6 +387,29 @@ class AlbumUserSync extends BaseSync { } } +class AlbumUserMetadataSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getDeletes(options: SyncQueryOptions) { + return this.auditQuery('album_user_metadata_audit', options) + .select(['id', 'albumId', 'userId']) + .where('userId', '=', options.userId) + .stream(); + } + + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('album_user_metadata_audit', daysAgo); + } + + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getUpserts(options: SyncQueryOptions) { + return this.upsertQuery('album_user_metadata', options) + .select(columns.syncAlbumUserMetadata) + .select('album_user_metadata.updateId') + .where('userId', '=', options.userId) + .stream(); + } +} + class AssetSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getDeletes(options: SyncQueryOptions) { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 6dbbd28b1a..05f304459d 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -165,6 +165,19 @@ export const album_user_delete_audit = registerFunction({ END`, }); +export const album_user_metadata_audit = registerFunction({ + name: 'album_user_metadata_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO album_user_metadata_audit ("albumId", "userId") + SELECT "albumId", "userId" + FROM OLD; + RETURN NULL; + END`, +}); + export const memory_delete_audit = registerFunction({ name: 'memory_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 2426c2aab7..ad7e710f8e 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -4,6 +4,7 @@ import { album_delete_audit, album_user_after_insert, album_user_delete_audit, + album_user_metadata_audit, asset_delete_audit, asset_face_audit, asset_metadata_audit, @@ -25,6 +26,8 @@ import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table' import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; import { AlbumAuditTable } from 'src/schema/tables/album-audit.table'; import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table'; +import { AlbumUserMetadataAuditTable } from 'src/schema/tables/album-user-metadata-audit.table'; +import { AlbumUserMetadataTable } from 'src/schema/tables/album-user-metadata.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; @@ -83,6 +86,8 @@ export class ImmichDatabase { AlbumAssetTable, AlbumAssetAuditTable, AlbumAuditTable, + AlbumUserMetadataAuditTable, + AlbumUserMetadataTable, AlbumUserAuditTable, AlbumUserTable, AlbumTable, @@ -150,6 +155,7 @@ export class ImmichDatabase { asset_delete_audit, album_delete_audit, album_user_after_insert, + album_user_metadata_audit, album_user_delete_audit, memory_delete_audit, memory_asset_delete_audit, @@ -178,6 +184,8 @@ export interface DB { album_audit: AlbumAuditTable; album_asset: AlbumAssetTable; album_asset_audit: AlbumAssetAuditTable; + album_user_metadata: AlbumUserMetadataTable; + album_user_metadata_audit: AlbumUserMetadataAuditTable; album_user: AlbumUserTable; album_user_audit: AlbumUserAuditTable; diff --git a/server/src/schema/migrations/1774800000000-AlbumUserMetadataFavorites.ts b/server/src/schema/migrations/1774800000000-AlbumUserMetadataFavorites.ts new file mode 100644 index 0000000000..d4824868a3 --- /dev/null +++ b/server/src/schema/migrations/1774800000000-AlbumUserMetadataFavorites.ts @@ -0,0 +1,66 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION album_user_metadata_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO album_user_metadata_audit ("albumId", "userId") + SELECT "albumId", "userId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "album_user_metadata_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "albumId" uuid NOT NULL, + "userId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "album_user_metadata_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "album_user_metadata_audit_albumId_idx" ON "album_user_metadata_audit" ("albumId");`.execute(db); + await sql`CREATE INDEX "album_user_metadata_audit_userId_idx" ON "album_user_metadata_audit" ("userId");`.execute(db); + await sql`CREATE INDEX "album_user_metadata_audit_deletedAt_idx" ON "album_user_metadata_audit" ("deletedAt");`.execute(db); + await sql`CREATE TABLE "album_user_metadata" ( + "albumId" uuid NOT NULL, + "userId" uuid NOT NULL, + "isFavorite" boolean NOT NULL DEFAULT false, + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "album_user_metadata_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "album" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "album_user_metadata_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "album_user_metadata_pkey" PRIMARY KEY ("albumId", "userId") +);`.execute(db); + await sql`CREATE INDEX "album_user_metadata_userId_idx" ON "album_user_metadata" ("userId");`.execute(db); + await sql`CREATE INDEX "album_user_metadata_updateId_idx" ON "album_user_metadata" ("updateId");`.execute(db); + await sql`CREATE INDEX "album_user_metadata_updatedAt_idx" ON "album_user_metadata" ("updatedAt");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_user_metadata_audit" + AFTER DELETE ON "album_user_metadata" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION album_user_metadata_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_user_metadata_updated_at" + BEFORE UPDATE ON "album_user_metadata" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "album_user_metadata" ("albumId", "userId", "isFavorite") + SELECT "id", "ownerId", false FROM "album" + ON CONFLICT ("albumId", "userId") DO NOTHING;`.execute(db); + await sql`INSERT INTO "album_user_metadata" ("albumId", "userId", "isFavorite") + SELECT "albumId", "userId", false FROM "album_user" + ON CONFLICT ("albumId", "userId") DO NOTHING;`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_metadata_audit', '{"type":"function","name":"album_user_metadata_audit","sql":"CREATE OR REPLACE FUNCTION album_user_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_user_metadata_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_metadata_audit', '{"type":"trigger","name":"album_user_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"album_user_metadata_audit\\"\\n AFTER DELETE ON \\"album_user_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_user_metadata_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_metadata_updated_at', '{"type":"trigger","name":"album_user_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"album_user_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"album_user_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "album_user_metadata_audit";`.execute(db); + await sql`DROP TABLE "album_user_metadata";`.execute(db); + await sql`DROP FUNCTION album_user_metadata_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_metadata_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_metadata_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_metadata_updated_at';`.execute(db); +} diff --git a/server/src/schema/tables/album-user-metadata-audit.table.ts b/server/src/schema/tables/album-user-metadata-audit.table.ts new file mode 100644 index 0000000000..4a2ec70c06 --- /dev/null +++ b/server/src/schema/tables/album-user-metadata-audit.table.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; + +@Table('album_user_metadata_audit') +export class AlbumUserMetadataAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + albumId!: string; + + @Column({ type: 'uuid', index: true }) + userId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/album-user-metadata.table.ts b/server/src/schema/tables/album-user-metadata.table.ts new file mode 100644 index 0000000000..faf6d002a4 --- /dev/null +++ b/server/src/schema/tables/album-user-metadata.table.ts @@ -0,0 +1,48 @@ +import { + AfterDeleteTrigger, + Column, + ForeignKeyColumn, + Generated, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { album_user_metadata_audit } from 'src/schema/functions'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { UserTable } from 'src/schema/tables/user.table'; + +@UpdatedAtTrigger('album_user_metadata_updated_at') +@Table('album_user_metadata') +@AfterDeleteTrigger({ + scope: 'statement', + function: album_user_metadata_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) +export class AlbumUserMetadataTable { + @ForeignKeyColumn(() => AlbumTable, { + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + primary: true, + index: false, + }) + albumId!: string; + + @ForeignKeyColumn(() => UserTable, { + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + primary: true, + index: true, + }) + userId!: string; + + @Column({ type: 'boolean', default: false }) + isFavorite!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; + + @UpdateDateColumn({ index: true }) + updatedAt!: Generated; +} diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 47646d0c6d..d367dc3a44 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -112,6 +112,23 @@ describe(AlbumService.name, () => { expect(mocks.album.getShared).toHaveBeenCalledTimes(1); }); + it('includes favorite status in album lists', async () => { + const album = AlbumFactory.create(); + mocks.album.getOwned.mockResolvedValue([{ ...getForAlbum(album), isFavorite: true }]); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 0, + startDate: null, + endDate: null, + lastModifiedAssetTimestamp: null, + }, + ]); + + const result = await sut.getAll(AuthFactory.create(album.owner), {}); + expect(result[0].isFavorite).toBe(true); + }); + it('gets list of albums that are NOT shared', async () => { const album = AlbumFactory.create(); mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]); @@ -337,12 +354,42 @@ describe(AlbumService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.update.mockResolvedValue(getForAlbum(album)); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 0, + startDate: null, + endDate: null, + lastModifiedAssetTimestamp: null, + }, + ]); await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }); expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' }); }); + + it('should preserve favorite status in the response', async () => { + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.update.mockResolvedValue(getForAlbum(album)); + mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true }); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 0, + startDate: null, + endDate: null, + lastModifiedAssetTimestamp: null, + }, + ]); + + const result = await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }); + expect(result.isFavorite).toBe(true); + }); }); describe('delete', () => { @@ -430,7 +477,16 @@ describe(AlbumService.name, () => { const user = UserFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(getForAlbum(album)); - mocks.album.update.mockResolvedValue(getForAlbum(album)); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 0, + startDate: null, + endDate: null, + lastModifiedAssetTimestamp: null, + }, + ]); mocks.user.get.mockResolvedValue(user); mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); @@ -468,7 +524,7 @@ describe(AlbumService.name, () => { expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId }); - expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, undefined); }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { @@ -565,10 +621,28 @@ describe(AlbumService.name, () => { await sut.get(AuthFactory.create(album.owner), album.id, {}); - expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, album.owner.id); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id])); }); + it('should include favorite status for the authenticated user', async () => { + const album = AlbumFactory.create(); + mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true }); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + lastModifiedAssetTimestamp: new Date('1970-01-01'), + }, + ]); + + const result = await sut.get(AuthFactory.create(album.owner), album.id, {}); + expect(result.isFavorite).toBe(true); + }); + it('should get a shared album via a shared link', async () => { const album = AlbumFactory.from().albumUser().build(); mocks.album.getById.mockResolvedValue(getForAlbum(album)); @@ -586,7 +660,7 @@ describe(AlbumService.name, () => { const auth = AuthFactory.from().sharedLink().build(); await sut.get(auth, album.id, {}); - expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, auth.user.id); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id])); }); @@ -607,7 +681,7 @@ describe(AlbumService.name, () => { await sut.get(AuthFactory.create(user), album.id, {}); - expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); + expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, user.id); expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( user.id, new Set([album.id]), @@ -615,6 +689,26 @@ describe(AlbumService.name, () => { ); }); + it('should not expose favorite status over shared links', async () => { + const album = AlbumFactory.create(); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + lastModifiedAssetTimestamp: new Date('1970-01-01'), + }, + ]); + + const auth = AuthFactory.from().sharedLink().build(); + const result = await sut.get(auth, album.id, {}); + + expect(result.isFavorite).toBe(false); + }); + it('should throw an error for no access', async () => { const auth = AuthFactory.create(); await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); @@ -628,6 +722,53 @@ describe(AlbumService.name, () => { }); }); + describe('updateAlbumUserMetadata', () => { + it('should update favorite status for an owned album', async () => { + const album = AlbumFactory.create(); + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.albumUserMetadata.upsert.mockResolvedValue(); + mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true }); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 0, + startDate: null, + endDate: null, + lastModifiedAssetTimestamp: null, + }, + ]); + + const result = await sut.updateAlbumUserMetadata(AuthFactory.create(album.owner), album.id, { isFavorite: true }); + + expect(mocks.albumUserMetadata.upsert).toHaveBeenCalledWith({ + albumId: album.id, + userId: album.owner.id, + isFavorite: true, + }); + expect(result.isFavorite).toBe(true); + }); + + it('should allow shared viewers to update favorite status', async () => { + const viewer = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: viewer.id, role: AlbumUserRole.Viewer }).build(); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); + mocks.albumUserMetadata.upsert.mockResolvedValue(); + mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true }); + mocks.album.getMetadataForIds.mockResolvedValue([ + { + albumId: album.id, + assetCount: 0, + startDate: null, + endDate: null, + lastModifiedAssetTimestamp: null, + }, + ]); + + const result = await sut.updateAlbumUserMetadata(AuthFactory.create(viewer), album.id, { isFavorite: true }); + expect(result.isFavorite).toBe(true); + }); + }); + describe('addAssets', () => { it('should allow the owner to add assets', async () => { const owner = UserFactory.create({ isAdmin: true }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 547ec63bf8..143292bbfc 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -14,6 +14,7 @@ import { mapAlbumWithoutAssets, UpdateAlbumDto, UpdateAlbumUserDto, + UpdateAlbumUserMetadataDto, } from 'src/dtos/album.dto'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -77,7 +78,7 @@ export class AlbumService extends BaseService { await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; - const album = await this.findOrFail(id, { withAssets }); + const album = await this.findOrFail(id, { withAssets }, auth.user.id); const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]); const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0; @@ -147,7 +148,7 @@ export class AlbumService extends BaseService { throw new BadRequestException('Invalid album thumbnail'); } } - const updatedAlbum = await this.albumRepository.update(album.id, { + await this.albumRepository.update(album.id, { id: album.id, albumName: dto.albumName, description: dto.description, @@ -156,7 +157,15 @@ export class AlbumService extends BaseService { order: dto.order, }); - return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets }); + return this.get(auth, id, { withoutAssets: true }); + } + + async updateAlbumUserMetadata(auth: AuthDto, id: string, dto: UpdateAlbumUserMetadataDto): Promise { + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); + + await this.albumUserMetadataRepository.upsert({ albumId: id, userId: auth.user.id, isFavorite: dto.isFavorite }); + + return this.get(auth, id, { withoutAssets: true }); } async delete(auth: AuthDto, id: string): Promise { @@ -306,7 +315,7 @@ export class AlbumService extends BaseService { await this.eventRepository.emit('AlbumInvite', { id, userId }); } - return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); + return this.get(auth, id, { withoutAssets: true }); } async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise { @@ -338,8 +347,8 @@ export class AlbumService extends BaseService { await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } - private async findOrFail(id: string, options: AlbumInfoOptions) { - const album = await this.albumRepository.getById(id, options); + private async findOrFail(id: string, options: AlbumInfoOptions, userId?: string) { + const album = await this.albumRepository.getById(id, options, userId); if (!album) { throw new BadRequestException('Album not found'); } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index b3a50a07ae..97c8aa2700 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -7,6 +7,7 @@ import { StorageCore } from 'src/cores/storage.core'; import { UserAdmin } from 'src/database'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; @@ -66,6 +67,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ AccessRepository, ActivityRepository, AlbumRepository, + AlbumUserMetadataRepository, AlbumUserRepository, ApiKeyRepository, AppRepository, @@ -125,6 +127,7 @@ export class BaseService { protected accessRepository: AccessRepository, protected activityRepository: ActivityRepository, protected albumRepository: AlbumRepository, + protected albumUserMetadataRepository: AlbumUserMetadataRepository, protected albumUserRepository: AlbumUserRepository, protected apiKeyRepository: ApiKeyRepository, protected appRepository: AppRepository, diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 9bdeca14d7..5925cf3ff4 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -78,6 +78,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.AlbumAssetsV1, SyncRequestType.AlbumsV1, SyncRequestType.AlbumUsersV1, + SyncRequestType.AlbumUserMetadataV1, SyncRequestType.AlbumToAssetsV1, SyncRequestType.AssetExifsV1, SyncRequestType.AlbumAssetExifsV1, @@ -183,6 +184,7 @@ export class SyncService extends BaseService { this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap), [SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id), + [SyncRequestType.AlbumUserMetadataV1]: () => this.syncAlbumUserMetadataV1(options, response, checkpointMap), [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumAssetExifsV1]: () => @@ -213,6 +215,7 @@ export class SyncService extends BaseService { await this.syncRepository.album.cleanupAuditTable(pruneThreshold); await this.syncRepository.albumUser.cleanupAuditTable(pruneThreshold); + await this.syncRepository.albumUserMetadata.cleanupAuditTable(pruneThreshold); await this.syncRepository.albumToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.asset.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold); @@ -489,6 +492,20 @@ export class SyncService extends BaseService { } } + private async syncAlbumUserMetadataV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AlbumUserMetadataDeleteV1; + const deletes = this.syncRepository.albumUserMetadata.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AlbumUserMetadataV1; + const upserts = this.syncRepository.albumUserMetadata.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncAlbumAssetsV1( options: SyncQueryOptions, response: Writable, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index ed2c9431f3..d6a8e176b6 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -76,6 +76,7 @@ export const getDehydrated = >(entity: T) => { export const getForAlbum = (album: ReturnType) => ({ ...album, + isFavorite: false, assets: album.assets.map((asset) => getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }), ), diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 1906fc0ccb..c19484c3a9 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -20,6 +20,7 @@ import { } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; @@ -401,6 +402,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { switch (key) { case AccessRepository: case AlbumRepository: + case AlbumUserMetadataRepository: case AlbumUserRepository: case ActivityRepository: case AssetRepository: @@ -465,6 +467,7 @@ const newMockRepository = (key: ClassConstructor) => { switch (key) { case ActivityRepository: case AlbumRepository: + case AlbumUserMetadataRepository: case AssetRepository: case AssetJobRepository: case ConfigRepository: diff --git a/server/test/medium/specs/repositories/album-user-metadata.repository.spec.ts b/server/test/medium/specs/repositories/album-user-metadata.repository.spec.ts new file mode 100644 index 0000000000..c956493cf7 --- /dev/null +++ b/server/test/medium/specs/repositories/album-user-metadata.repository.spec.ts @@ -0,0 +1,104 @@ +import { Kysely } from 'kysely'; +import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { BaseService } from 'src/services/base.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + + return { + ctx, + sut: ctx.get(AlbumUserMetadataRepository), + albumUserRepo: ctx.get(AlbumUserRepository), + }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AlbumUserMetadataRepository.name, () => { + it('should create an owner metadata row when an album is created', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + await expect( + ctx.database + .selectFrom('album_user_metadata') + .select(['albumId', 'userId', 'isFavorite']) + .where('albumId', '=', album.id) + .where('userId', '=', user.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ + albumId: album.id, + userId: user.id, + isFavorite: false, + }); + }); + + it('should create a shared-user metadata row when an album user is added', async () => { + const { ctx, albumUserRepo } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: sharedUser } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: owner.id }); + + await albumUserRepo.create({ albumId: album.id, userId: sharedUser.id }); + + await expect( + ctx.database + .selectFrom('album_user_metadata') + .select(['albumId', 'userId', 'isFavorite']) + .where('albumId', '=', album.id) + .where('userId', '=', sharedUser.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ + albumId: album.id, + userId: sharedUser.id, + isFavorite: false, + }); + }); + + it('should delete metadata and write an audit row when album access is removed', async () => { + const { ctx, albumUserRepo, sut } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: sharedUser } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: owner.id }); + + await albumUserRepo.create({ albumId: album.id, userId: sharedUser.id }); + await sut.upsert({ albumId: album.id, userId: sharedUser.id, isFavorite: true }); + + await albumUserRepo.delete({ albumId: album.id, userId: sharedUser.id }); + + await expect( + ctx.database + .selectFrom('album_user_metadata') + .select('albumId') + .where('albumId', '=', album.id) + .where('userId', '=', sharedUser.id) + .executeTakeFirst(), + ).resolves.toBeUndefined(); + + await expect( + ctx.database + .selectFrom('album_user_metadata_audit') + .select(['albumId', 'userId']) + .where('albumId', '=', album.id) + .where('userId', '=', sharedUser.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ + albumId: album.id, + userId: sharedUser.id, + }); + }); +}); diff --git a/server/test/medium/specs/sync/sync-album-user-metadata.spec.ts b/server/test/medium/specs/sync/sync-album-user-metadata.spec.ts new file mode 100644 index 0000000000..97f90f184f --- /dev/null +++ b/server/test/medium/specs/sync/sync-album-user-metadata.spec.ts @@ -0,0 +1,95 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AlbumUserMetadataV1, () => { + it('should sync owner album metadata rows', async () => { + const { auth, ctx } = await setup(); + const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + userId: auth.user.id, + isFavorite: false, + }, + type: SyncEntityType.AlbumUserMetadataV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + it('should sync favorite updates', async () => { + const { auth, ctx } = await setup(); + const repo = ctx.get(AlbumUserMetadataRepository); + const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); + + const initial = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]); + await ctx.syncAckAll(auth, initial); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUserMetadataV1]); + + await repo.upsert({ albumId: album.id, userId: auth.user.id, isFavorite: true }); + + const updated = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]); + expect(updated).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + userId: auth.user.id, + isFavorite: true, + }, + type: SyncEntityType.AlbumUserMetadataV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); +}); + +describe(SyncEntityType.AlbumUserMetadataDeleteV1, () => { + it('should sync metadata deletes when shared album access is removed', async () => { + const { auth, ctx } = await setup(); + const albumUserRepo = ctx.get(AlbumUserRepository); + const { user: owner } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: owner.id }); + await albumUserRepo.create({ albumId: album.id, userId: auth.user.id }); + + const initial = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]); + await ctx.syncAckAll(auth, initial); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUserMetadataV1]); + + await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id }); + + const deleted = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]); + expect(deleted).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + userId: auth.user.id, + }, + type: SyncEntityType.AlbumUserMetadataDeleteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); +}); diff --git a/server/test/utils.ts b/server/test/utils.ts index b3e47b2b7e..4c564e6fb5 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -16,6 +16,7 @@ import { AuthGuard } from 'src/middleware/auth.guard'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; @@ -211,6 +212,7 @@ export type ServiceOverrides = { access: AccessRepository; activity: ActivityRepository; album: AlbumRepository; + albumUserMetadata: AlbumUserMetadataRepository; albumUser: AlbumUserRepository; apiKey: ApiKeyRepository; app: AppRepository; @@ -296,6 +298,7 @@ export const getMocks = () => { activity: automock(ActivityRepository), audit: automock(AuditRepository), album: automock(AlbumRepository, { strict: false }), + albumUserMetadata: automock(AlbumUserMetadataRepository), albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), @@ -362,6 +365,7 @@ export const newTestService = ( overrides.access || (mocks.access as IAccessRepository as AccessRepository), overrides.activity || (mocks.activity as As), overrides.album || (mocks.album as As), + overrides.albumUserMetadata || (mocks.albumUserMetadata as As), overrides.albumUser || (mocks.albumUser as As), overrides.apiKey || (mocks.apiKey as As), overrides.app || (mocks.app as As), diff --git a/web/src/test-data/factories/album-factory.ts b/web/src/test-data/factories/album-factory.ts index 7ea3715f73..53575877d8 100644 --- a/web/src/test-data/factories/album-factory.ts +++ b/web/src/test-data/factories/album-factory.ts @@ -18,5 +18,6 @@ export const albumFactory = Sync.makeFactory({ albumUsers: [], hasSharedLink: false, isActivityEnabled: true, + isFavorite: false, order: AssetOrder.Desc, });