1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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

* refactor: add/remove album assets

* chore: open api

* feat: remove owned assets from album

* refactor: move to bulk id req/res dto

* chore: open api

* chore: merge main

* dev: mobile work

* fix: adding asset from web not sync with mobile

* remove print statement

---------

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

View File

@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
*/ */
'name': string; '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 * @export
@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = {
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
/**
*
* @export
* @interface BulkIdsDto
*/
export interface BulkIdsDto {
/**
*
* @type {Array<string>}
* @memberof BulkIdsDto
*/
'ids': Array<string>;
}
/** /**
* *
* @export * @export
@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
*/ */
'isPaused': boolean; 'isPaused': boolean;
} }
/**
*
* @export
* @interface RemoveAssetsDto
*/
export interface RemoveAssetsDto {
/**
*
* @type {Array<string>}
* @memberof RemoveAssetsDto
*/
'assetIds': Array<string>;
}
/** /**
* *
* @export * @export
@ -3678,16 +3640,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* *
* @param {string} id * @param {string} id
* @param {AddAssetsDto} addAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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 // verify required parameter 'id' is not null or undefined
assertParamExists('addAssetsToAlbum', 'id', id) assertParamExists('addAssetsToAlbum', 'id', id)
// verify required parameter 'addAssetsDto' is not null or undefined // verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto) assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets` const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id))); .replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs. // 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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration) localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return { return {
url: toPathString(localVarUrlObj), url: toPathString(localVarUrlObj),
@ -3998,15 +3960,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* *
* @param {string} id * @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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 // verify required parameter 'id' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'id', id) assertParamExists('removeAssetFromAlbum', 'id', id)
// verify required parameter 'removeAssetsDto' is not null or undefined // verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto) assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets` const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id))); .replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs. // 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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration) localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return { return {
url: toPathString(localVarUrlObj), url: toPathString(localVarUrlObj),
@ -4150,13 +4112,13 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} id * @param {string} id
* @param {AddAssetsDto} addAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> { async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options); const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -4224,12 +4186,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} id * @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> { async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -4270,8 +4232,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<AddAssetsResponseDto> { addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(axios, basePath)); 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. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<AlbumResponseDto> { removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(axios, basePath)); 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 * @memberof AlbumApiAddAssetsToAlbum
*/ */
readonly addAssetsDto: AddAssetsDto readonly bulkIdsDto: BulkIdsDto
/** /**
* *
@ -4490,10 +4452,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest {
/** /**
* *
* @type {RemoveAssetsDto} * @type {BulkIdsDto}
* @memberof AlbumApiRemoveAssetFromAlbum * @memberof AlbumApiRemoveAssetFromAlbum
*/ */
readonly removeAssetsDto: RemoveAssetsDto readonly bulkIdsDto: BulkIdsDto
} }
/** /**
@ -4553,7 +4515,7 @@ export class AlbumApi extends BaseAPI {
* @memberof AlbumApi * @memberof AlbumApi
*/ */
public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) { 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 * @memberof AlbumApi
*/ */
public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) { 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.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/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> { class SharedAlbumNotifier extends StateNotifier<List<Album>> {
@ -72,19 +70,3 @@ final sharedAlbumProvider =
ref.watch(dbProvider), 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:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
@ -219,24 +220,43 @@ class AlbumService {
yield* _db.albums.watchObject(albumId); yield* _db.albums.watchObject(albumId);
} }
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets, Iterable<Asset> assets,
Album album, Album album,
) async { ) async {
try { try {
var result = await _apiService.albumApi.addAssetsToAlbum( var response = await _apiService.albumApi.addAssetsToAlbum(
album.remoteId!, 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()); await _db.writeTxn(() => album.assets.save());
return AddAssetsResponse(
alreadyInAlbum: duplicatedAssets,
successfullyAdded: successAssets.length,
);
} }
return result;
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
return null; return null;
} }
return null;
} }
Future<bool> addAdditionalUserToAlbum( Future<bool> addAdditionalUserToAlbum(
@ -314,8 +334,8 @@ class AlbumService {
try { try {
await _apiService.albumApi.removeAssetFromAlbum( await _apiService.albumApi.removeAssetFromAlbum(
album.remoteId!, album.remoteId!,
RemoveAssetsDto( BulkIdsDto(
assetIds: assets.map((e) => e.remoteId!).toList(growable: false), ids: assets.map((asset) => asset.remoteId!).toList(),
), ),
); );
album.assets.removeAll(assets); album.assets.removeAll(assets);

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.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/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.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.invalidate(albumDetailProvider(album.id));
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
Navigator.pop(context); Navigator.pop(context);
} }

View File

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

View File

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

View File

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

View File

@ -8,8 +8,6 @@ doc/APIKeyCreateDto.md
doc/APIKeyCreateResponseDto.md doc/APIKeyCreateResponseDto.md
doc/APIKeyResponseDto.md doc/APIKeyResponseDto.md
doc/APIKeyUpdateDto.md doc/APIKeyUpdateDto.md
doc/AddAssetsDto.md
doc/AddAssetsResponseDto.md
doc/AddUsersDto.md doc/AddUsersDto.md
doc/AdminSignupResponseDto.md doc/AdminSignupResponseDto.md
doc/AlbumApi.md doc/AlbumApi.md
@ -33,6 +31,7 @@ doc/AudioCodec.md
doc/AuthDeviceResponseDto.md doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md doc/AuthenticationApi.md
doc/BulkIdResponseDto.md doc/BulkIdResponseDto.md
doc/BulkIdsDto.md
doc/ChangePasswordDto.md doc/ChangePasswordDto.md
doc/CheckDuplicateAssetDto.md doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md doc/CheckDuplicateAssetResponseDto.md
@ -78,7 +77,6 @@ doc/PersonApi.md
doc/PersonResponseDto.md doc/PersonResponseDto.md
doc/PersonUpdateDto.md doc/PersonUpdateDto.md
doc/QueueStatusDto.md doc/QueueStatusDto.md
doc/RemoveAssetsDto.md
doc/SearchAlbumResponseDto.md doc/SearchAlbumResponseDto.md
doc/SearchApi.md doc/SearchApi.md
doc/SearchAssetDto.md doc/SearchAssetDto.md
@ -150,8 +148,6 @@ lib/auth/authentication.dart
lib/auth/http_basic_auth.dart lib/auth/http_basic_auth.dart
lib/auth/http_bearer_auth.dart lib/auth/http_bearer_auth.dart
lib/auth/oauth.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/add_users_dto.dart
lib/model/admin_signup_response_dto.dart lib/model/admin_signup_response_dto.dart
lib/model/album_count_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/audio_codec.dart
lib/model/auth_device_response_dto.dart lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart lib/model/bulk_id_response_dto.dart
lib/model/bulk_ids_dto.dart
lib/model/change_password_dto.dart lib/model/change_password_dto.dart
lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_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_response_dto.dart
lib/model/person_update_dto.dart lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart lib/model/queue_status_dto.dart
lib/model/remove_assets_dto.dart
lib/model/search_album_response_dto.dart lib/model/search_album_response_dto.dart
lib/model/search_asset_dto.dart lib/model/search_asset_dto.dart
lib/model/search_asset_response_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/validate_access_token_response_dto.dart
lib/model/video_codec.dart lib/model/video_codec.dart
pubspec.yaml pubspec.yaml
test/add_assets_dto_test.dart
test/add_assets_response_dto_test.dart
test/add_users_dto_test.dart test/add_users_dto_test.dart
test/admin_signup_response_dto_test.dart test/admin_signup_response_dto_test.dart
test/album_api_test.dart test/album_api_test.dart
@ -290,6 +284,7 @@ test/audio_codec_test.dart
test/auth_device_response_dto_test.dart test/auth_device_response_dto_test.dart
test/authentication_api_test.dart test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart test/bulk_id_response_dto_test.dart
test/bulk_ids_dto_test.dart
test/change_password_dto_test.dart test/change_password_dto_test.dart
test/check_duplicate_asset_dto_test.dart test/check_duplicate_asset_dto_test.dart
test/check_duplicate_asset_response_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_response_dto_test.dart
test/person_update_dto_test.dart test/person_update_dto_test.dart
test/queue_status_dto_test.dart test/queue_status_dto_test.dart
test/remove_assets_dto_test.dart
test/search_album_response_dto_test.dart test/search_album_response_dto_test.dart
test/search_api_test.dart test/search_api_test.dart
test/search_asset_dto_test.dart test/search_asset_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import {
albumStub, albumStub,
assetStub,
authStub, authStub,
IAccessRepositoryMock, IAccessRepositoryMock,
newAccessRepositoryMock, newAccessRepositoryMock,
@ -11,7 +12,7 @@ import {
userStub, userStub,
} from '@test'; } from '@test';
import _ from 'lodash'; import _ from 'lodash';
import { IAssetRepository } from '../asset'; import { BulkIdErrorReason, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user'; import { IUserRepository } from '../user';
import { IAlbumRepository } from './album.repository'; import { IAlbumRepository } from './album.repository';
@ -202,7 +203,7 @@ describe(AlbumService.name, () => {
describe('update', () => { describe('update', () => {
it('should prevent updating an album that does not exist', async () => { it('should prevent updating an album that does not exist', async () => {
albumMock.getByIds.mockResolvedValue([]); albumMock.getById.mockResolvedValue(null);
await expect( await expect(
sut.update(authStub.user1, 'invalid-id', { sut.update(authStub.user1, 'invalid-id', {
@ -224,7 +225,7 @@ describe(AlbumService.name, () => {
it('should require a valid thumbnail asset id', async () => { it('should require a valid thumbnail asset id', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
albumMock.update.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset);
albumMock.hasAsset.mockResolvedValue(false); albumMock.hasAsset.mockResolvedValue(false);
@ -241,7 +242,7 @@ describe(AlbumService.name, () => {
it('should allow the owner to update the album', async () => { it('should allow the owner to update the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); albumMock.getById.mockResolvedValue(albumStub.oneAsset);
albumMock.update.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset);
await sut.update(authStub.admin, albumStub.oneAsset.id, { await sut.update(authStub.admin, albumStub.oneAsset.id, {
@ -263,7 +264,7 @@ describe(AlbumService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should throw an error for an album not found', async () => { it('should throw an error for an album not found', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([]); albumMock.getById.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -274,7 +275,7 @@ describe(AlbumService.name, () => {
it('should not let a shared user delete the album', async () => { it('should not let a shared user delete the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false); 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( await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -285,7 +286,7 @@ describe(AlbumService.name, () => {
it('should let the owner delete an album', async () => { it('should let the owner delete an album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.empty]); albumMock.getById.mockResolvedValue(albumStub.empty);
await sut.delete(authStub.admin, albumStub.empty.id); 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 () => { it('should throw an error if the userId is already added', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect( await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }), sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
@ -314,7 +315,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => { it('should throw an error if the userId does not exist', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(null);
await expect( await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }), sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
@ -324,7 +325,7 @@ describe(AlbumService.name, () => {
it('should add valid shared users', async () => { it('should add valid shared users', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(userStub.user2); userMock.get.mockResolvedValue(userStub.user2);
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] }); await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
@ -339,14 +340,14 @@ describe(AlbumService.name, () => {
describe('removeUser', () => { describe('removeUser', () => {
it('should require a valid album id', async () => { it('should require a valid album id', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); 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); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
it('should remove a shared user from an owned album', async () => { it('should remove a shared user from an owned album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
await expect( await expect(
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), 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 () => { it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false); accessMock.album.hasOwnerAccess.mockResolvedValue(false);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]); albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
await expect( await expect(
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id), 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 () => { 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); 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 () => { 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'); 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 () => { 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( await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -409,7 +410,7 @@ describe(AlbumService.name, () => {
}); });
it('should throw an error for a user not in the album', async () => { 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( await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -418,4 +419,301 @@ describe(AlbumService.name, () => {
expect(albumMock.update).not.toHaveBeenCalled(); 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 { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; 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 { AuthUserDto } from '../auth';
import { AccessCore, IAccessRepository, Permission } from '../index';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user'; import { IUserRepository } from '../user';
import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto'; 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[]> { 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[]; let albums: AlbumEntity[];
if (assetId) { if (assetId) {
@ -73,15 +77,10 @@ export class AlbumService {
); );
} }
private async updateInvalidThumbnails(): Promise<number> { async get(authUser: AuthUserDto, id: string) {
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails();
for (const albumId of invalidAlbumIds) { return mapAlbum(await this.findOrFail(id));
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
}
return invalidAlbumIds.length;
} }
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> { 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> { async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id); await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
const album = await this.get(id); const album = await this.findOrFail(id);
if (dto.albumThumbnailAssetId) { if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, 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> { async delete(authUser: AuthUserDto, id: string): Promise<void> {
await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); 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) { if (!album) {
throw new BadRequestException('Album not found'); 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] } }); 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) { async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id); 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) { for (const userId of dto.sharedUserIds) {
const exists = album.sharedUsers.find((user) => user.id === userId); const exists = album.sharedUsers.find((user) => user.id === userId);
@ -172,7 +249,7 @@ export class AlbumService {
userId = authUser.id; userId = authUser.id;
} }
const album = await this.get(id); const album = await this.findOrFail(id);
if (album.ownerId === userId) { if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner'); throw new BadRequestException('Cannot remove album owner');
@ -195,8 +272,8 @@ export class AlbumService {
}); });
} }
private async get(id: string) { private async findOrFail(id: string) {
const [album] = await this.albumRepository.getByIds([id]); const album = await this.albumRepository.getById(id);
if (!album) { if (!album) {
throw new BadRequestException('Album not found'); throw new BadRequestException('Album not found');
} }

View File

@ -1,3 +1,5 @@
import { ValidateUUID } from '../../domain.util';
/** @deprecated Use `BulkIdResponseDto` instead */ /** @deprecated Use `BulkIdResponseDto` instead */
export enum AssetIdErrorReason { export enum AssetIdErrorReason {
DUPLICATE = 'duplicate', DUPLICATE = 'duplicate',
@ -19,6 +21,11 @@ export enum BulkIdErrorReason {
UNKNOWN = 'unknown', UNKNOWN = 'unknown',
} }
export class BulkIdsDto {
@ValidateUUID({ each: true })
ids!: string[];
}
export class BulkIdResponseDto { export class BulkIdResponseDto {
id!: string; id!: string;
success!: boolean; 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 { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AlbumModule } from './api-v1/album/album.module';
import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository'; import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository';
import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller'; import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller';
import { AssetService } from './api-v1/asset/asset.service'; import { AssetService } from './api-v1/asset/asset.service';
@ -34,7 +33,6 @@ import {
imports: [ imports: [
// //
DomainModule.register({ imports: [InfraModule] }), DomainModule.register({ imports: [InfraModule] }),
AlbumModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]), TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
], ],

View File

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

View File

@ -3,11 +3,35 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm'; import { In, IsNull, Not, Repository } from 'typeorm';
import { dataSource } from '../database.config'; import { dataSource } from '../database.config';
import { AlbumEntity } from '../entities'; import { AlbumEntity, AssetEntity } from '../entities';
@Injectable() @Injectable()
export class AlbumRepository implements IAlbumRepository { 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[]> { getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({ 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: [], sharedLinks: [],
sharedUsers: [], 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>({ emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5', id: 'album-5',
albumName: 'Empty album with invalid thumbnail', albumName: 'Empty album with invalid thumbnail',

View File

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

View File

@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
*/ */
'name': string; '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 * @export
@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = {
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
/**
*
* @export
* @interface BulkIdsDto
*/
export interface BulkIdsDto {
/**
*
* @type {Array<string>}
* @memberof BulkIdsDto
*/
'ids': Array<string>;
}
/** /**
* *
* @export * @export
@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
*/ */
'isPaused': boolean; 'isPaused': boolean;
} }
/**
*
* @export
* @interface RemoveAssetsDto
*/
export interface RemoveAssetsDto {
/**
*
* @type {Array<string>}
* @memberof RemoveAssetsDto
*/
'assetIds': Array<string>;
}
/** /**
* *
* @export * @export
@ -3679,16 +3641,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* *
* @param {string} id * @param {string} id
* @param {AddAssetsDto} addAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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 // verify required parameter 'id' is not null or undefined
assertParamExists('addAssetsToAlbum', 'id', id) assertParamExists('addAssetsToAlbum', 'id', id)
// verify required parameter 'addAssetsDto' is not null or undefined // verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto) assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets` const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id))); .replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs. // 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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration) localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return { return {
url: toPathString(localVarUrlObj), url: toPathString(localVarUrlObj),
@ -3999,15 +3961,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* *
* @param {string} id * @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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 // verify required parameter 'id' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'id', id) assertParamExists('removeAssetFromAlbum', 'id', id)
// verify required parameter 'removeAssetsDto' is not null or undefined // verify required parameter 'bulkIdsDto' is not null or undefined
assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto) assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto)
const localVarPath = `/album/{id}/assets` const localVarPath = `/album/{id}/assets`
.replace(`{${"id"}}`, encodeURIComponent(String(id))); .replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs. // 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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration) localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
return { return {
url: toPathString(localVarUrlObj), url: toPathString(localVarUrlObj),
@ -4151,13 +4113,13 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} id * @param {string} id
* @param {AddAssetsDto} addAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> { async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options); const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -4225,12 +4187,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} id * @param {string} id
* @param {RemoveAssetsDto} removeAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> { async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -4268,13 +4230,13 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
/** /**
* *
* @param {string} id * @param {string} id
* @param {AddAssetsDto} addAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {string} [key] * @param {string} [key]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise<AddAssetsResponseDto> { addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.addAssetsToAlbum(id, addAssetsDto, key, options).then((request) => request(axios, basePath)); 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 {string} id
* @param {RemoveAssetsDto} removeAssetsDto * @param {BulkIdsDto} bulkIdsDto
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise<AlbumResponseDto> { removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.removeAssetFromAlbum(id, removeAssetsDto, options).then((request) => request(axios, basePath)); return localVarFp.removeAssetFromAlbum(id, bulkIdsDto, options).then((request) => request(axios, basePath));
}, },
/** /**
* *
@ -4380,10 +4342,10 @@ export interface AlbumApiAddAssetsToAlbumRequest {
/** /**
* *
* @type {AddAssetsDto} * @type {BulkIdsDto}
* @memberof AlbumApiAddAssetsToAlbum * @memberof AlbumApiAddAssetsToAlbum
*/ */
readonly addAssetsDto: AddAssetsDto readonly bulkIdsDto: BulkIdsDto
/** /**
* *
@ -4499,10 +4461,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest {
/** /**
* *
* @type {RemoveAssetsDto} * @type {BulkIdsDto}
* @memberof AlbumApiRemoveAssetFromAlbum * @memberof AlbumApiRemoveAssetFromAlbum
*/ */
readonly removeAssetsDto: RemoveAssetsDto readonly bulkIdsDto: BulkIdsDto
} }
/** /**
@ -4562,7 +4524,7 @@ export class AlbumApi extends BaseAPI {
* @memberof AlbumApi * @memberof AlbumApi
*/ */
public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) { 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 * @memberof AlbumApi
*/ */
public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) { 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(); let multiSelectAsset: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = multiSelectAsset.size > 0; $: isMultiSelectionMode = multiSelectAsset.size > 0;
$: isMultiSelectionUserOwned = Array.from(multiSelectAsset).every((asset) => asset.ownerId === currentUser?.id);
afterNavigate(({ from }) => { afterNavigate(({ from }) => {
backUrl = from?.url.pathname ?? '/albums'; backUrl = from?.url.pathname ?? '/albums';
@ -182,24 +183,24 @@
const createAlbumHandler = async (event: CustomEvent) => { const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: AssetResponseDto[] } = event.detail; const { assets }: { assets: AssetResponseDto[] } = event.detail;
try { try {
const { data } = await api.albumApi.addAssetsToAlbum({ const { data: results } = await api.albumApi.addAssetsToAlbum({
id: album.id, id: album.id,
addAssetsDto: { bulkIdsDto: { ids: assets.map((a) => a.id) },
assetIds: assets.map((a) => a.id),
},
key: sharedLink?.key, key: sharedLink?.key,
}); });
if (data.album) { const count = results.filter(({ success }) => success).length;
album = data.album; 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; isShowAssetSelection = false;
} catch (e) { } catch (e) {
console.error('Error [createAlbumHandler] ', e); handleError(e, 'Error creating album');
notificationController.show({
type: NotificationType.Error,
message: 'Error creating album, check console for more details',
});
} }
}; };
@ -307,7 +308,7 @@
{#if sharedLink?.allowDownload || !isPublicShared} {#if sharedLink?.allowDownload || !isPublicShared}
<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} /> <DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
{/if} {/if}
{#if isOwned} {#if isOwned || isMultiSelectionUserOwned}
<RemoveFromAlbum bind:album /> <RemoveFromAlbum bind:album />
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>

View File

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

View File

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

View File

@ -17,14 +17,20 @@
const removeFromAlbum = async () => { const removeFromAlbum = async () => {
try { try {
const { data } = await api.albumApi.removeAssetFromAlbum({ const { data: results } = await api.albumApi.removeAssetFromAlbum({
id: album.id, id: album.id,
removeAssetsDto: { bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) },
assetIds: Array.from(getAssets()).map((a) => a.id),
},
}); });
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
album = data; album = data;
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} asset${count === 1 ? '' : 's'}`,
});
clearSelect(); clearSelect();
} catch (e) { } catch (e) {
console.error('Error [album-viewer] [removeAssetFromAlbum]', 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 { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { downloadManager } from '$lib/stores/download'; 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'; import { handleError } from './handle-error';
export const addAssetsToAlbum = async ( export const addAssetsToAlbum = async (
albumId: string, albumId: string,
assetIds: Array<string>, assetIds: Array<string>,
key: string | undefined = undefined, key: string | undefined = undefined,
): Promise<AddAssetsResponseDto> => ): Promise<BulkIdResponseDto[]> =>
api.albumApi.addAssetsToAlbum({ id: albumId, addAssetsDto: { assetIds }, key }).then(({ data: dto }) => { api.albumApi.addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetIds }, key }).then(({ data: results }) => {
if (dto.successfullyAdded > 0) { 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 // This might be 0 if the user tries to add an asset that is already in the album
notificationController.show({ notificationController.show({
message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
type: NotificationType.Info, type: NotificationType.Info,
message: `Added ${count} asset${count === 1 ? '' : 's'}`,
}); });
} }
return dto; return results;
}); });
const downloadBlob = (data: Blob, filename: string) => { const downloadBlob = (data: Blob, filename: string) => {