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:
parent
ba71c83948
commit
b9cda59172
116
cli/src/api/open-api/api.ts
generated
116
cli/src/api/open-api/api.ts
generated
@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
|
||||
*/
|
||||
'name': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AddAssetsDto
|
||||
*/
|
||||
export interface AddAssetsDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof AddAssetsDto
|
||||
*/
|
||||
'assetIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AddAssetsResponseDto
|
||||
*/
|
||||
export interface AddAssetsResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {AlbumResponseDto}
|
||||
* @memberof AddAssetsResponseDto
|
||||
*/
|
||||
'album'?: AlbumResponseDto;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof AddAssetsResponseDto
|
||||
*/
|
||||
'alreadyInAlbum': Array<string>;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AddAssetsResponseDto
|
||||
*/
|
||||
'successfullyAdded': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = {
|
||||
|
||||
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface BulkIdsDto
|
||||
*/
|
||||
export interface BulkIdsDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof BulkIdsDto
|
||||
*/
|
||||
'ids': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
|
||||
*/
|
||||
'isPaused': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface RemoveAssetsDto
|
||||
*/
|
||||
export interface RemoveAssetsDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof RemoveAssetsDto
|
||||
*/
|
||||
'assetIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -3678,16 +3640,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AddAssetsDto} addAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('addAssetsToAlbum', 'id', id)
|
||||
// verify required parameter 'addAssetsDto' is not null or undefined
|
||||
assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto)
|
||||
// verify required parameter 'bulkIdsDto' is not null or undefined
|
||||
assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto)
|
||||
const localVarPath = `/album/{id}/assets`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
@ -3721,7 +3683,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration)
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
@ -3998,15 +3960,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {RemoveAssetsDto} removeAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('removeAssetFromAlbum', 'id', id)
|
||||
// verify required parameter 'removeAssetsDto' is not null or undefined
|
||||
assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto)
|
||||
// verify required parameter 'bulkIdsDto' is not null or undefined
|
||||
assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto)
|
||||
const localVarPath = `/album/{id}/assets`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
@ -4036,7 +3998,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration)
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
@ -4150,13 +4112,13 @@ export const AlbumApiFp = function(configuration?: Configuration) {
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AddAssetsDto} addAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options);
|
||||
async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@ -4224,12 +4186,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {RemoveAssetsDto} removeAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options);
|
||||
async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@ -4270,8 +4232,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<AddAssetsResponseDto> {
|
||||
return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
|
||||
return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@ -4332,8 +4294,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<AlbumResponseDto> {
|
||||
return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(axios, basePath));
|
||||
removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
|
||||
return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@ -4371,10 +4333,10 @@ export interface AlbumApiAddAssetsToAlbumRequest {
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {AddAssetsDto}
|
||||
* @type {BulkIdsDto}
|
||||
* @memberof AlbumApiAddAssetsToAlbum
|
||||
*/
|
||||
readonly addAssetsDto: AddAssetsDto
|
||||
readonly bulkIdsDto: BulkIdsDto
|
||||
|
||||
/**
|
||||
*
|
||||
@ -4490,10 +4452,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest {
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {RemoveAssetsDto}
|
||||
* @type {BulkIdsDto}
|
||||
* @memberof AlbumApiRemoveAssetFromAlbum
|
||||
*/
|
||||
readonly removeAssetsDto: RemoveAssetsDto
|
||||
readonly bulkIdsDto: BulkIdsDto
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4553,7 +4515,7 @@ export class AlbumApi extends BaseAPI {
|
||||
* @memberof AlbumApi
|
||||
*/
|
||||
public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) {
|
||||
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4629,7 +4591,7 @@ export class AlbumApi extends BaseAPI {
|
||||
* @memberof AlbumApi
|
||||
*/
|
||||
public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) {
|
||||
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath));
|
||||
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
@ -3,12 +3,10 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
@ -72,19 +70,3 @@ final sharedAlbumProvider =
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final sharedAlbumDetailProvider =
|
||||
StreamProvider.family<Album, int>((ref, albumId) async* {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) return;
|
||||
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
|
||||
|
||||
await for (final a in sharedAlbumService.watchAlbum(albumId)) {
|
||||
if (a == null) {
|
||||
throw Exception("Album with ID=$albumId does not exist anymore!");
|
||||
}
|
||||
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
|
||||
yield a;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
@ -219,24 +220,43 @@ class AlbumService {
|
||||
yield* _db.albums.watchObject(albumId);
|
||||
}
|
||||
|
||||
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
||||
Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
|
||||
Iterable<Asset> assets,
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
var result = await _apiService.albumApi.addAssetsToAlbum(
|
||||
var response = await _apiService.albumApi.addAssetsToAlbum(
|
||||
album.remoteId!,
|
||||
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
|
||||
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
List<Asset> successAssets = [];
|
||||
List<String> duplicatedAssets = [];
|
||||
|
||||
for (final result in response) {
|
||||
if (result.success) {
|
||||
successAssets
|
||||
.add(assets.firstWhere((asset) => asset.remoteId == result.id));
|
||||
} else if (!result.success &&
|
||||
result.error == BulkIdResponseDtoErrorEnum.duplicate) {
|
||||
duplicatedAssets.add(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
album.assets.addAll(successAssets);
|
||||
await _db.writeTxn(() => album.assets.save());
|
||||
|
||||
return AddAssetsResponse(
|
||||
alreadyInAlbum: duplicatedAssets,
|
||||
successfullyAdded: successAssets.length,
|
||||
);
|
||||
if (result != null && result.successfullyAdded > 0) {
|
||||
album.assets.addAll(assets);
|
||||
await _db.writeTxn(() => album.assets.save());
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
@ -314,8 +334,8 @@ class AlbumService {
|
||||
try {
|
||||
await _apiService.albumApi.removeAssetFromAlbum(
|
||||
album.remoteId!,
|
||||
RemoveAssetsDto(
|
||||
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
|
||||
BulkIdsDto(
|
||||
ids: assets.map((asset) => asset.remoteId!).toList(),
|
||||
),
|
||||
);
|
||||
album.assets.removeAll(assets);
|
||||
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
@ -63,9 +64,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
ref.invalidate(albumDetailProvider(album.id));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@ -99,7 +100,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
|
||||
Navigator.pop(context);
|
||||
selectionDisabled();
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(sharedAlbumDetailProvider(album.id));
|
||||
ref.invalidate(albumDetailProvider(album.id));
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
ImmichToast.show(
|
||||
|
@ -6,13 +6,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
@ -28,11 +27,20 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
FocusNode titleFocusNode = useFocusNode();
|
||||
final album = ref.watch(sharedAlbumDetailProvider(albumId));
|
||||
final album = ref.watch(albumDetailProvider(albumId));
|
||||
final userId = ref.watch(authenticationProvider).userId;
|
||||
final selection = useState<Set<Asset>>({});
|
||||
final multiSelectEnabled = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.invalidate(albumDetailProvider(albumId));
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
if (multiSelectEnabled.value) {
|
||||
selection.value = {};
|
||||
@ -77,8 +85,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
if (addAssetsResult != null &&
|
||||
addAssetsResult.successfullyAdded > 0) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
ref.invalidate(albumDetailProvider(albumId));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
@ -100,7 +107,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
.addAdditionalUserToAlbum(sharedUserIds, album);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.invalidate(sharedAlbumDetailProvider(album.id));
|
||||
ref.invalidate(albumDetailProvider(album.id));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
|
@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
@ -208,6 +209,9 @@ class HomePage extends HookConsumerWidget {
|
||||
),
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(albumDetailProvider(album.id));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
12
mobile/openapi/.openapi-generator/FILES
generated
12
mobile/openapi/.openapi-generator/FILES
generated
@ -8,8 +8,6 @@ doc/APIKeyCreateDto.md
|
||||
doc/APIKeyCreateResponseDto.md
|
||||
doc/APIKeyResponseDto.md
|
||||
doc/APIKeyUpdateDto.md
|
||||
doc/AddAssetsDto.md
|
||||
doc/AddAssetsResponseDto.md
|
||||
doc/AddUsersDto.md
|
||||
doc/AdminSignupResponseDto.md
|
||||
doc/AlbumApi.md
|
||||
@ -33,6 +31,7 @@ doc/AudioCodec.md
|
||||
doc/AuthDeviceResponseDto.md
|
||||
doc/AuthenticationApi.md
|
||||
doc/BulkIdResponseDto.md
|
||||
doc/BulkIdsDto.md
|
||||
doc/ChangePasswordDto.md
|
||||
doc/CheckDuplicateAssetDto.md
|
||||
doc/CheckDuplicateAssetResponseDto.md
|
||||
@ -78,7 +77,6 @@ doc/PersonApi.md
|
||||
doc/PersonResponseDto.md
|
||||
doc/PersonUpdateDto.md
|
||||
doc/QueueStatusDto.md
|
||||
doc/RemoveAssetsDto.md
|
||||
doc/SearchAlbumResponseDto.md
|
||||
doc/SearchApi.md
|
||||
doc/SearchAssetDto.md
|
||||
@ -150,8 +148,6 @@ lib/auth/authentication.dart
|
||||
lib/auth/http_basic_auth.dart
|
||||
lib/auth/http_bearer_auth.dart
|
||||
lib/auth/oauth.dart
|
||||
lib/model/add_assets_dto.dart
|
||||
lib/model/add_assets_response_dto.dart
|
||||
lib/model/add_users_dto.dart
|
||||
lib/model/admin_signup_response_dto.dart
|
||||
lib/model/album_count_response_dto.dart
|
||||
@ -176,6 +172,7 @@ lib/model/asset_type_enum.dart
|
||||
lib/model/audio_codec.dart
|
||||
lib/model/auth_device_response_dto.dart
|
||||
lib/model/bulk_id_response_dto.dart
|
||||
lib/model/bulk_ids_dto.dart
|
||||
lib/model/change_password_dto.dart
|
||||
lib/model/check_duplicate_asset_dto.dart
|
||||
lib/model/check_duplicate_asset_response_dto.dart
|
||||
@ -217,7 +214,6 @@ lib/model/people_update_item.dart
|
||||
lib/model/person_response_dto.dart
|
||||
lib/model/person_update_dto.dart
|
||||
lib/model/queue_status_dto.dart
|
||||
lib/model/remove_assets_dto.dart
|
||||
lib/model/search_album_response_dto.dart
|
||||
lib/model/search_asset_dto.dart
|
||||
lib/model/search_asset_response_dto.dart
|
||||
@ -260,8 +256,6 @@ lib/model/user_response_dto.dart
|
||||
lib/model/validate_access_token_response_dto.dart
|
||||
lib/model/video_codec.dart
|
||||
pubspec.yaml
|
||||
test/add_assets_dto_test.dart
|
||||
test/add_assets_response_dto_test.dart
|
||||
test/add_users_dto_test.dart
|
||||
test/admin_signup_response_dto_test.dart
|
||||
test/album_api_test.dart
|
||||
@ -290,6 +284,7 @@ test/audio_codec_test.dart
|
||||
test/auth_device_response_dto_test.dart
|
||||
test/authentication_api_test.dart
|
||||
test/bulk_id_response_dto_test.dart
|
||||
test/bulk_ids_dto_test.dart
|
||||
test/change_password_dto_test.dart
|
||||
test/check_duplicate_asset_dto_test.dart
|
||||
test/check_duplicate_asset_response_dto_test.dart
|
||||
@ -335,7 +330,6 @@ test/person_api_test.dart
|
||||
test/person_response_dto_test.dart
|
||||
test/person_update_dto_test.dart
|
||||
test/queue_status_dto_test.dart
|
||||
test/remove_assets_dto_test.dart
|
||||
test/search_album_response_dto_test.dart
|
||||
test/search_api_test.dart
|
||||
test/search_asset_dto_test.dart
|
||||
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AddAssetsResponseDto.md
generated
BIN
mobile/openapi/doc/AddAssetsResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AlbumApi.md
generated
BIN
mobile/openapi/doc/AlbumApi.md
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/doc/RemoveAssetsDto.md
generated
BIN
mobile/openapi/doc/RemoveAssetsDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/album_api.dart
generated
BIN
mobile/openapi/lib/api/album_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/add_assets_response_dto.dart
generated
BIN
mobile/openapi/lib/model/add_assets_response_dto.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/remove_assets_dto.dart
generated
BIN
mobile/openapi/lib/model/remove_assets_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/add_assets_response_dto_test.dart
generated
BIN
mobile/openapi/test/add_assets_response_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/album_api_test.dart
generated
BIN
mobile/openapi/test/album_api_test.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/test/remove_assets_dto_test.dart
generated
BIN
mobile/openapi/test/remove_assets_dto_test.dart
generated
Binary file not shown.
@ -278,7 +278,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RemoveAssetsDto"
|
||||
"$ref": "#/components/schemas/BulkIdsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -289,7 +289,10 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkIdResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -336,7 +339,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddAssetsDto"
|
||||
"$ref": "#/components/schemas/BulkIdsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -347,7 +350,10 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddAssetsResponseDto"
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkIdResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -4535,42 +4541,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddAssetsDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddAssetsResponseDto": {
|
||||
"properties": {
|
||||
"album": {
|
||||
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||
},
|
||||
"alreadyInAlbum": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"successfullyAdded": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"successfullyAdded",
|
||||
"alreadyInAlbum"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddUsersDto": {
|
||||
"properties": {
|
||||
"sharedUserIds": {
|
||||
@ -5093,6 +5063,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"BulkIdsDto": {
|
||||
"properties": {
|
||||
"ids": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ids"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ChangePasswordDto": {
|
||||
"properties": {
|
||||
"newPassword": {
|
||||
@ -6055,21 +6040,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RemoveAssetsDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SearchAlbumResponseDto": {
|
||||
"properties": {
|
||||
"count": {
|
||||
|
@ -12,9 +12,10 @@ export enum Permission {
|
||||
ASSET_DOWNLOAD = 'asset.download',
|
||||
|
||||
// ALBUM_CREATE = 'album.create',
|
||||
// ALBUM_READ = 'album.read',
|
||||
ALBUM_READ = 'album.read',
|
||||
ALBUM_UPDATE = 'album.update',
|
||||
ALBUM_DELETE = 'album.delete',
|
||||
ALBUM_REMOVE_ASSET = 'album.removeAsset',
|
||||
ALBUM_SHARE = 'album.share',
|
||||
ALBUM_DOWNLOAD = 'album.download',
|
||||
|
||||
@ -39,6 +40,16 @@ export class AccessCore {
|
||||
}
|
||||
}
|
||||
|
||||
async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) {
|
||||
for (const { permission, id } of permissions) {
|
||||
const hasAccess = await this.hasPermission(authUser, permission, id);
|
||||
if (hasAccess) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
|
||||
ids = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
@ -76,12 +87,11 @@ export class AccessCore {
|
||||
// TODO: fix this to not use authUser.id for shared link access control
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD: {
|
||||
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
|
||||
}
|
||||
case Permission.ALBUM_READ:
|
||||
return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
|
||||
|
||||
// case Permission.ALBUM_READ:
|
||||
// return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
|
||||
case Permission.ALBUM_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
|
||||
|
||||
default:
|
||||
return false;
|
||||
@ -122,8 +132,11 @@ export class AccessCore {
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
// case Permission.ALBUM_READ:
|
||||
// return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
case Permission.ALBUM_READ:
|
||||
return (
|
||||
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ALBUM_UPDATE:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
@ -140,13 +153,17 @@ export class AccessCore {
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ALBUM_REMOVE_ASSET:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.LIBRARY_READ:
|
||||
return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
|
||||
|
||||
case Permission.LIBRARY_DOWNLOAD:
|
||||
return authUser.id === id;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ export interface AlbumAssetCount {
|
||||
}
|
||||
|
||||
export interface IAlbumRepository {
|
||||
getById(id: string): Promise<AlbumEntity | null>;
|
||||
getByIds(ids: string[]): Promise<AlbumEntity[]>;
|
||||
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||
hasAsset(id: string, assetId: string): Promise<boolean>;
|
||||
@ -21,4 +22,5 @@ export interface IAlbumRepository {
|
||||
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
delete(album: AlbumEntity): Promise<void>;
|
||||
updateThumbnails(): Promise<number | undefined>;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
albumStub,
|
||||
assetStub,
|
||||
authStub,
|
||||
IAccessRepositoryMock,
|
||||
newAccessRepositoryMock,
|
||||
@ -11,7 +12,7 @@ import {
|
||||
userStub,
|
||||
} from '@test';
|
||||
import _ from 'lodash';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { BulkIdErrorReason, IAssetRepository } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IAlbumRepository } from './album.repository';
|
||||
@ -202,7 +203,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should prevent updating an album that does not exist', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.user1, 'invalid-id', {
|
||||
@ -224,7 +225,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.hasAsset.mockResolvedValue(false);
|
||||
|
||||
@ -241,7 +242,7 @@ describe(AlbumService.name, () => {
|
||||
it('should allow the owner to update the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await sut.update(authStub.admin, albumStub.oneAsset.id, {
|
||||
@ -263,7 +264,7 @@ describe(AlbumService.name, () => {
|
||||
describe('delete', () => {
|
||||
it('should throw an error for an album not found', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@ -274,7 +275,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should not let a shared user delete the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@ -285,7 +286,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should let the owner delete an album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await sut.delete(authStub.admin, albumStub.empty.id);
|
||||
|
||||
@ -305,7 +306,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error if the userId is already added', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@ -314,7 +315,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
|
||||
@ -324,7 +325,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should add valid shared users', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(userStub.user2);
|
||||
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
|
||||
@ -339,14 +340,14 @@ describe(AlbumService.name, () => {
|
||||
describe('removeUser', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a shared user from an owned album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
|
||||
@ -362,7 +363,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
|
||||
@ -373,7 +374,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
|
||||
|
||||
@ -386,7 +387,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
|
||||
|
||||
@ -399,7 +400,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow the owner to be removed', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@ -409,7 +410,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error for a user not in the album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@ -418,4 +419,301 @@ describe(AlbumService.name, () => {
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlbumInfo', () => {
|
||||
it('should get a shared album', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
await sut.get(authStub.admin, albumStub.oneAsset.id);
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id);
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
|
||||
});
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
|
||||
await sut.get(authStub.adminSharedLink, 'album-123');
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get a shared album via shared with user', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
|
||||
await sut.get(authStub.user1, 'album-123');
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123');
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
{ success: true, id: 'asset-2' },
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [{ id: 'asset-1' }],
|
||||
albumThumbnailAssetId: 'asset-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
{ success: true, id: 'asset-2' },
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared link user to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
{ success: true, id: 'asset-2' },
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.image, { id: 'asset-1' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
});
|
||||
|
||||
it('should skip duplicate assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets not shared with user', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
});
|
||||
|
||||
it('should not allow unauthorized access to the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow unauthorized shared link access to the album', async () => {
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should allow the owner to remove assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [],
|
||||
albumThumbnailAssetId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip assets not in the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets without user permission to remove', async () => {
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset the thumbnail if it is removed', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.withLocation],
|
||||
albumThumbnailAssetId: assetStub.withLocation.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// // it('removes assets from shared album (shared with auth user)', async () => {
|
||||
// // const albumEntity = _getOwnedSharedAlbum();
|
||||
// // albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
// // await expect(
|
||||
// // sut.removeAssetsFromAlbum(
|
||||
// // authUser,
|
||||
// // {
|
||||
// // ids: ['1'],
|
||||
// // },
|
||||
// // albumEntity.id,
|
||||
// // ),
|
||||
// // ).resolves.toBeUndefined();
|
||||
// // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
// // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
// // ids: ['1'],
|
||||
// // });
|
||||
// // });
|
||||
|
||||
// it('prevents removing assets from a not owned / shared album', async () => {
|
||||
// const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
|
||||
// const albumResponse: AddAssetsResponseDto = {
|
||||
// alreadyInAlbum: [],
|
||||
// successfullyAdded: 1,
|
||||
// };
|
||||
|
||||
// const albumId = albumEntity.id;
|
||||
|
||||
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
|
||||
|
||||
// await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// });
|
||||
});
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../access';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../index';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto';
|
||||
@ -37,7 +37,11 @@ export class AlbumService {
|
||||
}
|
||||
|
||||
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
await this.updateInvalidThumbnails();
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||
}
|
||||
|
||||
let albums: AlbumEntity[];
|
||||
if (assetId) {
|
||||
@ -73,15 +77,10 @@ export class AlbumService {
|
||||
);
|
||||
}
|
||||
|
||||
private async updateInvalidThumbnails(): Promise<number> {
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||
}
|
||||
|
||||
return invalidAlbumIds.length;
|
||||
async get(authUser: AuthUserDto, id: string) {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
await this.albumRepository.updateThumbnails();
|
||||
return mapAlbum(await this.findOrFail(id));
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
@ -107,7 +106,7 @@ export class AlbumService {
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
|
||||
|
||||
const album = await this.get(id);
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
if (dto.albumThumbnailAssetId) {
|
||||
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
|
||||
@ -130,7 +129,7 @@ export class AlbumService {
|
||||
async delete(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
|
||||
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
const album = await this.albumRepository.getById(id);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
@ -139,10 +138,88 @@ export class AlbumService {
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
|
||||
}
|
||||
|
||||
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
for (const id of dto.ids) {
|
||||
const hasAsset = album.assets.find((asset) => asset.id === id);
|
||||
if (hasAsset) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id);
|
||||
if (!hasAccess) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ id, success: true });
|
||||
album.assets.push({ id } as AssetEntity);
|
||||
}
|
||||
|
||||
const newAsset = results.find(({ success }) => success);
|
||||
if (newAsset) {
|
||||
await this.albumRepository.update({
|
||||
id,
|
||||
assets: album.assets,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
for (const id of dto.ids) {
|
||||
const hasAsset = album.assets.find((asset) => asset.id === id);
|
||||
if (!hasAsset) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasAny(authUser, [
|
||||
{ permission: Permission.ALBUM_REMOVE_ASSET, id },
|
||||
{ permission: Permission.ASSET_SHARE, id },
|
||||
]);
|
||||
if (!hasAccess) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ id, success: true });
|
||||
album.assets = album.assets.filter((asset) => asset.id !== id);
|
||||
if (album.albumThumbnailAssetId === id) {
|
||||
album.albumThumbnailAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const hasSuccess = results.find(({ success }) => success);
|
||||
if (hasSuccess) {
|
||||
await this.albumRepository.update({
|
||||
id,
|
||||
assets: album.assets,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
|
||||
|
||||
const album = await this.get(id);
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
for (const userId of dto.sharedUserIds) {
|
||||
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||
@ -172,7 +249,7 @@ export class AlbumService {
|
||||
userId = authUser.id;
|
||||
}
|
||||
|
||||
const album = await this.get(id);
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
if (album.ownerId === userId) {
|
||||
throw new BadRequestException('Cannot remove album owner');
|
||||
@ -195,8 +272,8 @@ export class AlbumService {
|
||||
});
|
||||
}
|
||||
|
||||
private async get(id: string) {
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
private async findOrFail(id: string) {
|
||||
const album = await this.albumRepository.getById(id);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ValidateUUID } from '../../domain.util';
|
||||
|
||||
/** @deprecated Use `BulkIdResponseDto` instead */
|
||||
export enum AssetIdErrorReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
@ -19,6 +21,11 @@ export enum BulkIdErrorReason {
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkIdResponseDto {
|
||||
id!: string;
|
||||
success!: boolean;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { ValidateUUID } from '@app/domain';
|
||||
|
||||
export class AddAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { ValidateUUID } from '@app/domain';
|
||||
|
||||
export class AddUsersDto {
|
||||
@ValidateUUID({ each: true })
|
||||
sharedUserIds!: string[];
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { ValidateUUID } from '@app/domain';
|
||||
|
||||
export class RemoveAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
@ -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;
|
||||
}
|
@ -5,7 +5,6 @@ import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AlbumModule } from './api-v1/album/album.module';
|
||||
import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository';
|
||||
import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller';
|
||||
import { AssetService } from './api-v1/asset/asset.service';
|
||||
@ -34,7 +33,6 @@ import {
|
||||
imports: [
|
||||
//
|
||||
DomainModule.register({ imports: [InfraModule] }),
|
||||
AlbumModule,
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
||||
],
|
||||
|
@ -3,6 +3,8 @@ import {
|
||||
AlbumCountResponseDto,
|
||||
AlbumService,
|
||||
AuthUserDto,
|
||||
BulkIdResponseDto,
|
||||
BulkIdsDto,
|
||||
CreateAlbumDto,
|
||||
UpdateAlbumDto,
|
||||
} from '@app/domain';
|
||||
@ -10,7 +12,7 @@ import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
|
||||
import { Authenticated, AuthUser } from '../app.guard';
|
||||
import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
|
||||
@ -36,6 +38,12 @@ export class AlbumController {
|
||||
return this.service.create(authUser, dto);
|
||||
}
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Get(':id')
|
||||
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
||||
return this.service.get(authUser, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
|
||||
return this.service.update(authUser, id, dto);
|
||||
@ -46,6 +54,25 @@ export class AlbumController {
|
||||
return this.service.delete(authUser, id);
|
||||
}
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Put(':id/assets')
|
||||
addAssetsToAlbum(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: BulkIdsDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.addAssets(authUser, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/assets')
|
||||
removeAssetFromAlbum(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Body() dto: BulkIdsDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.removeAssets(authUser, id, dto);
|
||||
}
|
||||
|
||||
@Put(':id/users')
|
||||
addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
|
||||
return this.service.addUsers(authUser, id, dto);
|
||||
|
@ -3,11 +3,35 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
import { dataSource } from '../database.config';
|
||||
import { AlbumEntity } from '../entities';
|
||||
import { AlbumEntity, AssetEntity } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumRepository implements IAlbumRepository {
|
||||
constructor(@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>) {}
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
|
||||
) {}
|
||||
|
||||
getById(id: string): Promise<AlbumEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
relations: {
|
||||
owner: true,
|
||||
sharedUsers: true,
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
sharedLinks: true,
|
||||
},
|
||||
order: {
|
||||
assets: {
|
||||
fileCreatedAt: 'DESC',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getByIds(ids: string[]): Promise<AlbumEntity[]> {
|
||||
return this.repository.find({
|
||||
@ -161,4 +185,46 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure all thumbnails for albums are updated by:
|
||||
* - Removing thumbnails from albums without assets
|
||||
* - Removing references of thumbnails to assets outside the album
|
||||
* - Setting a thumbnail when none is set and the album contains assets
|
||||
*
|
||||
* @returns Amount of updated album thumbnails or undefined when unknown
|
||||
*/
|
||||
async updateThumbnails(): Promise<number | undefined> {
|
||||
// Subquery for getting a new thumbnail.
|
||||
const newThumbnail = this.assetRepository
|
||||
.createQueryBuilder('assets')
|
||||
.select('albums_assets2.assetsId')
|
||||
.addFrom('albums_assets_assets', 'albums_assets2')
|
||||
.where('albums_assets2.assetsId = assets.id')
|
||||
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
|
||||
.orderBy('assets.fileCreatedAt', 'DESC')
|
||||
.limit(1);
|
||||
|
||||
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||
const albumHasAssets = dataSource
|
||||
.createQueryBuilder()
|
||||
.select('1')
|
||||
.from('albums_assets_assets', 'albums_assets')
|
||||
.where('"albums"."id" = "albums_assets"."albumsId"');
|
||||
|
||||
const albumContainsThumbnail = albumHasAssets
|
||||
.clone()
|
||||
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
|
||||
|
||||
const updateAlbums = this.repository
|
||||
.createQueryBuilder('albums')
|
||||
.update(AlbumEntity)
|
||||
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
|
||||
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
|
||||
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
|
||||
|
||||
const result = await updateAlbums.execute();
|
||||
|
||||
return result.affected;
|
||||
}
|
||||
}
|
||||
|
13
server/test/fixtures/album.stub.ts
vendored
13
server/test/fixtures/album.stub.ts
vendored
@ -69,6 +69,19 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
}),
|
||||
twoAssets: Object.freeze<AlbumEntity>({
|
||||
id: 'album-4a',
|
||||
albumName: 'Album with two assets',
|
||||
ownerId: authStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
assets: [assetStub.image, assetStub.withLocation],
|
||||
albumThumbnailAsset: assetStub.image,
|
||||
albumThumbnailAssetId: assetStub.image.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
}),
|
||||
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-5',
|
||||
albumName: 'Empty album with invalid thumbnail',
|
||||
|
@ -2,6 +2,7 @@ import { IAlbumRepository } from '@app/domain';
|
||||
|
||||
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
||||
return {
|
||||
getById: jest.fn(),
|
||||
getByIds: jest.fn(),
|
||||
getByAssetId: jest.fn(),
|
||||
getAssetCountForIds: jest.fn(),
|
||||
@ -15,5 +16,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
updateThumbnails: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
120
web/src/api/open-api/api.ts
generated
120
web/src/api/open-api/api.ts
generated
@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
|
||||
*/
|
||||
'name': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AddAssetsDto
|
||||
*/
|
||||
export interface AddAssetsDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof AddAssetsDto
|
||||
*/
|
||||
'assetIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AddAssetsResponseDto
|
||||
*/
|
||||
export interface AddAssetsResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {AlbumResponseDto}
|
||||
* @memberof AddAssetsResponseDto
|
||||
*/
|
||||
'album'?: AlbumResponseDto;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof AddAssetsResponseDto
|
||||
*/
|
||||
'alreadyInAlbum': Array<string>;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AddAssetsResponseDto
|
||||
*/
|
||||
'successfullyAdded': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = {
|
||||
|
||||
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface BulkIdsDto
|
||||
*/
|
||||
export interface BulkIdsDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof BulkIdsDto
|
||||
*/
|
||||
'ids': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
|
||||
*/
|
||||
'isPaused': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface RemoveAssetsDto
|
||||
*/
|
||||
export interface RemoveAssetsDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
* @memberof RemoveAssetsDto
|
||||
*/
|
||||
'assetIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@ -3679,16 +3641,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AddAssetsDto} addAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('addAssetsToAlbum', 'id', id)
|
||||
// verify required parameter 'addAssetsDto' is not null or undefined
|
||||
assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto)
|
||||
// verify required parameter 'bulkIdsDto' is not null or undefined
|
||||
assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto)
|
||||
const localVarPath = `/album/{id}/assets`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
@ -3722,7 +3684,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration)
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
@ -3999,15 +3961,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {RemoveAssetsDto} removeAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('removeAssetFromAlbum', 'id', id)
|
||||
// verify required parameter 'removeAssetsDto' is not null or undefined
|
||||
assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto)
|
||||
// verify required parameter 'bulkIdsDto' is not null or undefined
|
||||
assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto)
|
||||
const localVarPath = `/album/{id}/assets`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
@ -4037,7 +3999,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration)
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
@ -4151,13 +4113,13 @@ export const AlbumApiFp = function(configuration?: Configuration) {
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AddAssetsDto} addAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AddAssetsResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options);
|
||||
async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@ -4225,12 +4187,12 @@ export const AlbumApiFp = function(configuration?: Configuration) {
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {RemoveAssetsDto} removeAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options);
|
||||
async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@ -4268,13 +4230,13 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AddAssetsDto} addAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise<AddAssetsResponseDto> {
|
||||
return localVarFp.addAssetsToAlbum(id, addAssetsDto, key, options).then((request) => request(axios, basePath));
|
||||
addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
|
||||
return localVarFp.addAssetsToAlbum(id, bulkIdsDto, key, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@ -4335,12 +4297,12 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {RemoveAssetsDto} removeAssetsDto
|
||||
* @param {BulkIdsDto} bulkIdsDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise<AlbumResponseDto> {
|
||||
return localVarFp.removeAssetFromAlbum(id, removeAssetsDto, options).then((request) => request(axios, basePath));
|
||||
removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
|
||||
return localVarFp.removeAssetFromAlbum(id, bulkIdsDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@ -4380,10 +4342,10 @@ export interface AlbumApiAddAssetsToAlbumRequest {
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {AddAssetsDto}
|
||||
* @type {BulkIdsDto}
|
||||
* @memberof AlbumApiAddAssetsToAlbum
|
||||
*/
|
||||
readonly addAssetsDto: AddAssetsDto
|
||||
readonly bulkIdsDto: BulkIdsDto
|
||||
|
||||
/**
|
||||
*
|
||||
@ -4499,10 +4461,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest {
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {RemoveAssetsDto}
|
||||
* @type {BulkIdsDto}
|
||||
* @memberof AlbumApiRemoveAssetFromAlbum
|
||||
*/
|
||||
readonly removeAssetsDto: RemoveAssetsDto
|
||||
readonly bulkIdsDto: BulkIdsDto
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4562,7 +4524,7 @@ export class AlbumApi extends BaseAPI {
|
||||
* @memberof AlbumApi
|
||||
*/
|
||||
public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) {
|
||||
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4638,7 +4600,7 @@ export class AlbumApi extends BaseAPI {
|
||||
* @memberof AlbumApi
|
||||
*/
|
||||
public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) {
|
||||
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath));
|
||||
return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -92,6 +92,7 @@
|
||||
|
||||
let multiSelectAsset: Set<AssetResponseDto> = new Set();
|
||||
$: isMultiSelectionMode = multiSelectAsset.size > 0;
|
||||
$: isMultiSelectionUserOwned = Array.from(multiSelectAsset).every((asset) => asset.ownerId === currentUser?.id);
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
backUrl = from?.url.pathname ?? '/albums';
|
||||
@ -182,24 +183,24 @@
|
||||
const createAlbumHandler = async (event: CustomEvent) => {
|
||||
const { assets }: { assets: AssetResponseDto[] } = event.detail;
|
||||
try {
|
||||
const { data } = await api.albumApi.addAssetsToAlbum({
|
||||
const { data: results } = await api.albumApi.addAssetsToAlbum({
|
||||
id: album.id,
|
||||
addAssetsDto: {
|
||||
assetIds: assets.map((a) => a.id),
|
||||
},
|
||||
bulkIdsDto: { ids: assets.map((a) => a.id) },
|
||||
key: sharedLink?.key,
|
||||
});
|
||||
|
||||
if (data.album) {
|
||||
album = data.album;
|
||||
}
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: `Added ${count} asset${count === 1 ? '' : 's'}`,
|
||||
});
|
||||
|
||||
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
|
||||
album = data;
|
||||
|
||||
isShowAssetSelection = false;
|
||||
} catch (e) {
|
||||
console.error('Error [createAlbumHandler] ', e);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error creating album, check console for more details',
|
||||
});
|
||||
handleError(e, 'Error creating album');
|
||||
}
|
||||
};
|
||||
|
||||
@ -307,7 +308,7 @@
|
||||
{#if sharedLink?.allowDownload || !isPublicShared}
|
||||
<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
|
||||
{/if}
|
||||
{#if isOwned}
|
||||
{#if isOwned || isMultiSelectionUserOwned}
|
||||
<RemoveFromAlbum bind:album />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
|
@ -189,11 +189,8 @@
|
||||
isShowAlbumPicker = false;
|
||||
const album = event.detail.album;
|
||||
|
||||
addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
|
||||
if (dto.successfullyAdded === 1 && dto.album) {
|
||||
appearsInAlbums = [...appearsInAlbums, dto.album];
|
||||
}
|
||||
});
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
await getAllAlbums();
|
||||
};
|
||||
|
||||
const disableKeyDownEvent = () => {
|
||||
|
@ -44,10 +44,9 @@
|
||||
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
|
||||
showAlbumPicker = false;
|
||||
const album = event.detail.album;
|
||||
|
||||
const assetIds = Array.from(getAssets()).map((asset) => asset.id);
|
||||
|
||||
addAssetsToAlbum(album.id, assetIds).then(clearSelect);
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
clearSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -17,14 +17,20 @@
|
||||
|
||||
const removeFromAlbum = async () => {
|
||||
try {
|
||||
const { data } = await api.albumApi.removeAssetFromAlbum({
|
||||
const { data: results } = await api.albumApi.removeAssetFromAlbum({
|
||||
id: album.id,
|
||||
removeAssetsDto: {
|
||||
assetIds: Array.from(getAssets()).map((a) => a.id),
|
||||
},
|
||||
bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) },
|
||||
});
|
||||
|
||||
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
|
||||
album = data;
|
||||
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: `Removed ${count} asset${count === 1 ? '' : 's'}`,
|
||||
});
|
||||
|
||||
clearSelect();
|
||||
} catch (e) {
|
||||
console.error('Error [album-viewer] [removeAssetFromAlbum]', e);
|
||||
|
@ -1,23 +1,24 @@
|
||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||
import { downloadManager } from '$lib/stores/download';
|
||||
import { AddAssetsResponseDto, api, AssetApiGetDownloadInfoRequest, AssetResponseDto, DownloadResponseDto } from '@api';
|
||||
import { api, AssetApiGetDownloadInfoRequest, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto } from '@api';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export const addAssetsToAlbum = async (
|
||||
albumId: string,
|
||||
assetIds: Array<string>,
|
||||
key: string | undefined = undefined,
|
||||
): Promise<AddAssetsResponseDto> =>
|
||||
api.albumApi.addAssetsToAlbum({ id: albumId, addAssetsDto: { assetIds }, key }).then(({ data: dto }) => {
|
||||
if (dto.successfullyAdded > 0) {
|
||||
): Promise<BulkIdResponseDto[]> =>
|
||||
api.albumApi.addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetIds }, key }).then(({ data: results }) => {
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
if (count > 0) {
|
||||
// This might be 0 if the user tries to add an asset that is already in the album
|
||||
notificationController.show({
|
||||
message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
|
||||
type: NotificationType.Info,
|
||||
message: `Added ${count} asset${count === 1 ? '' : 's'}`,
|
||||
});
|
||||
}
|
||||
|
||||
return dto;
|
||||
return results;
|
||||
});
|
||||
|
||||
const downloadBlob = (data: Blob, filename: string) => {
|
||||
|
Loading…
Reference in New Issue
Block a user