Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Tran 17b3676038 chore: e2e test 2026-03-29 14:09:48 +00:00
Alex Tran 6876eb2f05 feat: favorite albums 2026-03-29 06:18:57 +00:00
45 changed files with 1461 additions and 29 deletions
@@ -424,6 +424,7 @@ describe('/albums', () => {
description: '',
albumThumbnailAssetId: null,
shared: false,
isFavorite: false,
albumUsers: [],
hasSharedLink: false,
assets: [],
@@ -540,6 +541,44 @@ describe('/albums', () => {
});
});
describe('PATCH /albums/:id/user-metadata', () => {
it('should toggle favorite status per user on a shared album', async () => {
const before = await getAlbumInfo({ id: user1Albums[3].id }, { headers: asBearerAuth(user2.accessToken) });
expect(before.isFavorite).toBe(false);
const favoriteResponse = await request(app)
.patch(`/albums/${user1Albums[3].id}/user-metadata`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ isFavorite: true });
expect(favoriteResponse.status).toBe(200);
expect(favoriteResponse.body).toMatchObject({ id: user1Albums[3].id, isFavorite: true });
const favoritedForViewer = await getAlbumInfo(
{ id: user1Albums[3].id },
{ headers: asBearerAuth(user2.accessToken) },
);
const unchangedForOwner = await getAlbumInfo(
{ id: user1Albums[3].id },
{ headers: asBearerAuth(user1.accessToken) },
);
expect(favoritedForViewer.isFavorite).toBe(true);
expect(unchangedForOwner.isFavorite).toBe(false);
const unfavoriteResponse = await request(app)
.patch(`/albums/${user1Albums[3].id}/user-metadata`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ isFavorite: false });
expect(unfavoriteResponse.status).toBe(200);
expect(unfavoriteResponse.body).toMatchObject({ id: user1Albums[3].id, isFavorite: false });
const after = await getAlbumInfo({ id: user1Albums[3].id }, { headers: asBearerAuth(user2.accessToken) });
expect(after.isFavorite).toBe(false);
});
});
describe('DELETE /albums/:id/assets', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
@@ -427,6 +427,7 @@ export function getAlbum(
albumUsers: [], // Empty array for non-shared album
shared: false,
hasSharedLink: false,
isFavorite: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
assets: albumAssets,
+4
View File
@@ -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)
+3
View File
@@ -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';
+61
View File
@@ -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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['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<AlbumResponseDto?> 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;
}
}
+6
View File
@@ -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':
+10 -1
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -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<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
isFavorite: mapValueOfType<bool>(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',
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncAlbumUserMetadataDeleteV1(
albumId: mapValueOfType<String>(json, r'albumId')!,
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<SyncAlbumUserMetadataDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAlbumUserMetadataDeleteV1>[];
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<String, SyncAlbumUserMetadataDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAlbumUserMetadataDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncAlbumUserMetadataDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAlbumUserMetadataDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'albumId',
'userId',
};
}
+118
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncAlbumUserMetadataV1(
albumId: mapValueOfType<String>(json, r'albumId')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<SyncAlbumUserMetadataV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAlbumUserMetadataV1>[];
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<String, SyncAlbumUserMetadataV1> mapFromJson(dynamic json) {
final map = <String, SyncAlbumUserMetadataV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncAlbumUserMetadataV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAlbumUserMetadataV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'albumId',
'isFavorite',
'userId',
};
}
+6
View File
@@ -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;
+3
View File
@@ -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 = <SyncRequestType>[
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;
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return UpdateAlbumUserMetadataDto(
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
);
}
return null;
}
static List<UpdateAlbumUserMetadataDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateAlbumUserMetadataDto>[];
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<String, UpdateAlbumUserMetadataDto> mapFromJson(dynamic json) {
final map = <String, UpdateAlbumUserMetadataDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<UpdateAlbumUserMetadataDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateAlbumUserMetadataDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'isFavorite',
};
}
+125
View File
@@ -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": {
@@ -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",
@@ -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`);
@@ -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<AlbumResponseDto> {
return this.service.updateAlbumUserMetadata(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.AlbumDelete })
@HttpCode(HttpStatus.NO_CONTENT)
+5
View File
@@ -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'],
@@ -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);
});
});
+9
View File
@@ -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<User>;
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,
};
};
+20
View File
@@ -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;
+3
View File
@@ -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',
+69 -9
View File
@@ -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
@@ -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"
@@ -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
+29
View File
@@ -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",
@@ -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<DB>) {}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID, isFavorite: true }] })
async upsert(dto: Insertable<AlbumUserMetadataTable>) {
await this.db
.insertInto('album_user_metadata')
.values(dto)
.onConflict((oc) =>
oc.columns(['albumId', 'userId']).doUpdateSet((eb) => ({ isFavorite: eb.ref('excluded.isFavorite') })),
)
.execute();
}
}
@@ -17,11 +17,21 @@ export class AlbumUserRepository {
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
create(albumUser: Insertable<AlbumUserTable>) {
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<void> {
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();
});
}
}
+29 -2
View File
@@ -31,6 +31,18 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
const withFavorite = (eb: ExpressionBuilder<DB, 'album'>, userId?: string) => {
if (!userId) {
return sql<boolean>`false`.as('isFavorite');
}
return sql<boolean>`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<DB, 'album'>) => {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
.$notNull()
@@ -84,14 +96,15 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@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)
+2
View File
@@ -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,
@@ -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) {
+13
View File
@@ -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',
+8
View File
@@ -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;
@@ -0,0 +1,66 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -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<string>;
@Column({ type: 'uuid', index: true })
albumId!: string;
@Column({ type: 'uuid', index: true })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}
@@ -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<boolean>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@UpdateDateColumn({ index: true })
updatedAt!: Generated<Timestamp>;
}
+146 -5
View File
@@ -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 });
+15 -6
View File
@@ -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<AlbumResponseDto> {
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<void> {
@@ -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<void> {
@@ -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');
}
+3
View File
@@ -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,
+17
View File
@@ -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,
+1
View File
@@ -76,6 +76,7 @@ export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
...album,
isFavorite: false,
assets: album.assets.map((asset) =>
getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }),
),
+3
View File
@@ -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 = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
switch (key) {
case AccessRepository:
case AlbumRepository:
case AlbumUserMetadataRepository:
case AlbumUserRepository:
case ActivityRepository:
case AssetRepository:
@@ -465,6 +467,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
switch (key) {
case ActivityRepository:
case AlbumRepository:
case AlbumUserMetadataRepository:
case AssetRepository:
case AssetJobRepository:
case ConfigRepository:
@@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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,
});
});
});
@@ -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<DB>;
const setup = async (db?: Kysely<DB>) => {
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 }),
]);
});
});
+4
View File
@@ -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 = <T extends BaseService>(
overrides.access || (mocks.access as IAccessRepository as AccessRepository),
overrides.activity || (mocks.activity as As<ActivityRepository>),
overrides.album || (mocks.album as As<AlbumRepository>),
overrides.albumUserMetadata || (mocks.albumUserMetadata as As<AlbumUserMetadataRepository>),
overrides.albumUser || (mocks.albumUser as As<AlbumUserRepository>),
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
overrides.app || (mocks.app as As<AppRepository>),
@@ -18,5 +18,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumUsers: [],
hasSharedLink: false,
isActivityEnabled: true,
isFavorite: false,
order: AssetOrder.Desc,
});