1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-24 10:37:28 +02:00

refactor(server)!: add/remove album assets (#3109)

* refactor: add/remove album assets

* chore: open api

* feat: remove owned assets from album

* refactor: move to bulk id req/res dto

* chore: open api

* chore: merge main

* dev: mobile work

* fix: adding asset from web not sync with mobile

* remove print statement

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
This commit is contained in:
Jason Rasmussen 2023-08-01 21:29:14 -04:00 committed by GitHub
parent ba71c83948
commit b9cda59172
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 819 additions and 888 deletions

View File

@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
*/
'name': string;
}
/**
*
* @export
* @interface AddAssetsDto
*/
export interface AddAssetsDto {
/**
*
* @type {Array<string>}
* @memberof AddAssetsDto
*/
'assetIds': Array<string>;
}
/**
*
* @export
* @interface AddAssetsResponseDto
*/
export interface AddAssetsResponseDto {
/**
*
* @type {AlbumResponseDto}
* @memberof AddAssetsResponseDto
*/
'album'?: AlbumResponseDto;
/**
*
* @type {Array<string>}
* @memberof AddAssetsResponseDto
*/
'alreadyInAlbum': Array<string>;
/**
*
* @type {number}
* @memberof AddAssetsResponseDto
*/
'successfullyAdded': number;
}
/**
*
* @export
@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = {
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
/**
*
* @export
* @interface BulkIdsDto
*/
export interface BulkIdsDto {
/**
*
* @type {Array<string>}
* @memberof BulkIdsDto
*/
'ids': Array<string>;
}
/**
*
* @export
@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
*/
'isPaused': boolean;
}
/**
*
* @export
* @interface RemoveAssetsDto
*/
export interface RemoveAssetsDto {
/**
*
* @type {Array<string>}
* @memberof RemoveAssetsDto
*/
'assetIds': Array<string>;
}
/**
*
* @export
@ -3678,16 +3640,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/**
*
* @param {string} id
* @param {AddAssetsDto} addAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('addAssetsToAlbum', 'id', id)
// verify required parameter 'addAssetsDto' is not null or undefined
assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto)
// verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
@ -3721,7 +3683,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -3998,15 +3960,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/**
*
* @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'id', id)
// verify required parameter 'removeAssetsDto' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto)
// verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
@ -4036,7 +3998,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -4150,13 +4112,13 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/**
*
* @param {string} id
* @param {AddAssetsDto} addAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options);
async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -4224,12 +4186,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/**
*
* @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options);
async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -4270,8 +4232,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<AddAssetsResponseDto> {
return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(axios, basePath));
addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(axios, basePath));
},
/**
*
@ -4332,8 +4294,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<AlbumResponseDto> {
return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(axios, basePath));
removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath));
},
/**
*
@ -4371,10 +4333,10 @@ export interface AlbumApiAddAssetsToAlbumRequest {
/**
*
* @type {AddAssetsDto}
* @type {BulkIdsDto}
* @memberof AlbumApiAddAssetsToAlbum
*/
readonly addAssetsDto: AddAssetsDto
readonly bulkIdsDto: BulkIdsDto
/**
*
@ -4490,10 +4452,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest {
/**
*
* @type {RemoveAssetsDto}
* @type {BulkIdsDto}
* @memberof AlbumApiRemoveAssetFromAlbum
*/
readonly removeAssetsDto: RemoveAssetsDto
readonly bulkIdsDto: BulkIdsDto
}
/**
@ -4553,7 +4515,7 @@ export class AlbumApi extends BaseAPI {
* @memberof AlbumApi
*/
public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -4629,7 +4591,7 @@ export class AlbumApi extends BaseAPI {
* @memberof AlbumApi
*/
public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath));
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -0,0 +1,49 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:collection/collection.dart';
class AddAssetsResponse {
List<String> alreadyInAlbum;
int successfullyAdded;
AddAssetsResponse({
required this.alreadyInAlbum,
required this.successfullyAdded,
});
AddAssetsResponse copyWith({
List<String>? alreadyInAlbum,
int? successfullyAdded,
}) {
return AddAssetsResponse(
alreadyInAlbum: alreadyInAlbum ?? this.alreadyInAlbum,
successfullyAdded: successfullyAdded ?? this.successfullyAdded,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'alreadyInAlbum': alreadyInAlbum,
'successfullyAdded': successfullyAdded,
};
}
String toJson() => json.encode(toMap());
@override
String toString() =>
'AddAssetsResponse(alreadyInAlbum: $alreadyInAlbum, successfullyAdded: $successfullyAdded)';
@override
bool operator ==(covariant AddAssetsResponse other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return listEquals(other.alreadyInAlbum, alreadyInAlbum) &&
other.successfullyAdded == successfullyAdded;
}
@override
int get hashCode => alreadyInAlbum.hashCode ^ successfullyAdded.hashCode;
}

View File

@ -0,0 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
final albumDetailProvider =
StreamProvider.family<Album, int>((ref, albumId) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final AlbumService service = ref.watch(albumServiceProvider);
await for (final a in service.watchAlbum(albumId)) {
if (a == null) {
throw Exception("Album with ID=$albumId does not exist anymore!");
}
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
yield a;
}
}
});

View File

@ -3,12 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
@ -72,19 +70,3 @@ final sharedAlbumProvider =
ref.watch(dbProvider),
);
});
final sharedAlbumDetailProvider =
StreamProvider.family<Album, int>((ref, albumId) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
await for (final a in sharedAlbumService.watchAlbum(albumId)) {
if (a == null) {
throw Exception("Album with ID=$albumId does not exist anymore!");
}
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
yield a;
}
}
});

View File

@ -5,6 +5,7 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
@ -219,24 +220,43 @@ class AlbumService {
yield* _db.albums.watchObject(albumId);
}
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets,
Album album,
) async {
try {
var result = await _apiService.albumApi.addAssetsToAlbum(
var response = await _apiService.albumApi.addAssetsToAlbum(
album.remoteId!,
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
);
if (result != null && result.successfullyAdded > 0) {
album.assets.addAll(assets);
if (response != null) {
List<Asset> successAssets = [];
List<String> duplicatedAssets = [];
for (final result in response) {
if (result.success) {
successAssets
.add(assets.firstWhere((asset) => asset.remoteId == result.id));
} else if (!result.success &&
result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicatedAssets.add(result.id);
}
}
album.assets.addAll(successAssets);
await _db.writeTxn(() => album.assets.save());
return AddAssetsResponse(
alreadyInAlbum: duplicatedAssets,
successfullyAdded: successAssets.length,
);
}
return result;
} catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
return null;
}
return null;
}
Future<bool> addAdditionalUserToAlbum(
@ -314,8 +334,8 @@ class AlbumService {
try {
await _apiService.albumApi.removeAssetFromAlbum(
album.remoteId!,
RemoveAssetsDto(
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
BulkIdsDto(
ids: assets.map((asset) => asset.remoteId!).toList(),
),
);
album.assets.removeAll(assets);

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
@ -63,9 +64,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
}
}
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.invalidate(albumDetailProvider(album.id));
Navigator.pop(context);
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@ -99,7 +100,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
Navigator.pop(context);
selectionDisabled();
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(sharedAlbumDetailProvider(album.id));
ref.invalidate(albumDetailProvider(album.id));
} else {
Navigator.pop(context);
ImmichToast.show(

View File

@ -6,13 +6,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
@ -28,11 +27,20 @@ class AlbumViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(sharedAlbumDetailProvider(albumId));
final album = ref.watch(albumDetailProvider(albumId));
final userId = ref.watch(authenticationProvider).userId;
final selection = useState<Set<Asset>>({});
final multiSelectEnabled = useState(false);
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.invalidate(albumDetailProvider(albumId));
return null;
},
[],
);
Future<bool> onWillPop() async {
if (multiSelectEnabled.value) {
selection.value = {};
@ -77,8 +85,7 @@ class AlbumViewerPage extends HookConsumerWidget {
if (addAssetsResult != null &&
addAssetsResult.successfullyAdded > 0) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(sharedAlbumDetailProvider(albumId));
ref.invalidate(albumDetailProvider(albumId));
}
ImmichLoadingOverlayController.appLoader.hide();
@ -100,7 +107,7 @@ class AlbumViewerPage extends HookConsumerWidget {
.addAdditionalUserToAlbum(sharedUserIds, album);
if (isSuccess) {
ref.invalidate(sharedAlbumDetailProvider(album.id));
ref.invalidate(albumDetailProvider(album.id));
}
ImmichLoadingOverlayController.appLoader.hide();

View File

@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
@ -208,6 +209,9 @@ class HomePage extends HookConsumerWidget {
),
toastType: ToastType.success,
);
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(albumDetailProvider(album.id));
}
}
} finally {

View File

@ -8,8 +8,6 @@ doc/APIKeyCreateDto.md
doc/APIKeyCreateResponseDto.md
doc/APIKeyResponseDto.md
doc/APIKeyUpdateDto.md
doc/AddAssetsDto.md
doc/AddAssetsResponseDto.md
doc/AddUsersDto.md
doc/AdminSignupResponseDto.md
doc/AlbumApi.md
@ -33,6 +31,7 @@ doc/AudioCodec.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/BulkIdResponseDto.md
doc/BulkIdsDto.md
doc/ChangePasswordDto.md
doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md
@ -78,7 +77,6 @@ doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonUpdateDto.md
doc/QueueStatusDto.md
doc/RemoveAssetsDto.md
doc/SearchAlbumResponseDto.md
doc/SearchApi.md
doc/SearchAssetDto.md
@ -150,8 +148,6 @@ lib/auth/authentication.dart
lib/auth/http_basic_auth.dart
lib/auth/http_bearer_auth.dart
lib/auth/oauth.dart
lib/model/add_assets_dto.dart
lib/model/add_assets_response_dto.dart
lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart
@ -176,6 +172,7 @@ lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart
lib/model/bulk_ids_dto.dart
lib/model/change_password_dto.dart
lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart
@ -217,7 +214,6 @@ lib/model/people_update_item.dart
lib/model/person_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
lib/model/remove_assets_dto.dart
lib/model/search_album_response_dto.dart
lib/model/search_asset_dto.dart
lib/model/search_asset_response_dto.dart
@ -260,8 +256,6 @@ lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
lib/model/video_codec.dart
pubspec.yaml
test/add_assets_dto_test.dart
test/add_assets_response_dto_test.dart
test/add_users_dto_test.dart
test/admin_signup_response_dto_test.dart
test/album_api_test.dart
@ -290,6 +284,7 @@ test/audio_codec_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart
test/bulk_ids_dto_test.dart
test/change_password_dto_test.dart
test/check_duplicate_asset_dto_test.dart
test/check_duplicate_asset_response_dto_test.dart
@ -335,7 +330,6 @@ test/person_api_test.dart
test/person_response_dto_test.dart
test/person_update_dto_test.dart
test/queue_status_dto_test.dart
test/remove_assets_dto_test.dart
test/search_album_response_dto_test.dart
test/search_api_test.dart
test/search_asset_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -278,7 +278,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RemoveAssetsDto"
"$ref": "#/components/schemas/BulkIdsDto"
}
}
},
@ -289,7 +289,10 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumResponseDto"
"items": {
"$ref": "#/components/schemas/BulkIdResponseDto"
},
"type": "array"
}
}
},
@ -336,7 +339,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddAssetsDto"
"$ref": "#/components/schemas/BulkIdsDto"
}
}
},
@ -347,7 +350,10 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddAssetsResponseDto"
"items": {
"$ref": "#/components/schemas/BulkIdResponseDto"
},
"type": "array"
}
}
},
@ -4535,42 +4541,6 @@
],
"type": "object"
},
"AddAssetsDto": {
"properties": {
"assetIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"assetIds"
],
"type": "object"
},
"AddAssetsResponseDto": {
"properties": {
"album": {
"$ref": "#/components/schemas/AlbumResponseDto"
},
"alreadyInAlbum": {
"items": {
"type": "string"
},
"type": "array"
},
"successfullyAdded": {
"type": "integer"
}
},
"required": [
"successfullyAdded",
"alreadyInAlbum"
],
"type": "object"
},
"AddUsersDto": {
"properties": {
"sharedUserIds": {
@ -5093,6 +5063,21 @@
],
"type": "object"
},
"BulkIdsDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"ids"
],
"type": "object"
},
"ChangePasswordDto": {
"properties": {
"newPassword": {
@ -6055,21 +6040,6 @@
],
"type": "object"
},
"RemoveAssetsDto": {
"properties": {
"assetIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"assetIds"
],
"type": "object"
},
"SearchAlbumResponseDto": {
"properties": {
"count": {

View File

@ -12,9 +12,10 @@ export enum Permission {
ASSET_DOWNLOAD = 'asset.download',
// ALBUM_CREATE = 'album.create',
// ALBUM_READ = 'album.read',
ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
ALBUM_REMOVE_ASSET = 'album.removeAsset',
ALBUM_SHARE = 'album.share',
ALBUM_DOWNLOAD = 'album.download',
@ -39,6 +40,16 @@ export class AccessCore {
}
}
async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) {
for (const { permission, id } of permissions) {
const hasAccess = await this.hasPermission(authUser, permission, id);
if (hasAccess) {
return true;
}
}
return false;
}
async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
ids = Array.isArray(ids) ? ids : [ids];
@ -76,12 +87,11 @@ export class AccessCore {
// TODO: fix this to not use authUser.id for shared link access control
return this.repository.asset.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_DOWNLOAD: {
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
}
case Permission.ALBUM_READ:
return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
// case Permission.ALBUM_READ:
// return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
case Permission.ALBUM_DOWNLOAD:
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
default:
return false;
@ -122,8 +132,11 @@ export class AccessCore {
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
);
// case Permission.ALBUM_READ:
// return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_READ:
return (
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
);
case Permission.ALBUM_UPDATE:
return this.repository.album.hasOwnerAccess(authUser.id, id);
@ -140,13 +153,17 @@ export class AccessCore {
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
);
case Permission.ALBUM_REMOVE_ASSET:
return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.LIBRARY_READ:
return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
case Permission.LIBRARY_DOWNLOAD:
return authUser.id === id;
}
return false;
default:
return false;
}
}
}

View File

@ -8,6 +8,7 @@ export interface AlbumAssetCount {
}
export interface IAlbumRepository {
getById(id: string): Promise<AlbumEntity | null>;
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
hasAsset(id: string, assetId: string): Promise<boolean>;
@ -21,4 +22,5 @@ export interface IAlbumRepository {
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
delete(album: AlbumEntity): Promise<void>;
updateThumbnails(): Promise<number | undefined>;
}

View File

@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import {
albumStub,
assetStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
@ -11,7 +12,7 @@ import {
userStub,
} from '@test';
import _ from 'lodash';
import { IAssetRepository } from '../asset';
import { BulkIdErrorReason, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { IAlbumRepository } from './album.repository';
@ -202,7 +203,7 @@ describe(AlbumService.name, () => {
describe('update', () => {
it('should prevent updating an album that does not exist', async () => {
albumMock.getByIds.mockResolvedValue([]);
albumMock.getById.mockResolvedValue(null);
await expect(
sut.update(authStub.user1, 'invalid-id', {
@ -224,7 +225,7 @@ describe(AlbumService.name, () => {
it('should require a valid thumbnail asset id', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
albumMock.update.mockResolvedValue(albumStub.oneAsset);
albumMock.hasAsset.mockResolvedValue(false);
@ -241,7 +242,7 @@ describe(AlbumService.name, () => {
it('should allow the owner to update the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
albumMock.update.mockResolvedValue(albumStub.oneAsset);
await sut.update(authStub.admin, albumStub.oneAsset.id, {
@ -263,7 +264,7 @@ describe(AlbumService.name, () => {
describe('delete', () => {
it('should throw an error for an album not found', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([]);
albumMock.getById.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException,
@ -274,7 +275,7 @@ describe(AlbumService.name, () => {
it('should not let a shared user delete the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException,
@ -285,7 +286,7 @@ describe(AlbumService.name, () => {
it('should let the owner delete an album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
albumMock.getById.mockResolvedValue(albumStub.empty);
await sut.delete(authStub.admin, albumStub.empty.id);
@ -305,7 +306,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId is already added', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
).rejects.toBeInstanceOf(BadRequestException);
@ -314,7 +315,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(null);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
@ -324,7 +325,7 @@ describe(AlbumService.name, () => {
it('should add valid shared users', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(userStub.user2);
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
@ -339,14 +340,14 @@ describe(AlbumService.name, () => {
describe('removeUser', () => {
it('should require a valid album id', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([]);
albumMock.getById.mockResolvedValue(null);
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should remove a shared user from an owned album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await expect(
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
@ -362,7 +363,7 @@ describe(AlbumService.name, () => {
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
await expect(
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
@ -373,7 +374,7 @@ describe(AlbumService.name, () => {
});
it('should allow a shared user to remove themselves', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
@ -386,7 +387,7 @@ describe(AlbumService.name, () => {
});
it('should allow a shared user to remove themselves using "me"', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
@ -399,7 +400,7 @@ describe(AlbumService.name, () => {
});
it('should not allow the owner to be removed', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
albumMock.getById.mockResolvedValue(albumStub.empty);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
BadRequestException,
@ -409,7 +410,7 @@ describe(AlbumService.name, () => {
});
it('should throw an error for a user not in the album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
albumMock.getById.mockResolvedValue(albumStub.empty);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException,
@ -418,4 +419,301 @@ describe(AlbumService.name, () => {
expect(albumMock.update).not.toHaveBeenCalled();
});
});
describe('getAlbumInfo', () => {
it('should get a shared album', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
await sut.get(authStub.admin, albumStub.oneAsset.id);
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
});
it('should get a shared album via a shared link', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
await sut.get(authStub.adminSharedLink, 'album-123');
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
'album-123',
);
});
it('should get a shared album via shared with user', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
await sut.get(authStub.user1, 'album-123');
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123');
});
it('should throw an error for no access', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
});
});
describe('addAssets', () => {
it('should allow the owner to add assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).resolves.toEqual([
{ success: true, id: 'asset-1' },
{ success: true, id: 'asset-2' },
{ success: true, id: 'asset-3' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
});
it('should not set the thumbnail if the album has one already', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-id',
});
});
it('should allow a shared user to add assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
await expect(
sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).resolves.toEqual([
{ success: true, id: 'asset-1' },
{ success: true, id: 'asset-2' },
{ success: true, id: 'asset-3' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
});
it('should allow a shared link user to add assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).resolves.toEqual([
{ success: true, id: 'asset-1' },
{ success: true, id: 'asset-2' },
{ success: true, id: 'asset-3' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
'album-123',
);
});
it('should allow adding assets shared via partner sharing', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.image, { id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1',
});
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
});
it('should skip duplicate assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
]);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should skip assets not shared with user', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
});
it('should not allow unauthorized access to the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled();
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled();
});
it('should not allow unauthorized shared link access to the album', async () => {
accessMock.album.hasSharedLinkAccess.mockResolvedValue(false);
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled();
});
});
describe('removeAssets', () => {
it('should allow the owner to remove assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [],
albumThumbnailAssetId: null,
});
});
it('should skip assets not in the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
]);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should skip assets without user permission to remove', async () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should reset the thumbnail if it is removed', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
assets: [assetStub.withLocation],
albumThumbnailAssetId: assetStub.withLocation.id,
});
});
});
// // it('removes assets from shared album (shared with auth user)', async () => {
// // const albumEntity = _getOwnedSharedAlbum();
// // albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// // await expect(
// // sut.removeAssetsFromAlbum(
// // authUser,
// // {
// // ids: ['1'],
// // },
// // albumEntity.id,
// // ),
// // ).resolves.toBeUndefined();
// // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// // ids: ['1'],
// // });
// // });
// it('prevents removing assets from a not owned / shared album', async () => {
// const albumEntity = _getNotOwnedNotSharedAlbum();
// const albumResponse: AddAssetsResponseDto = {
// alreadyInAlbum: [],
// successfullyAdded: 1,
// };
// const albumId = albumEntity.id;
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
// await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
// });
});

View File

@ -1,8 +1,8 @@
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { IAssetRepository, mapAsset } from '../asset';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { AccessCore, IAccessRepository, Permission } from '../index';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto';
@ -37,7 +37,11 @@ export class AlbumService {
}
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.updateInvalidThumbnails();
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
for (const albumId of invalidAlbumIds) {
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
}
let albums: AlbumEntity[];
if (assetId) {
@ -73,15 +77,10 @@ export class AlbumService {
);
}
private async updateInvalidThumbnails(): Promise<number> {
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
for (const albumId of invalidAlbumIds) {
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
}
return invalidAlbumIds.length;
async get(authUser: AuthUserDto, id: string) {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails();
return mapAlbum(await this.findOrFail(id));
}
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
@ -107,7 +106,7 @@ export class AlbumService {
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
const album = await this.get(id);
const album = await this.findOrFail(id);
if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
@ -130,7 +129,7 @@ export class AlbumService {
async delete(authUser: AuthUserDto, id: string): Promise<void> {
await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
const [album] = await this.albumRepository.getByIds([id]);
const album = await this.albumRepository.getById(id);
if (!album) {
throw new BadRequestException('Album not found');
}
@ -139,10 +138,88 @@ export class AlbumService {
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
}
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id);
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id);
if (hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id);
if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id, success: true });
album.assets.push({ id } as AssetEntity);
}
const newAsset = results.find(({ success }) => success);
if (newAsset) {
await this.albumRepository.update({
id,
assets: album.assets,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id,
});
}
return results;
}
async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id);
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
for (const id of dto.ids) {
const hasAsset = album.assets.find((asset) => asset.id === id);
if (!hasAsset) {
results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = await this.access.hasAny(authUser, [
{ permission: Permission.ALBUM_REMOVE_ASSET, id },
{ permission: Permission.ASSET_SHARE, id },
]);
if (!hasAccess) {
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
results.push({ id, success: true });
album.assets = album.assets.filter((asset) => asset.id !== id);
if (album.albumThumbnailAssetId === id) {
album.albumThumbnailAssetId = null;
}
}
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.albumRepository.update({
id,
assets: album.assets,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null,
});
}
return results;
}
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
const album = await this.get(id);
const album = await this.findOrFail(id);
for (const userId of dto.sharedUserIds) {
const exists = album.sharedUsers.find((user) => user.id === userId);
@ -172,7 +249,7 @@ export class AlbumService {
userId = authUser.id;
}
const album = await this.get(id);
const album = await this.findOrFail(id);
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
@ -195,8 +272,8 @@ export class AlbumService {
});
}
private async get(id: string) {
const [album] = await this.albumRepository.getByIds([id]);
private async findOrFail(id: string) {
const album = await this.albumRepository.getById(id);
if (!album) {
throw new BadRequestException('Album not found');
}

View File

@ -1,3 +1,5 @@
import { ValidateUUID } from '../../domain.util';
/** @deprecated Use `BulkIdResponseDto` instead */
export enum AssetIdErrorReason {
DUPLICATE = 'duplicate',
@ -19,6 +21,11 @@ export enum BulkIdErrorReason {
UNKNOWN = 'unknown',
}
export class BulkIdsDto {
@ValidateUUID({ each: true })
ids!: string[];
}
export class BulkIdResponseDto {
id!: string;
success!: boolean;

View File

@ -1,132 +0,0 @@
import { dataSource } from '@app/infra/database.config';
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository {
get(albumId: string): Promise<AlbumEntity | null>;
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateThumbnails(): Promise<number | undefined>;
}
export const IAlbumRepository = 'IAlbumRepository';
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
) {}
async get(albumId: string): Promise<AlbumEntity | null> {
return this.albumRepository.findOne({
where: { id: albumId },
relations: {
owner: true,
sharedUsers: true,
assets: {
exifInfo: true,
},
sharedLinks: true,
},
order: {
assets: {
fileCreatedAt: 'DESC',
},
},
});
}
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> {
const assetCount = album.assets.length;
album.assets = album.assets.filter((asset) => {
return !removeAssetsDto.assetIds.includes(asset.id);
});
const numRemovedAssets = assetCount - album.assets.length;
if (numRemovedAssets > 0) {
album.updatedAt = new Date();
}
await this.albumRepository.save(album, {});
return numRemovedAssets;
}
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto> {
const alreadyExisting: string[] = [];
for (const assetId of addAssetsDto.assetIds) {
// Album already contains that asset
if (album.assets?.some((a) => a.id === assetId)) {
alreadyExisting.push(assetId);
continue;
}
album.assets.push({ id: assetId } as AssetEntity);
}
// Add album thumbnail if not exist.
if (!album.albumThumbnailAssetId && album.assets.length > 0) {
album.albumThumbnailAssetId = album.assets[0].id;
}
const successfullyAdded = addAssetsDto.assetIds.length - alreadyExisting.length;
if (successfullyAdded > 0) {
album.updatedAt = new Date();
}
await this.albumRepository.save(album);
return {
successfullyAdded,
alreadyInAlbum: alreadyExisting,
};
}
/**
* Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets
* - Removing references of thumbnails to assets outside the album
* - Setting a thumbnail when none is set and the album contains assets
*
* @returns Amount of updated album thumbnails or undefined when unknown
*/
async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail.
const newThumbnail = this.assetRepository
.createQueryBuilder('assets')
.select('albums_assets2.assetsId')
.addFrom('albums_assets_assets', 'albums_assets2')
.where('albums_assets2.assetsId = assets.id')
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
.orderBy('assets.fileCreatedAt', 'DESC')
.limit(1);
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const albumContainsThumbnail = albumHasAssets
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
const updateAlbums = this.albumRepository
.createQueryBuilder('albums')
.update(AlbumEntity)
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
const result = await updateAlbums.execute();
return result.affected;
}
}

View File

@ -1,45 +0,0 @@
import { AlbumResponseDto, AuthUserDto } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard';
import { UseValidation } from '../../app.utils';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AlbumService } from './album.service';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ApiTags('Album')
@Controller('album')
@Authenticated()
@UseValidation()
export class AlbumController {
constructor(private service: AlbumService) {}
@SharedLinkRoute()
@Put(':id/assets')
addAssetsToAlbum(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AddAssetsDto,
): Promise<AddAssetsResponseDto> {
// TODO: Handle nonexistent assetIds.
// TODO: Disallow adding assets of another user to an album.
return this.service.addAssets(authUser, id, dto);
}
@SharedLinkRoute()
@Get(':id')
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.get(authUser, id);
}
@Delete(':id/assets')
removeAssetFromAlbum(
@AuthUser() authUser: AuthUserDto,
@Body() dto: RemoveAssetsDto,
@Param() { id }: UUIDParamDto,
): Promise<AlbumResponseDto> {
return this.service.removeAssets(authUser, id, dto);
}
}

View File

@ -1,13 +0,0 @@
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { AlbumController } from './album.controller';
import { AlbumService } from './album.service';
@Module({
imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])],
controllers: [AlbumController],
providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }],
})
export class AlbumModule {}

View File

@ -1,258 +0,0 @@
import { AlbumResponseDto, AuthUserDto, mapUser } from '@app/domain';
import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { userStub } from '@test';
import { IAlbumRepository } from './album-repository';
import { AlbumService } from './album.service';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
email: 'auth@test.com',
isAdmin: false,
});
const albumOwner: UserEntity = Object.freeze({
...authUser,
firstName: 'auth',
lastName: 'user',
createdAt: new Date('2022-06-19T23:41:36.910Z'),
deletedAt: null,
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
profileImagePath: '',
shouldChangePassword: false,
oauthId: '',
tags: [],
assets: [],
storageLabel: null,
externalPath: null,
});
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';
const sharedAlbumSharedAlsoWithId = '3333';
const _getOwnedAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = albumOwner.id;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.updatedAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.sharedUsers = [];
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedLinks = [];
return albumEntity;
};
const _getSharedWithAuthUserAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = sharedAlbumOwnerId;
albumEntity.owner = albumOwner;
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedUsers = [
{
...userStub.user1,
id: authUser.id,
},
{
...userStub.user1,
id: sharedAlbumSharedAlsoWithId,
},
];
albumEntity.sharedLinks = [];
return albumEntity;
};
const _getNotOwnedNotSharedAlbum = () => {
const albumEntity = new AlbumEntity();
albumEntity.ownerId = '5555';
albumEntity.id = albumId;
albumEntity.albumName = 'name';
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
albumEntity.sharedUsers = [];
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
return albumEntity;
};
beforeAll(() => {
albumRepositoryMock = {
addAssets: jest.fn(),
get: jest.fn(),
removeAssets: jest.fn(),
updateThumbnails: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock);
});
it('gets an owned album', async () => {
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const albumEntity = _getOwnedAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
const expectedResult: AlbumResponseDto = {
ownerId: albumOwner.id,
owner: mapUser(albumOwner),
id: albumId,
albumName: 'name',
createdAt: new Date('2022-06-19T23:41:36.910Z'),
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
sharedUsers: [],
assets: [],
albumThumbnailAssetId: null,
shared: false,
assetCount: 0,
};
await expect(sut.get(authUser, albumId)).resolves.toEqual(expectedResult);
});
it('gets a shared album', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
const result = await sut.get(authUser, albumId);
expect(result.id).toEqual(albumId);
expect(result.ownerId).toEqual(sharedAlbumOwnerId);
expect(result.shared).toEqual(true);
expect(result.sharedUsers).toHaveLength(2);
expect(result.sharedUsers[0].id).toEqual(authUser.id);
expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
});
it('prevents retrieving an album that is not owned or shared', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
await expect(sut.get(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws a not found exception if the album is not found', async () => {
albumRepositoryMock.get.mockImplementation(() => Promise.resolve(null));
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
});
it('adds assets to owned album', async () => {
const albumEntity = _getOwnedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.album?.id).toEqual(albumId);
});
it('adds assets to shared album (shared with auth user)', async () => {
const albumEntity = _getSharedWithAuthUserAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.album?.id).toEqual(albumId);
});
it('prevents adding assets to a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
await expect(sut.addAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
});
// it('removes assets from owned album', async () => {
// const albumEntity = _getOwnedAlbum();
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// await expect(
// sut.removeAssetsFromAlbum(
// authUser,
// {
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
// },
// albumEntity.id,
// ),
// ).resolves.toBeUndefined();
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
// });
// });
// it('removes assets from shared album (shared with auth user)', async () => {
// const albumEntity = _getOwnedSharedAlbum();
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
// await expect(
// sut.removeAssetsFromAlbum(
// authUser,
// {
// assetIds: ['1'],
// },
// albumEntity.id,
// ),
// ).resolves.toBeUndefined();
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
// assetIds: ['1'],
// });
// });
it('prevents removing assets from a not owned / shared album', async () => {
const albumEntity = _getNotOwnedNotSharedAlbum();
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1,
};
const albumId = albumEntity.id;
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
await expect(sut.removeAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@ -1,72 +0,0 @@
import { AlbumResponseDto, AuthUserDto, mapAlbum } from '@app/domain';
import { AlbumEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { IAlbumRepository } from './album-repository';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@Injectable()
export class AlbumService {
private logger = new Logger(AlbumService.name);
constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {}
private async _getAlbum({
authUser,
albumId,
validateIsOwner = true,
}: {
authUser: AuthUserDto;
albumId: string;
validateIsOwner?: boolean;
}): Promise<AlbumEntity> {
await this.repository.updateThumbnails();
const album = await this.repository.get(albumId);
if (!album) {
throw new NotFoundException('Album Not Found');
}
const isOwner = album.ownerId == authUser.id;
if (validateIsOwner && !isOwner) {
throw new ForbiddenException('Unauthorized Album Access');
} else if (!isOwner && !album.sharedUsers?.some((user) => user.id == authUser.id)) {
throw new ForbiddenException('Unauthorized Album Access');
}
return album;
}
async get(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return mapAlbum(album);
}
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const deletedCount = await this.repository.removeAssets(album, dto);
const newAlbum = await this._getAlbum({ authUser, albumId });
if (deletedCount !== dto.assetIds.length) {
throw new BadRequestException('Some assets were not found in the album');
}
return mapAlbum(newAlbum);
}
async addAssets(authUser: AuthUserDto, albumId: string, dto: AddAssetsDto): Promise<AddAssetsResponseDto> {
if (authUser.isPublicUser && !authUser.isAllowUpload) {
this.logger.warn('Deny public user attempt to add asset to album');
throw new ForbiddenException('Public user is not allowed to upload');
}
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const result = await this.repository.addAssets(album, dto);
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return {
...result,
album: mapAlbum(newAlbum),
};
}
}

View File

@ -1,6 +0,0 @@
import { ValidateUUID } from '@app/domain';
export class AddAssetsDto {
@ValidateUUID({ each: true })
assetIds!: string[];
}

View File

@ -1,6 +0,0 @@
import { ValidateUUID } from '@app/domain';
export class AddUsersDto {
@ValidateUUID({ each: true })
sharedUserIds!: string[];
}

View File

@ -1,6 +0,0 @@
import { ValidateUUID } from '@app/domain';
export class RemoveAssetsDto {
@ValidateUUID({ each: true })
assetIds!: string[];
}

View File

@ -1,13 +0,0 @@
import { AlbumResponseDto } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
export class AddAssetsResponseDto {
@ApiProperty({ type: 'integer' })
successfullyAdded!: number;
@ApiProperty()
alreadyInAlbum!: string[];
@ApiProperty()
album?: AlbumResponseDto;
}

View File

@ -5,7 +5,6 @@ import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlbumModule } from './api-v1/album/album.module';
import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository';
import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller';
import { AssetService } from './api-v1/asset/asset.service';
@ -34,7 +33,6 @@ import {
imports: [
//
DomainModule.register({ imports: [InfraModule] }),
AlbumModule,
ScheduleModule.forRoot(),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
],

View File

@ -3,6 +3,8 @@ import {
AlbumCountResponseDto,
AlbumService,
AuthUserDto,
BulkIdResponseDto,
BulkIdsDto,
CreateAlbumDto,
UpdateAlbumDto,
} from '@app/domain';
@ -10,7 +12,7 @@ import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ -36,6 +38,12 @@ export class AlbumController {
return this.service.create(authUser, dto);
}
@SharedLinkRoute()
@Get(':id')
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.get(authUser, id);
}
@Patch(':id')
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
return this.service.update(authUser, id, dto);
@ -46,6 +54,25 @@ export class AlbumController {
return this.service.delete(authUser, id);
}
@SharedLinkRoute()
@Put(':id/assets')
addAssetsToAlbum(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: BulkIdsDto,
): Promise<BulkIdResponseDto[]> {
return this.service.addAssets(authUser, id, dto);
}
@Delete(':id/assets')
removeAssetFromAlbum(
@AuthUser() authUser: AuthUserDto,
@Body() dto: BulkIdsDto,
@Param() { id }: UUIDParamDto,
): Promise<BulkIdResponseDto[]> {
return this.service.removeAssets(authUser, id, dto);
}
@Put(':id/users')
addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
return this.service.addUsers(authUser, id, dto);

View File

@ -3,11 +3,35 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { dataSource } from '../database.config';
import { AlbumEntity } from '../entities';
import { AlbumEntity, AssetEntity } from '../entities';
@Injectable()
export class AlbumRepository implements IAlbumRepository {
constructor(@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>) {}
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
) {}
getById(id: string): Promise<AlbumEntity | null> {
return this.repository.findOne({
where: {
id,
},
relations: {
owner: true,
sharedUsers: true,
assets: {
exifInfo: true,
},
sharedLinks: true,
},
order: {
assets: {
fileCreatedAt: 'DESC',
},
},
});
}
getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({
@ -161,4 +185,46 @@ export class AlbumRepository implements IAlbumRepository {
},
});
}
/**
* Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets
* - Removing references of thumbnails to assets outside the album
* - Setting a thumbnail when none is set and the album contains assets
*
* @returns Amount of updated album thumbnails or undefined when unknown
*/
async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail.
const newThumbnail = this.assetRepository
.createQueryBuilder('assets')
.select('albums_assets2.assetsId')
.addFrom('albums_assets_assets', 'albums_assets2')
.where('albums_assets2.assetsId = assets.id')
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
.orderBy('assets.fileCreatedAt', 'DESC')
.limit(1);
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const albumContainsThumbnail = albumHasAssets
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
const updateAlbums = this.repository
.createQueryBuilder('albums')
.update(AlbumEntity)
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
const result = await updateAlbums.execute();
return result.affected;
}
}

View File

@ -69,6 +69,19 @@ export const albumStub = {
sharedLinks: [],
sharedUsers: [],
}),
twoAssets: Object.freeze<AlbumEntity>({
id: 'album-4a',
albumName: 'Album with two assets',
ownerId: authStub.admin.id,
owner: userStub.admin,
assets: [assetStub.image, assetStub.withLocation],
albumThumbnailAsset: assetStub.image,
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
sharedLinks: [],
sharedUsers: [],
}),
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5',
albumName: 'Empty album with invalid thumbnail',

View File

@ -2,6 +2,7 @@ import { IAlbumRepository } from '@app/domain';
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
return {
getById: jest.fn(),
getByIds: jest.fn(),
getByAssetId: jest.fn(),
getAssetCountForIds: jest.fn(),
@ -15,5 +16,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
updateThumbnails: jest.fn(),
};
};

View File

@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
*/
'name': string;
}
/**
*
* @export
* @interface AddAssetsDto
*/
export interface AddAssetsDto {
/**
*
* @type {Array<string>}
* @memberof AddAssetsDto
*/
'assetIds': Array<string>;
}
/**
*
* @export
* @interface AddAssetsResponseDto
*/
export interface AddAssetsResponseDto {
/**
*
* @type {AlbumResponseDto}
* @memberof AddAssetsResponseDto
*/
'album'?: AlbumResponseDto;
/**
*
* @type {Array<string>}
* @memberof AddAssetsResponseDto
*/
'alreadyInAlbum': Array<string>;
/**
*
* @type {number}
* @memberof AddAssetsResponseDto
*/
'successfullyAdded': number;
}
/**
*
* @export
@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = {
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
/**
*
* @export
* @interface BulkIdsDto
*/
export interface BulkIdsDto {
/**
*
* @type {Array<string>}
* @memberof BulkIdsDto
*/
'ids': Array<string>;
}
/**
*
* @export
@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
*/
'isPaused': boolean;
}
/**
*
* @export
* @interface RemoveAssetsDto
*/
export interface RemoveAssetsDto {
/**
*
* @type {Array<string>}
* @memberof RemoveAssetsDto
*/
'assetIds': Array<string>;
}
/**
*
* @export
@ -3679,16 +3641,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/**
*
* @param {string} id
* @param {AddAssetsDto} addAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('addAssetsToAlbum', 'id', id)
// verify required parameter 'addAssetsDto' is not null or undefined
assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto)
// verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
@ -3722,7 +3684,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -3999,15 +3961,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/**
*
* @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'id', id)
// verify required parameter 'removeAssetsDto' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto)
// verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
@ -4037,7 +3999,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -4151,13 +4113,13 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/**
*
* @param {string} id
* @param {AddAssetsDto} addAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options);
async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -4225,12 +4187,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/**
*
* @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options);
async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -4268,13 +4230,13 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
/**
*
* @param {string} id
* @param {AddAssetsDto} addAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise<AddAssetsResponseDto> {
return localVarFp.addAssetsToAlbum(id, addAssetsDto, key, options).then((request) => request(axios, basePath));
addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.addAssetsToAlbum(id, bulkIdsDto, key, options).then((request) => request(axios, basePath));
},
/**
*
@ -4335,12 +4297,12 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
/**
*
* @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto
* @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise<AlbumResponseDto> {
return localVarFp.removeAssetFromAlbum(id, removeAssetsDto, options).then((request) => request(axios, basePath));
removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.removeAssetFromAlbum(id, bulkIdsDto, options).then((request) => request(axios, basePath));
},
/**
*
@ -4380,10 +4342,10 @@ export interface AlbumApiAddAssetsToAlbumRequest {
/**
*
* @type {AddAssetsDto}
* @type {BulkIdsDto}
* @memberof AlbumApiAddAssetsToAlbum
*/
readonly addAssetsDto: AddAssetsDto
readonly bulkIdsDto: BulkIdsDto
/**
*
@ -4499,10 +4461,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest {
/**
*
* @type {RemoveAssetsDto}
* @type {BulkIdsDto}
* @memberof AlbumApiRemoveAssetFromAlbum
*/
readonly removeAssetsDto: RemoveAssetsDto
readonly bulkIdsDto: BulkIdsDto
}
/**
@ -4562,7 +4524,7 @@ export class AlbumApi extends BaseAPI {
* @memberof AlbumApi
*/
public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -4638,7 +4600,7 @@ export class AlbumApi extends BaseAPI {
* @memberof AlbumApi
*/
public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath));
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -92,6 +92,7 @@
let multiSelectAsset: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = multiSelectAsset.size > 0;
$: isMultiSelectionUserOwned = Array.from(multiSelectAsset).every((asset) => asset.ownerId === currentUser?.id);
afterNavigate(({ from }) => {
backUrl = from?.url.pathname ?? '/albums';
@ -182,24 +183,24 @@
const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: AssetResponseDto[] } = event.detail;
try {
const { data } = await api.albumApi.addAssetsToAlbum({
const { data: results } = await api.albumApi.addAssetsToAlbum({
id: album.id,
addAssetsDto: {
assetIds: assets.map((a) => a.id),
},
bulkIdsDto: { ids: assets.map((a) => a.id) },
key: sharedLink?.key,
});
if (data.album) {
album = data.album;
}
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: `Added ${count} asset${count === 1 ? '' : 's'}`,
});
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
album = data;
isShowAssetSelection = false;
} catch (e) {
console.error('Error [createAlbumHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error creating album, check console for more details',
});
handleError(e, 'Error creating album');
}
};
@ -307,7 +308,7 @@
{#if sharedLink?.allowDownload || !isPublicShared}
<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
{/if}
{#if isOwned}
{#if isOwned || isMultiSelectionUserOwned}
<RemoveFromAlbum bind:album />
{/if}
</AssetSelectControlBar>

View File

@ -189,11 +189,8 @@
isShowAlbumPicker = false;
const album = event.detail.album;
addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
if (dto.successfullyAdded === 1 && dto.album) {
appearsInAlbums = [...appearsInAlbums, dto.album];
}
});
await addAssetsToAlbum(album.id, [asset.id]);
await getAllAlbums();
};
const disableKeyDownEvent = () => {

View File

@ -44,10 +44,9 @@
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
showAlbumPicker = false;
const album = event.detail.album;
const assetIds = Array.from(getAssets()).map((asset) => asset.id);
addAssetsToAlbum(album.id, assetIds).then(clearSelect);
await addAssetsToAlbum(album.id, assetIds);
clearSelect();
};
</script>

View File

@ -17,14 +17,20 @@
const removeFromAlbum = async () => {
try {
const { data } = await api.albumApi.removeAssetFromAlbum({
const { data: results } = await api.albumApi.removeAssetFromAlbum({
id: album.id,
removeAssetsDto: {
assetIds: Array.from(getAssets()).map((a) => a.id),
},
bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) },
});
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
album = data;
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} asset${count === 1 ? '' : 's'}`,
});
clearSelect();
} catch (e) {
console.error('Error [album-viewer] [removeAssetFromAlbum]', e);

View File

@ -1,23 +1,24 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { downloadManager } from '$lib/stores/download';
import { AddAssetsResponseDto, api, AssetApiGetDownloadInfoRequest, AssetResponseDto, DownloadResponseDto } from '@api';
import { api, AssetApiGetDownloadInfoRequest, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto } from '@api';
import { handleError } from './handle-error';
export const addAssetsToAlbum = async (
albumId: string,
assetIds: Array<string>,
key: string | undefined = undefined,
): Promise<AddAssetsResponseDto> =>
api.albumApi.addAssetsToAlbum({ id: albumId, addAssetsDto: { assetIds }, key }).then(({ data: dto }) => {
if (dto.successfullyAdded > 0) {
): Promise<BulkIdResponseDto[]> =>
api.albumApi.addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetIds }, key }).then(({ data: results }) => {
const count = results.filter(({ success }) => success).length;
if (count > 0) {
// This might be 0 if the user tries to add an asset that is already in the album
notificationController.show({
message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
type: NotificationType.Info,
message: `Added ${count} asset${count === 1 ? '' : 's'}`,
});
}
return dto;
return results;
});
const downloadBlob = (data: Blob, filename: string) => {