1
0
mirror of https://github.com/immich-app/immich.git synced 2025-03-03 15:42: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 890 additions and 1282 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

View File

@ -182,8 +182,6 @@ Class | Method | HTTP request | Description
- [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
- [APIKeyResponseDto](doc//APIKeyResponseDto.md)
- [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- [AddAssetsDto](doc//AddAssetsDto.md)
- [AddAssetsResponseDto](doc//AddAssetsResponseDto.md)
- [AddUsersDto](doc//AddUsersDto.md)
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
@ -204,6 +202,7 @@ Class | Method | HTTP request | Description
- [AudioCodec](doc//AudioCodec.md)
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
- [ChangePasswordDto](doc//ChangePasswordDto.md)
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
@ -245,7 +244,6 @@ Class | Method | HTTP request | Description
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [RemoveAssetsDto](doc//RemoveAssetsDto.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
- [SearchAssetDto](doc//SearchAssetDto.md)
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)

View File

@ -1,17 +0,0 @@
# openapi.model.AddAssetsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**album** | [**AlbumResponseDto**](AlbumResponseDto.md) | | [optional]
**alreadyInAlbum** | **List<String>** | | [default to const []]
**successfullyAdded** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -22,7 +22,7 @@ Method | HTTP request | Description
# **addAssetsToAlbum**
> AddAssetsResponseDto addAssetsToAlbum(id, addAssetsDto, key)
> List<BulkIdResponseDto> addAssetsToAlbum(id, bulkIdsDto, key)
@ -46,11 +46,11 @@ import 'package:openapi/api.dart';
final api_instance = AlbumApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final addAssetsDto = AddAssetsDto(); // AddAssetsDto |
final bulkIdsDto = BulkIdsDto(); // BulkIdsDto |
final key = key_example; // String |
try {
final result = api_instance.addAssetsToAlbum(id, addAssetsDto, key);
final result = api_instance.addAssetsToAlbum(id, bulkIdsDto, key);
print(result);
} catch (e) {
print('Exception when calling AlbumApi->addAssetsToAlbum: $e\n');
@ -62,12 +62,12 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**addAssetsDto** | [**AddAssetsDto**](AddAssetsDto.md)| |
**bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)| |
**key** | **String**| | [optional]
### Return type
[**AddAssetsResponseDto**](AddAssetsResponseDto.md)
[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
### Authorization
@ -412,7 +412,7 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **removeAssetFromAlbum**
> AlbumResponseDto removeAssetFromAlbum(id, removeAssetsDto)
> List<BulkIdResponseDto> removeAssetFromAlbum(id, bulkIdsDto)
@ -436,10 +436,10 @@ import 'package:openapi/api.dart';
final api_instance = AlbumApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto |
final bulkIdsDto = BulkIdsDto(); // BulkIdsDto |
try {
final result = api_instance.removeAssetFromAlbum(id, removeAssetsDto);
final result = api_instance.removeAssetFromAlbum(id, bulkIdsDto);
print(result);
} catch (e) {
print('Exception when calling AlbumApi->removeAssetFromAlbum: $e\n');
@ -451,11 +451,11 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**removeAssetsDto** | [**RemoveAssetsDto**](RemoveAssetsDto.md)| |
**bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)| |
### Return type
[**AlbumResponseDto**](AlbumResponseDto.md)
[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
### Authorization

View File

@ -1,4 +1,4 @@
# openapi.model.AddAssetsDto
# openapi.model.BulkIdsDto
## Load the model package
```dart
@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assetIds** | **List<String>** | | [default to const []]
**ids** | **List<String>** | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -1,15 +0,0 @@
# openapi.model.RemoveAssetsDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assetIds** | **List<String>** | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -47,8 +47,6 @@ part 'model/api_key_create_dto.dart';
part 'model/api_key_create_response_dto.dart';
part 'model/api_key_response_dto.dart';
part 'model/api_key_update_dto.dart';
part 'model/add_assets_dto.dart';
part 'model/add_assets_response_dto.dart';
part 'model/add_users_dto.dart';
part 'model/admin_signup_response_dto.dart';
part 'model/album_count_response_dto.dart';
@ -69,6 +67,7 @@ part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart';
part 'model/auth_device_response_dto.dart';
part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart';
part 'model/change_password_dto.dart';
part 'model/check_duplicate_asset_dto.dart';
part 'model/check_duplicate_asset_response_dto.dart';
@ -110,7 +109,6 @@ part 'model/people_update_item.dart';
part 'model/person_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/queue_status_dto.dart';
part 'model/remove_assets_dto.dart';
part 'model/search_album_response_dto.dart';
part 'model/search_asset_dto.dart';
part 'model/search_asset_response_dto.dart';

View File

@ -21,16 +21,16 @@ class AlbumApi {
///
/// * [String] id (required):
///
/// * [AddAssetsDto] addAssetsDto (required):
/// * [BulkIdsDto] bulkIdsDto (required):
///
/// * [String] key:
Future<Response> addAssetsToAlbumWithHttpInfo(String id, AddAssetsDto addAssetsDto, { String? key, }) async {
Future<Response> addAssetsToAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/album/{id}/assets'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = addAssetsDto;
Object? postBody = bulkIdsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
@ -58,11 +58,11 @@ class AlbumApi {
///
/// * [String] id (required):
///
/// * [AddAssetsDto] addAssetsDto (required):
/// * [BulkIdsDto] bulkIdsDto (required):
///
/// * [String] key:
Future<AddAssetsResponseDto?> addAssetsToAlbum(String id, AddAssetsDto addAssetsDto, { String? key, }) async {
final response = await addAssetsToAlbumWithHttpInfo(id, addAssetsDto, key: key, );
Future<List<BulkIdResponseDto>?> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String? key, }) async {
final response = await addAssetsToAlbumWithHttpInfo(id, bulkIdsDto, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -70,8 +70,11 @@ class AlbumApi {
// 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), 'AddAssetsResponseDto',) as AddAssetsResponseDto;
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
.cast<BulkIdResponseDto>()
.toList();
}
return null;
}
@ -380,14 +383,14 @@ class AlbumApi {
///
/// * [String] id (required):
///
/// * [RemoveAssetsDto] removeAssetsDto (required):
Future<Response> removeAssetFromAlbumWithHttpInfo(String id, RemoveAssetsDto removeAssetsDto,) async {
/// * [BulkIdsDto] bulkIdsDto (required):
Future<Response> removeAssetFromAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async {
// ignore: prefer_const_declarations
final path = r'/album/{id}/assets'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = removeAssetsDto;
Object? postBody = bulkIdsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
@ -411,9 +414,9 @@ class AlbumApi {
///
/// * [String] id (required):
///
/// * [RemoveAssetsDto] removeAssetsDto (required):
Future<AlbumResponseDto?> removeAssetFromAlbum(String id, RemoveAssetsDto removeAssetsDto,) async {
final response = await removeAssetFromAlbumWithHttpInfo(id, removeAssetsDto,);
/// * [BulkIdsDto] bulkIdsDto (required):
Future<List<BulkIdResponseDto>?> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto,) async {
final response = await removeAssetFromAlbumWithHttpInfo(id, bulkIdsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -421,8 +424,11 @@ class AlbumApi {
// 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;
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
.cast<BulkIdResponseDto>()
.toList();
}
return null;
}

View File

@ -189,10 +189,6 @@ class ApiClient {
return APIKeyResponseDto.fromJson(value);
case 'APIKeyUpdateDto':
return APIKeyUpdateDto.fromJson(value);
case 'AddAssetsDto':
return AddAssetsDto.fromJson(value);
case 'AddAssetsResponseDto':
return AddAssetsResponseDto.fromJson(value);
case 'AddUsersDto':
return AddUsersDto.fromJson(value);
case 'AdminSignupResponseDto':
@ -233,6 +229,8 @@ class ApiClient {
return AuthDeviceResponseDto.fromJson(value);
case 'BulkIdResponseDto':
return BulkIdResponseDto.fromJson(value);
case 'BulkIdsDto':
return BulkIdsDto.fromJson(value);
case 'ChangePasswordDto':
return ChangePasswordDto.fromJson(value);
case 'CheckDuplicateAssetDto':
@ -315,8 +313,6 @@ class ApiClient {
return PersonUpdateDto.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'RemoveAssetsDto':
return RemoveAssetsDto.fromJson(value);
case 'SearchAlbumResponseDto':
return SearchAlbumResponseDto.fromJson(value);
case 'SearchAssetDto':

View File

@ -1,125 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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 AddAssetsResponseDto {
/// Returns a new [AddAssetsResponseDto] instance.
AddAssetsResponseDto({
this.album,
this.alreadyInAlbum = const [],
required this.successfullyAdded,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AlbumResponseDto? album;
List<String> alreadyInAlbum;
int successfullyAdded;
@override
bool operator ==(Object other) => identical(this, other) || other is AddAssetsResponseDto &&
other.album == album &&
other.alreadyInAlbum == alreadyInAlbum &&
other.successfullyAdded == successfullyAdded;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(album == null ? 0 : album!.hashCode) +
(alreadyInAlbum.hashCode) +
(successfullyAdded.hashCode);
@override
String toString() => 'AddAssetsResponseDto[album=$album, alreadyInAlbum=$alreadyInAlbum, successfullyAdded=$successfullyAdded]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.album != null) {
json[r'album'] = this.album;
} else {
// json[r'album'] = null;
}
json[r'alreadyInAlbum'] = this.alreadyInAlbum;
json[r'successfullyAdded'] = this.successfullyAdded;
return json;
}
/// Returns a new [AddAssetsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AddAssetsResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AddAssetsResponseDto(
album: AlbumResponseDto.fromJson(json[r'album']),
alreadyInAlbum: json[r'alreadyInAlbum'] is Iterable
? (json[r'alreadyInAlbum'] as Iterable).cast<String>().toList(growable: false)
: const [],
successfullyAdded: mapValueOfType<int>(json, r'successfullyAdded')!,
);
}
return null;
}
static List<AddAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AddAssetsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AddAssetsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AddAssetsResponseDto> mapFromJson(dynamic json) {
final map = <String, AddAssetsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AddAssetsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AddAssetsResponseDto-objects as value to a dart map
static Map<String, List<AddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AddAssetsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'alreadyInAlbum',
'successfullyAdded',
};
}

View File

@ -10,53 +10,53 @@
part of openapi.api;
class AddAssetsDto {
/// Returns a new [AddAssetsDto] instance.
AddAssetsDto({
this.assetIds = const [],
class BulkIdsDto {
/// Returns a new [BulkIdsDto] instance.
BulkIdsDto({
this.ids = const [],
});
List<String> assetIds;
List<String> ids;
@override
bool operator ==(Object other) => identical(this, other) || other is AddAssetsDto &&
other.assetIds == assetIds;
bool operator ==(Object other) => identical(this, other) || other is BulkIdsDto &&
other.ids == ids;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetIds.hashCode);
(ids.hashCode);
@override
String toString() => 'AddAssetsDto[assetIds=$assetIds]';
String toString() => 'BulkIdsDto[ids=$ids]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetIds'] = this.assetIds;
json[r'ids'] = this.ids;
return json;
}
/// Returns a new [AddAssetsDto] instance and imports its values from
/// Returns a new [BulkIdsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AddAssetsDto? fromJson(dynamic value) {
static BulkIdsDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AddAssetsDto(
assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
return BulkIdsDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<AddAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AddAssetsDto>[];
static List<BulkIdsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <BulkIdsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AddAssetsDto.fromJson(row);
final value = BulkIdsDto.fromJson(row);
if (value != null) {
result.add(value);
}
@ -65,12 +65,12 @@ class AddAssetsDto {
return result.toList(growable: growable);
}
static Map<String, AddAssetsDto> mapFromJson(dynamic json) {
final map = <String, AddAssetsDto>{};
static Map<String, BulkIdsDto> mapFromJson(dynamic json) {
final map = <String, BulkIdsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AddAssetsDto.fromJson(entry.value);
final value = BulkIdsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
@ -79,14 +79,14 @@ class AddAssetsDto {
return map;
}
// maps a json object with a list of AddAssetsDto-objects as value to a dart map
static Map<String, List<AddAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AddAssetsDto>>{};
// maps a json object with a list of BulkIdsDto-objects as value to a dart map
static Map<String, List<BulkIdsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<BulkIdsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AddAssetsDto.listFromJson(entry.value, growable: growable,);
map[entry.key] = BulkIdsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@ -94,7 +94,7 @@ class AddAssetsDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetIds',
'ids',
};
}

View File

@ -1,100 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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 RemoveAssetsDto {
/// Returns a new [RemoveAssetsDto] instance.
RemoveAssetsDto({
this.assetIds = const [],
});
List<String> assetIds;
@override
bool operator ==(Object other) => identical(this, other) || other is RemoveAssetsDto &&
other.assetIds == assetIds;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetIds.hashCode);
@override
String toString() => 'RemoveAssetsDto[assetIds=$assetIds]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetIds'] = this.assetIds;
return json;
}
/// Returns a new [RemoveAssetsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RemoveAssetsDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return RemoveAssetsDto(
assetIds: json[r'assetIds'] is Iterable
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<RemoveAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RemoveAssetsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RemoveAssetsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RemoveAssetsDto> mapFromJson(dynamic json) {
final map = <String, RemoveAssetsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RemoveAssetsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RemoveAssetsDto-objects as value to a dart map
static Map<String, List<RemoveAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RemoveAssetsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RemoveAssetsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetIds',
};
}

View File

@ -1,37 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AddAssetsResponseDto
void main() {
// final instance = AddAssetsResponseDto();
group('test AddAssetsResponseDto', () {
// AlbumResponseDto album
test('to test the property `album`', () async {
// TODO
});
// List<String> alreadyInAlbum (default value: const [])
test('to test the property `alreadyInAlbum`', () async {
// TODO
});
// int successfullyAdded
test('to test the property `successfullyAdded`', () async {
// TODO
});
});
}

View File

@ -17,7 +17,7 @@ void main() {
// final instance = AlbumApi();
group('tests for AlbumApi', () {
//Future<AddAssetsResponseDto> addAssetsToAlbum(String id, AddAssetsDto addAssetsDto, { String key }) async
//Future<List<BulkIdResponseDto>> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String key }) async
test('test addAssetsToAlbum', () async {
// TODO
});
@ -52,7 +52,7 @@ void main() {
// TODO
});
//Future<AlbumResponseDto> removeAssetFromAlbum(String id, RemoveAssetsDto removeAssetsDto) async
//Future<List<BulkIdResponseDto>> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto) async
test('test removeAssetFromAlbum', () async {
// TODO
});

View File

@ -11,13 +11,13 @@
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AddAssetsDto
// tests for BulkIdsDto
void main() {
// final instance = AddAssetsDto();
// final instance = BulkIdsDto();
group('test AddAssetsDto', () {
// List<String> assetIds (default value: const [])
test('to test the property `assetIds`', () async {
group('test BulkIdsDto', () {
// List<String> ids (default value: const [])
test('to test the property `ids`', () async {
// TODO
});

View File

@ -1,27 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// 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
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for RemoveAssetsDto
void main() {
// final instance = RemoveAssetsDto();
group('test RemoveAssetsDto', () {
// List<String> assetIds (default value: const [])
test('to test the property `assetIds`', () async {
// TODO
});
});
}

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) => {