mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 05:22:15 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17b3676038 | |||
| 6876eb2f05 |
@@ -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,
|
||||
|
||||
Generated
+4
@@ -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)
|
||||
|
||||
Generated
+3
@@ -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';
|
||||
|
||||
Generated
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+6
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }),
|
||||
),
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user