diff --git a/mobile/lib/modules/sharing/models/shared_album.model.dart b/mobile/lib/modules/sharing/models/shared_album.model.dart index e1323dbcb2..c3f2b82c7f 100644 --- a/mobile/lib/modules/sharing/models/shared_album.model.dart +++ b/mobile/lib/modules/sharing/models/shared_album.model.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:collection/collection.dart'; -import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart'; -import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; +import 'package:immich_mobile/shared/models/user.model.dart'; class SharedAlbum { final String id; @@ -11,8 +11,8 @@ class SharedAlbum { final String albumName; final String createdAt; final String? albumThumbnailAssetId; - final List sharedUsers; - final List? sharedAssets; + final List sharedUsers; + final List? assets; SharedAlbum({ required this.id, @@ -21,7 +21,7 @@ class SharedAlbum { required this.createdAt, required this.albumThumbnailAssetId, required this.sharedUsers, - this.sharedAssets, + this.assets, }); SharedAlbum copyWith({ @@ -30,8 +30,8 @@ class SharedAlbum { String? albumName, String? createdAt, String? albumThumbnailAssetId, - List? sharedUsers, - List? sharedAssets, + List? sharedUsers, + List? assets, }) { return SharedAlbum( id: id ?? this.id, @@ -40,7 +40,7 @@ class SharedAlbum { createdAt: createdAt ?? this.createdAt, albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId, sharedUsers: sharedUsers ?? this.sharedUsers, - sharedAssets: sharedAssets ?? this.sharedAssets, + assets: assets ?? this.assets, ); } @@ -55,8 +55,8 @@ class SharedAlbum { result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId}); } result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()}); - if (sharedAssets != null) { - result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()}); + if (assets != null) { + result.addAll({'assets': assets!.map((x) => x.toMap()).toList()}); } return result; @@ -69,9 +69,9 @@ class SharedAlbum { albumName: map['albumName'] ?? '', createdAt: map['createdAt'] ?? '', albumThumbnailAssetId: map['albumThumbnailAssetId'], - sharedUsers: List.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))), - sharedAssets: map['sharedAssets'] != null - ? List.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x))) + sharedUsers: List.from(map['sharedUsers']?.map((x) => User.fromMap(x))), + assets: map['assets'] != null + ? List.from(map['assets']?.map((x) => ImmichAsset.fromMap(x))) : null, ); } @@ -82,7 +82,7 @@ class SharedAlbum { @override String toString() { - return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, sharedAssets: $sharedAssets)'; + return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, assets: $assets)'; } @override @@ -97,7 +97,7 @@ class SharedAlbum { other.createdAt == createdAt && other.albumThumbnailAssetId == albumThumbnailAssetId && listEquals(other.sharedUsers, sharedUsers) && - listEquals(other.sharedAssets, sharedAssets); + listEquals(other.assets, assets); } @override @@ -108,6 +108,6 @@ class SharedAlbum { createdAt.hashCode ^ albumThumbnailAssetId.hashCode ^ sharedUsers.hashCode ^ - sharedAssets.hashCode; + assets.hashCode; } } diff --git a/mobile/lib/modules/sharing/models/shared_asset.model.dart b/mobile/lib/modules/sharing/models/shared_asset.model.dart deleted file mode 100644 index e74584ce7c..0000000000 --- a/mobile/lib/modules/sharing/models/shared_asset.model.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:convert'; - -import 'package:immich_mobile/shared/models/immich_asset.model.dart'; - -class SharedAssets { - final ImmichAsset assetInfo; - - SharedAssets({ - required this.assetInfo, - }); - - SharedAssets copyWith({ - ImmichAsset? assetInfo, - }) { - return SharedAssets( - assetInfo: assetInfo ?? this.assetInfo, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'assetInfo': assetInfo.toMap()}); - - return result; - } - - factory SharedAssets.fromMap(Map map) { - return SharedAssets( - assetInfo: ImmichAsset.fromMap(map['assetInfo']), - ); - } - - String toJson() => json.encode(toMap()); - - factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source)); - - @override - String toString() => 'SharedAssets(assetInfo: $assetInfo)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is SharedAssets && other.assetInfo == assetInfo; - } - - @override - int get hashCode => assetInfo.hashCode; -} diff --git a/mobile/lib/modules/sharing/models/shared_user.model.dart b/mobile/lib/modules/sharing/models/shared_user.model.dart deleted file mode 100644 index 78e0398f75..0000000000 --- a/mobile/lib/modules/sharing/models/shared_user.model.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:convert'; - -import 'package:immich_mobile/shared/models/user_info.model.dart'; - -class SharedUsers { - final int id; - final String albumId; - final String sharedUserId; - final UserInfo userInfo; - - SharedUsers({ - required this.id, - required this.albumId, - required this.sharedUserId, - required this.userInfo, - }); - - SharedUsers copyWith({ - int? id, - String? albumId, - String? sharedUserId, - UserInfo? userInfo, - }) { - return SharedUsers( - id: id ?? this.id, - albumId: albumId ?? this.albumId, - sharedUserId: sharedUserId ?? this.sharedUserId, - userInfo: userInfo ?? this.userInfo, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'id': id}); - result.addAll({'albumId': albumId}); - result.addAll({'sharedUserId': sharedUserId}); - result.addAll({'userInfo': userInfo.toMap()}); - - return result; - } - - factory SharedUsers.fromMap(Map map) { - return SharedUsers( - id: map['id']?.toInt() ?? 0, - albumId: map['albumId'] ?? '', - sharedUserId: map['sharedUserId'] ?? '', - userInfo: UserInfo.fromMap(map['userInfo']), - ); - } - - String toJson() => json.encode(toMap()); - - factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source)); - - @override - String toString() { - return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is SharedUsers && - other.id == id && - other.albumId == albumId && - other.sharedUserId == sharedUserId && - other.userInfo == userInfo; - } - - @override - int get hashCode { - return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode; - } -} diff --git a/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart b/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart index 64a82dfe78..6d4646aa25 100644 --- a/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart +++ b/mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart @@ -1,8 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/shared/models/user_info.model.dart'; +import 'package:immich_mobile/shared/models/user.model.dart'; import 'package:immich_mobile/shared/services/user.service.dart'; -final suggestedSharedUsersProvider = FutureProvider.autoDispose>((ref) async { +final suggestedSharedUsersProvider = FutureProvider.autoDispose>((ref) async { UserService userService = UserService(); return await userService.getAllUsersInfo(); diff --git a/mobile/lib/modules/sharing/services/shared_album.service.dart b/mobile/lib/modules/sharing/services/shared_album.service.dart index 0af8788d4c..9f42aac7f4 100644 --- a/mobile/lib/modules/sharing/services/shared_album.service.dart +++ b/mobile/lib/modules/sharing/services/shared_album.service.dart @@ -12,9 +12,10 @@ class SharedAlbumService { Future> getAllSharedAlbum() async { try { - var res = await _networkService.getRequest(url: 'shared/allSharedAlbums'); + var res = await _networkService.getRequest(url: 'album?shared=true'); List decodedData = jsonDecode(res.toString()); - List result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e))); + List result = + List.from(decodedData.map((e) => SharedAlbum.fromMap(e))); return result; } catch (e) { @@ -24,9 +25,10 @@ class SharedAlbumService { return []; } - Future createSharedAlbum(String albumName, Set assets, List sharedUserIds) async { + Future createSharedAlbum(String albumName, Set assets, + List sharedUserIds) async { try { - var res = await _networkService.postRequest(url: 'shared/createAlbum', data: { + var res = await _networkService.postRequest(url: 'album', data: { "albumName": albumName, "sharedWithUserIds": sharedUserIds, "assetIds": assets.map((asset) => asset.id).toList(), @@ -45,7 +47,7 @@ class SharedAlbumService { Future getAlbumDetail(String albumId) async { try { - var res = await _networkService.getRequest(url: 'shared/$albumId'); + var res = await _networkService.getRequest(url: 'album/$albumId'); dynamic decodedData = jsonDecode(res.toString()); SharedAlbum result = SharedAlbum.fromMap(decodedData); @@ -55,9 +57,11 @@ class SharedAlbumService { } } - Future addAdditionalAssetToAlbum(Set assets, String albumId) async { + Future addAdditionalAssetToAlbum( + Set assets, String albumId) async { try { - var res = await _networkService.postRequest(url: 'shared/addAssets', data: { + var res = + await _networkService.putRequest(url: 'album/$albumId/assets', data: { "albumId": albumId, "assetIds": assets.map((asset) => asset.id).toList(), }); @@ -73,10 +77,11 @@ class SharedAlbumService { } } - Future addAdditionalUserToAlbum(List sharedUserIds, String albumId) async { + Future addAdditionalUserToAlbum( + List sharedUserIds, String albumId) async { try { - var res = await _networkService.postRequest(url: 'shared/addUsers', data: { - "albumId": albumId, + var res = + await _networkService.putRequest(url: 'album/$albumId/users', data: { "sharedUserIds": sharedUserIds, }); @@ -93,7 +98,7 @@ class SharedAlbumService { Future deleteAlbum(String albumId) async { try { - Response res = await _networkService.deleteRequest(url: 'shared/$albumId'); + Response res = await _networkService.deleteRequest(url: 'album/$albumId'); if (res.statusCode != 200) { return false; @@ -108,7 +113,8 @@ class SharedAlbumService { Future leaveAlbum(String albumId) async { try { - Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId'); + Response res = + await _networkService.deleteRequest(url: 'album/$albumId/user/me'); if (res.statusCode != 200) { return false; @@ -121,10 +127,11 @@ class SharedAlbumService { } } - Future removeAssetFromAlbum(String albumId, List assetIds) async { + Future removeAssetFromAlbum( + String albumId, List assetIds) async { try { - Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: { - "albumId": albumId, + Response res = await _networkService + .deleteRequest(url: 'album/$albumId/assets', data: { "assetIds": assetIds, }); @@ -139,10 +146,11 @@ class SharedAlbumService { } } - Future changeTitleAlbum(String albumId, String ownerId, String newAlbumTitle) async { + Future changeTitleAlbum( + String albumId, String ownerId, String newAlbumTitle) async { try { - Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: { - "albumId": albumId, + Response res = + await _networkService.patchRequest(url: 'album/$albumId/', data: { "ownerId": ownerId, "albumName": newAlbumTitle, }); diff --git a/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart b/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart index 33831a6587..81feedc66f 100644 --- a/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart +++ b/mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart @@ -86,63 +86,60 @@ class SelectionThumbnailImage extends HookConsumerWidget { } } }, - child: Hero( - tag: asset.id, - child: Stack( - children: [ - Container( - decoration: BoxDecoration(border: drawBorderColor()), - child: CachedNetworkImage( - cacheKey: "${asset.id}-${cacheKey.value}", - width: 150, - height: 150, - memCacheHeight: asset.type == 'IMAGE' ? 150 : 150, - fit: BoxFit.cover, - imageUrl: thumbnailRequestUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( - scale: 0.2, - child: CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) { - return Icon( - Icons.image_not_supported_outlined, - color: Theme.of(context).primaryColor, - ); - }, + child: Stack( + children: [ + Container( + decoration: BoxDecoration(border: drawBorderColor()), + child: CachedNetworkImage( + cacheKey: "${asset.id}-${cacheKey.value}", + width: 150, + height: 150, + memCacheHeight: asset.type == 'IMAGE' ? 150 : 150, + fit: BoxFit.cover, + imageUrl: thumbnailRequestUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + fadeInDuration: const Duration(milliseconds: 250), + progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( + scale: 0.2, + child: CircularProgressIndicator(value: downloadProgress.progress), ), + errorWidget: (context, url, error) { + return Icon( + Icons.image_not_supported_outlined, + color: Theme.of(context).primaryColor, + ); + }, ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Align( - alignment: Alignment.topLeft, - child: _buildSelectionIcon(asset), - ), + ), + Padding( + padding: const EdgeInsets.all(3.0), + child: Align( + alignment: Alignment.topLeft, + child: _buildSelectionIcon(asset), ), - asset.type == 'IMAGE' - ? Container() - : Positioned( - bottom: 5, - right: 5, - child: Row( - children: [ - Text( - asset.duration.toString().substring(0, 7), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - const Icon( - Icons.play_circle_outline_rounded, + ), + asset.type == 'IMAGE' + ? Container() + : Positioned( + bottom: 5, + right: 5, + child: Row( + children: [ + Text( + asset.duration.toString().substring(0, 7), + style: const TextStyle( color: Colors.white, + fontSize: 10, ), - ], - ), - ) - ], - ), + ), + const Icon( + Icons.play_circle_outline_rounded, + color: Colors.white, + ), + ], + ), + ) + ], ), ); } diff --git a/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart index 01971a6494..91f6336a73 100644 --- a/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart +++ b/mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart @@ -23,32 +23,29 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget { onTap: () { // debugPrint("View ${asset.id}"); }, - child: Hero( - tag: asset.id, - child: Stack( - children: [ - CachedNetworkImage( - cacheKey: "${asset.id}-${cacheKey.value}", - width: 500, - height: 500, - memCacheHeight: asset.type == 'IMAGE' ? 500 : 500, - fit: BoxFit.cover, - imageUrl: thumbnailRequestUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( - scale: 0.2, - child: CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) { - return Icon( - Icons.image_not_supported_outlined, - color: Theme.of(context).primaryColor, - ); - }, + child: Stack( + children: [ + CachedNetworkImage( + cacheKey: "${asset.id}-${cacheKey.value}", + width: 500, + height: 500, + memCacheHeight: asset.type == 'IMAGE' ? 500 : 500, + fit: BoxFit.cover, + imageUrl: thumbnailRequestUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + fadeInDuration: const Duration(milliseconds: 250), + progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( + scale: 0.2, + child: CircularProgressIndicator(value: downloadProgress.progress), ), - ], - ), + errorWidget: (context, url, error) { + return Icon( + Icons.image_not_supported_outlined, + color: Theme.of(context).primaryColor, + ); + }, + ), + ], ), ); } diff --git a/mobile/lib/modules/sharing/views/album_viewer_page.dart b/mobile/lib/modules/sharing/views/album_viewer_page.dart index ae8436abf5..653f66021b 100644 --- a/mobile/lib/modules/sharing/views/album_viewer_page.dart +++ b/mobile/lib/modules/sharing/views/album_viewer_page.dart @@ -36,10 +36,10 @@ class AlbumViewerPage extends HookConsumerWidget { /// Find out if the assets in album exist on the device /// If they exist, add to selected asset state to show they are already selected. void _onAddPhotosPressed(SharedAlbum albumInfo) async { - if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { + if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) { ref .watch(assetSelectionProvider.notifier) - .addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList()); + .addNewAssets(albumInfo.assets!.toList()); } ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); @@ -101,10 +101,10 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget _buildAlbumDateRange(SharedAlbum albumInfo) { - if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { + if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) { String startDate = ""; - DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt); - DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt); + DateTime parsedStartDate = DateTime.parse(albumInfo.assets!.first.createdAt); + DateTime parsedEndDate = DateTime.parse(albumInfo.assets!.last.createdAt); if (parsedStartDate.year == parsedEndDate.year) { startDate = DateFormat('LLL d').format(parsedStartDate); @@ -163,7 +163,7 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget _buildImageGrid(SharedAlbum albumInfo) { - if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) { + if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) { return SliverPadding( padding: const EdgeInsets.only(top: 10.0), sliver: SliverGrid( @@ -174,9 +174,9 @@ class AlbumViewerPage extends HookConsumerWidget { ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { - return AlbumViewerThumbnail(asset: albumInfo.sharedAssets![index].assetInfo); + return AlbumViewerThumbnail(asset: albumInfo.assets![index]); }, - childCount: albumInfo.sharedAssets?.length, + childCount: albumInfo.assets?.length, ), ), ); diff --git a/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart b/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart index 388af8180a..d01693aae6 100644 --- a/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart +++ b/mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart'; import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart'; -import 'package:immich_mobile/shared/models/user_info.model.dart'; +import 'package:immich_mobile/shared/models/user.model.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; class SelectAdditionalUserForSharingPage extends HookConsumerWidget { @@ -14,14 +14,14 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - AsyncValue> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); - final sharedUsersList = useState>({}); + AsyncValue> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); + final sharedUsersList = useState>({}); _addNewUsersHandler() { AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList()); } - _buildTileIcon(UserInfo user) { + _buildTileIcon(User user) { if (sharedUsersList.value.contains(user)) { return CircleAvatar( backgroundColor: Theme.of(context).primaryColor, @@ -38,7 +38,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget { } } - _buildUserList(List users) { + _buildUserList(List users) { List usersChip = []; for (var user in sharedUsersList.value) { @@ -120,7 +120,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget { body: suggestedShareUsers.when( data: (users) { for (var sharedUsers in albumInfo.sharedUsers) { - users.removeWhere((u) => u.id == sharedUsers.sharedUserId || u.id == albumInfo.ownerId); + users.removeWhere((u) => u.id == sharedUsers.id || u.id == albumInfo.ownerId); } return _buildUserList(users); diff --git a/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart b/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart index 0c1168376a..91d7971bad 100644 --- a/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart +++ b/mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart @@ -8,15 +8,15 @@ import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.da import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart'; import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/models/user_info.model.dart'; +import 'package:immich_mobile/shared/models/user.model.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; class SelectUserForSharingPage extends HookConsumerWidget { const SelectUserForSharingPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - final sharedUsersList = useState>({}); - AsyncValue> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); + final sharedUsersList = useState>({}); + AsyncValue> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider); _createSharedAlbum() async { var isSuccess = await SharedAlbumService().createSharedAlbum( @@ -36,7 +36,7 @@ class SelectUserForSharingPage extends HookConsumerWidget { const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album'))); } - _buildTileIcon(UserInfo user) { + _buildTileIcon(User user) { if (sharedUsersList.value.contains(user)) { return CircleAvatar( backgroundColor: Theme.of(context).primaryColor, @@ -53,7 +53,7 @@ class SelectUserForSharingPage extends HookConsumerWidget { } } - _buildUserList(List users) { + _buildUserList(List users) { List usersChip = []; for (var user in sharedUsersList.value) { diff --git a/mobile/lib/shared/models/immich_asset.model.dart b/mobile/lib/shared/models/immich_asset.model.dart index 5097a36829..95ceab6ce0 100644 --- a/mobile/lib/shared/models/immich_asset.model.dart +++ b/mobile/lib/shared/models/immich_asset.model.dart @@ -1,6 +1,8 @@ import 'dart:convert'; -class ImmichAsset { +import 'package:equatable/equatable.dart'; + +class ImmichAsset extends Equatable { final String id; final String deviceAssetId; final String userId; @@ -13,7 +15,7 @@ class ImmichAsset { final String originalPath; final String resizePath; - ImmichAsset({ + const ImmichAsset({ required this.id, required this.deviceAssetId, required this.userId, @@ -56,19 +58,23 @@ class ImmichAsset { } Map toMap() { - return { - 'id': id, - 'deviceAssetId': deviceAssetId, - 'userId': userId, - 'deviceId': deviceId, - 'type': type, - 'createdAt': createdAt, - 'modifiedAt': modifiedAt, - 'isFavorite': isFavorite, - 'duration': duration, - 'originalPath': originalPath, - 'resizePath': resizePath, - }; + final result = {}; + + result.addAll({'id': id}); + result.addAll({'deviceAssetId': deviceAssetId}); + result.addAll({'userId': userId}); + result.addAll({'deviceId': deviceId}); + result.addAll({'type': type}); + result.addAll({'createdAt': createdAt}); + result.addAll({'modifiedAt': modifiedAt}); + result.addAll({'isFavorite': isFavorite}); + if (duration != null) { + result.addAll({'duration': duration}); + } + result.addAll({'originalPath': originalPath}); + result.addAll({'resizePath': resizePath}); + + return result; } factory ImmichAsset.fromMap(Map map) { @@ -97,35 +103,7 @@ class ImmichAsset { } @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is ImmichAsset && - other.id == id && - other.deviceAssetId == deviceAssetId && - other.userId == userId && - other.deviceId == deviceId && - other.type == type && - other.createdAt == createdAt && - other.modifiedAt == modifiedAt && - other.isFavorite == isFavorite && - other.duration == duration && - other.originalPath == originalPath && - other.resizePath == resizePath; - } - - @override - int get hashCode { - return id.hashCode ^ - deviceAssetId.hashCode ^ - userId.hashCode ^ - deviceId.hashCode ^ - type.hashCode ^ - createdAt.hashCode ^ - modifiedAt.hashCode ^ - isFavorite.hashCode ^ - duration.hashCode ^ - originalPath.hashCode ^ - resizePath.hashCode; + List get props { + return [id]; } } diff --git a/mobile/lib/shared/models/user_info.model.dart b/mobile/lib/shared/models/user.model.dart similarity index 58% rename from mobile/lib/shared/models/user_info.model.dart rename to mobile/lib/shared/models/user.model.dart index dc5203f9eb..e8a29cf8be 100644 --- a/mobile/lib/shared/models/user_info.model.dart +++ b/mobile/lib/shared/models/user.model.dart @@ -1,25 +1,33 @@ import 'dart:convert'; -class UserInfo { +class User { final String id; final String email; final String createdAt; + final String firstName; + final String lastName; - UserInfo({ + User({ required this.id, required this.email, required this.createdAt, + required this.firstName, + required this.lastName, }); - UserInfo copyWith({ + User copyWith({ String? id, String? email, String? createdAt, + String? firstName, + String? lastName, }) { - return UserInfo( + return User( id: id ?? this.id, email: email ?? this.email, createdAt: createdAt ?? this.createdAt, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, ); } @@ -33,17 +41,19 @@ class UserInfo { return result; } - factory UserInfo.fromMap(Map map) { - return UserInfo( + factory User.fromMap(Map map) { + return User( id: map['id'] ?? '', email: map['email'] ?? '', createdAt: map['createdAt'] ?? '', + firstName: map['firstName'] ?? '', + lastName: map['lastName'] ?? '', ); } String toJson() => json.encode(toMap()); - factory UserInfo.fromJson(String source) => UserInfo.fromMap(json.decode(source)); + factory User.fromJson(String source) => User.fromMap(json.decode(source)); @override String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)'; @@ -52,7 +62,12 @@ class UserInfo { bool operator ==(Object other) { if (identical(this, other)) return true; - return other is UserInfo && other.id == id && other.email == email && other.createdAt == createdAt; + return other is User && + other.id == id && + other.email == email && + other.createdAt == createdAt && + other.firstName == firstName && + other.lastName == lastName; } @override diff --git a/mobile/lib/shared/services/network.service.dart b/mobile/lib/shared/services/network.service.dart index 52260e494c..6998cc1595 100644 --- a/mobile/lib/shared/services/network.service.dart +++ b/mobile/lib/shared/services/network.service.dart @@ -22,7 +22,7 @@ class NetworkService { } on DioError catch (e) { debugPrint("DioError: ${e.response}"); } catch (e) { - debugPrint("ERROR getRequest: ${e.toString()}"); + debugPrint("ERROR deleteRequest: ${e.toString()}"); } } @@ -78,7 +78,26 @@ class NetworkService { debugPrint("DioError: ${e.response}"); return null; } catch (e) { - debugPrint("ERROR BackupService: $e"); + debugPrint("ERROR PostRequest: $e"); + return null; + } + } + + Future putRequest({required String url, dynamic data}) async { + try { + var dio = Dio(); + dio.interceptors.add(AuthenticatedRequestInterceptor()); + + var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); + String validUrl = Uri.parse('$savedEndpoint/$url').toString(); + Response res = await dio.put(validUrl, data: data); + + return res; + } on DioError catch (e) { + debugPrint("DioError: ${e.response}"); + return null; + } catch (e) { + debugPrint("ERROR PutRequest: $e"); return null; } } @@ -97,7 +116,7 @@ class NetworkService { } on DioError catch (e) { debugPrint("DioError: ${e.response}"); } catch (e) { - debugPrint("ERROR BackupService: $e"); + debugPrint("ERROR PatchRequest: $e"); } } @@ -122,7 +141,7 @@ class NetworkService { debugPrint("[PING SERVER] DioError: ${e.response} - $e"); return false; } catch (e) { - debugPrint("ERROR BackupService: $e"); + debugPrint("ERROR PingServer: $e"); return false; } } diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart index ca259c5f0f..10c5ef4698 100644 --- a/mobile/lib/shared/services/user.service.dart +++ b/mobile/lib/shared/services/user.service.dart @@ -6,7 +6,7 @@ import 'package:hive/hive.dart'; import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/shared/models/upload_profile_image_repsonse.model.dart'; -import 'package:immich_mobile/shared/models/user_info.model.dart'; +import 'package:immich_mobile/shared/models/user.model.dart'; import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/utils/dio_http_interceptor.dart'; import 'package:immich_mobile/utils/files_helper.dart'; @@ -15,11 +15,11 @@ import 'package:http_parser/http_parser.dart'; class UserService { final NetworkService _networkService = NetworkService(); - Future> getAllUsersInfo() async { + Future> getAllUsersInfo() async { try { Response res = await _networkService.getRequest(url: 'user'); List decodedData = jsonDecode(res.toString()); - List result = List.from(decodedData.map((e) => UserInfo.fromMap(e))); + List result = List.from(decodedData.map((e) => User.fromMap(e))); return result; } catch (e) { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d06aedafea..32002114ec 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1029,62 +1029,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.4" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.3" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.17" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.17" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.5" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.11" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" uuid: dependency: transitive description: diff --git a/server/apps/immich/src/api-v1/album/album-repository.ts b/server/apps/immich/src/api-v1/album/album-repository.ts new file mode 100644 index 0000000000..3b33c2f225 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/album-repository.ts @@ -0,0 +1,228 @@ +import { AlbumEntity } from '@app/database/entities/album.entity'; +import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity'; +import { UserAlbumEntity } from '@app/database/entities/user-album.entity'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { getConnection, Repository, SelectQueryBuilder } from 'typeorm'; +import { AddAssetsDto } from './dto/add-assets.dto'; +import { AddUsersDto } from './dto/add-users.dto'; +import { CreateAlbumDto } from './dto/create-album.dto'; +import { GetAlbumsDto } from './dto/get-albums.dto'; +import { RemoveAssetsDto } from './dto/remove-assets.dto'; +import { UpdateAlbumDto } from './dto/update-album.dto'; + +export interface IAlbumRepository { + create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise; + getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise; + get(albumId: string): Promise; + delete(album: AlbumEntity): Promise; + addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise; + removeUser(album: AlbumEntity, userId: string): Promise; + removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise; + addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise; + updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise; +} + +export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY'; + +@Injectable() +export class AlbumRepository implements IAlbumRepository { + constructor( + @InjectRepository(AlbumEntity) + private albumRepository: Repository, + + @InjectRepository(AssetAlbumEntity) + private assetAlbumRepository: Repository, + + @InjectRepository(UserAlbumEntity) + private userAlbumRepository: Repository, + ) {} + + async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise { + return await getConnection().transaction(async (transactionalEntityManager) => { + // Create album entity + const newAlbum = new AlbumEntity(); + newAlbum.ownerId = ownerId; + newAlbum.albumName = createAlbumDto.albumName; + + const album = await transactionalEntityManager.save(newAlbum); + + // Add shared users + if (createAlbumDto.sharedWithUserIds?.length) { + for (const sharedUserId of createAlbumDto.sharedWithUserIds) { + const newSharedUser = new UserAlbumEntity(); + newSharedUser.albumId = album.id; + newSharedUser.sharedUserId = sharedUserId; + + await transactionalEntityManager.save(newSharedUser); + } + } + + // Add shared assets + const newRecords: AssetAlbumEntity[] = []; + + if (createAlbumDto.assetIds?.length) { + for (const assetId of createAlbumDto.assetIds) { + const newAssetAlbum = new AssetAlbumEntity(); + newAssetAlbum.assetId = assetId; + newAssetAlbum.albumId = album.id; + + newRecords.push(newAssetAlbum); + } + } + + if (!album.albumThumbnailAssetId && newRecords.length > 0) { + album.albumThumbnailAssetId = newRecords[0].assetId; + await transactionalEntityManager.save(album); + } + + await transactionalEntityManager.save([...newRecords]); + + return album; + }); + return; + } + + getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise { + const filteringByShared = typeof getAlbumsDto.shared == 'boolean'; + const userId = ownerId; + let query = this.albumRepository.createQueryBuilder('album'); + + const getSharedAlbumIdsSubQuery = (qb: SelectQueryBuilder) => { + return qb + .subQuery() + .select('albumSub.id') + .from(AlbumEntity, 'albumSub') + .innerJoin('albumSub.sharedUsers', 'userAlbumSub') + .where('albumSub.ownerId = :ownerId', { ownerId: userId }) + .getQuery(); + }; + + if (filteringByShared) { + if (getAlbumsDto.shared) { + // shared albums + query = query + .innerJoinAndSelect('album.sharedUsers', 'sharedUser') + .innerJoinAndSelect('sharedUser.userInfo', 'userInfo') + .where((qb) => { + // owned and shared with other users + const subQuery = getSharedAlbumIdsSubQuery(qb); + return `album.id IN ${subQuery}`; + }) + .orWhere((qb) => { + // shared with userId + const subQuery = qb + .subQuery() + .select('userAlbum.albumId') + .from(UserAlbumEntity, 'userAlbum') + .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId }) + .getQuery(); + return `album.id IN ${subQuery}`; + }); + } else { + // owned, not shared albums + query = query.where('album.ownerId = :ownerId', { ownerId: userId }).andWhere((qb) => { + const subQuery = getSharedAlbumIdsSubQuery(qb); + return `album.id NOT IN ${subQuery}`; + }); + } + } else { + // owned and shared with userId + query = query + .leftJoinAndSelect('album.sharedUsers', 'sharedUser') + .leftJoinAndSelect('sharedUser.userInfo', 'userInfo') + .where('album.ownerId = :ownerId', { ownerId: userId }) + .orWhere((qb) => { + const subQuery = qb + .subQuery() + .select('userAlbum.albumId') + .from(UserAlbumEntity, 'userAlbum') + .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId }) + .getQuery(); + return `album.id IN ${subQuery}`; + }); + } + return query.orderBy('album.createdAt', 'DESC').getMany(); + } + + async get(albumId: string): Promise { + const album = await this.albumRepository.findOne({ + where: { id: albumId }, + relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'], + }); + + if (!album) { + return; + } + // TODO: sort in query + const sortedSharedAsset = album.assets.sort( + (a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(), + ); + + album.assets = sortedSharedAsset; + + return album; + } + + async delete(album: AlbumEntity): Promise { + await this.albumRepository.delete({ id: album.id, ownerId: album.ownerId }); + } + + async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise { + const newRecords: UserAlbumEntity[] = []; + + for (const sharedUserId of addUsersDto.sharedUserIds) { + const newEntity = new UserAlbumEntity(); + newEntity.albumId = album.id; + newEntity.sharedUserId = sharedUserId; + + newRecords.push(newEntity); + } + + await this.userAlbumRepository.save([...newRecords]); + return this.get(album.id); + } + + async removeUser(album: AlbumEntity, userId: string): Promise { + await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId }); + } + + async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise { + let deleteAssetCount = 0; + // TODO: should probably do a single delete query? + for (const assetId of removeAssetsDto.assetIds) { + const res = await this.assetAlbumRepository.delete({ albumId: album.id, assetId: assetId }); + if (res.affected == 1) deleteAssetCount++; + } + + // TODO: No need to return boolean if using a singe delete query + return deleteAssetCount == removeAssetsDto.assetIds.length; + } + + async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise { + const newRecords: AssetAlbumEntity[] = []; + + for (const assetId of addAssetsDto.assetIds) { + const newAssetAlbum = new AssetAlbumEntity(); + newAssetAlbum.assetId = assetId; + newAssetAlbum.albumId = album.id; + + newRecords.push(newAssetAlbum); + } + + // Add album thumbnail if not exist. + if (!album.albumThumbnailAssetId && newRecords.length > 0) { + album.albumThumbnailAssetId = newRecords[0].assetId; + await this.albumRepository.save(album); + } + + await this.assetAlbumRepository.save([...newRecords]); + return this.get(album.id); + } + + updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise { + album.albumName = updateAlbumDto.albumName; + + return this.albumRepository.save(album); + } +} diff --git a/server/apps/immich/src/api-v1/album/album.controller.ts b/server/apps/immich/src/api-v1/album/album.controller.ts new file mode 100644 index 0000000000..9eeb42080f --- /dev/null +++ b/server/apps/immich/src/api-v1/album/album.controller.ts @@ -0,0 +1,105 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + ValidationPipe, + ParseUUIDPipe, + Put, + Query, +} from '@nestjs/common'; +import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe'; +import { AlbumService } from './album.service'; +import { CreateAlbumDto } from './dto/create-album.dto'; +import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; +import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; +import { AddAssetsDto } from './dto/add-assets.dto'; +import { AddUsersDto } from './dto/add-users.dto'; +import { RemoveAssetsDto } from './dto/remove-assets.dto'; +import { UpdateAlbumDto } from './dto/update-album.dto'; +import { GetAlbumsDto } from './dto/get-albums.dto'; + +// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe. +@UseGuards(JwtAuthGuard) +@Controller('album') +export class AlbumController { + constructor(private readonly albumService: AlbumService) {} + + @Post() + async create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) { + return this.albumService.create(authUser, createAlbumDto); + } + + @Put('/:albumId/users') + async addUsers( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) addUsersDto: AddUsersDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + ) { + return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId); + } + + @Put('/:albumId/assets') + async addAssets( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) addAssetsDto: AddAssetsDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + ) { + return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId); + } + + @Get() + async getAllAlbums( + @GetAuthUser() authUser: AuthUserDto, + @Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto, + ) { + return this.albumService.getAllAlbums(authUser, query); + } + + @Get('/:albumId') + async getAlbumInfo( + @GetAuthUser() authUser: AuthUserDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + ) { + return this.albumService.getAlbumInfo(authUser, albumId); + } + + @Delete('/:albumId/assets') + async removeAssetFromAlbum( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + ) { + return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId); + } + + @Delete('/:albumId') + async deleteAlbum( + @GetAuthUser() authUser: AuthUserDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + ) { + return this.albumService.deleteAlbum(authUser, albumId); + } + + @Delete('/:albumId/user/:userId') + async removeUserFromAlbum( + @GetAuthUser() authUser: AuthUserDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + @Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string, + ) { + return this.albumService.removeUserFromAlbum(authUser, albumId, userId); + } + + @Patch('/:albumId') + async updateAlbumInfo( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto, + @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string, + ) { + return this.albumService.updateAlbumTitle(authUser, updateAlbumInfoDto, albumId); + } +} diff --git a/server/apps/immich/src/api-v1/album/album.module.ts b/server/apps/immich/src/api-v1/album/album.module.ts new file mode 100644 index 0000000000..a19d041e5b --- /dev/null +++ b/server/apps/immich/src/api-v1/album/album.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { AlbumService } from './album.service'; +import { AlbumController } from './album.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetEntity } from '@app/database/entities/asset.entity'; +import { UserEntity } from '@app/database/entities/user.entity'; +import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity'; +import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity'; +import { UserAlbumEntity } from '@app/database/entities/user-album.entity'; +import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])], + controllers: [AlbumController], + providers: [ + AlbumService, + { + provide: ALBUM_REPOSITORY, + useClass: AlbumRepository, + }, + ], +}) +export class AlbumModule {} diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts new file mode 100644 index 0000000000..7a671fc720 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -0,0 +1,414 @@ +import { AlbumService } from './album.service'; +import { IAlbumRepository } from './album-repository'; +import { AuthUserDto } from '../../decorators/auth-user.decorator'; +import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { AlbumEntity } from '@app/database/entities/album.entity'; +import { AlbumResponseDto } from './response-dto/album-response.dto'; + +describe('Album service', () => { + let sut: AlbumService; + let albumRepositoryMock: jest.Mocked; + const authUser: AuthUserDto = Object.freeze({ + id: '1111', + email: 'auth@test.com', + }); + const albumId = '0001'; + const sharedAlbumOwnerId = '2222'; + const sharedAlbumSharedAlsoWithId = '3333'; + const ownedAlbumSharedWithId = '4444'; + + const _getOwnedAlbum = () => { + const albumEntity = new AlbumEntity(); + albumEntity.ownerId = authUser.id; + albumEntity.id = albumId; + albumEntity.albumName = 'name'; + albumEntity.createdAt = 'date'; + albumEntity.sharedUsers = []; + albumEntity.assets = []; + + return albumEntity; + }; + + const _getOwnedSharedAlbum = () => { + const albumEntity = new AlbumEntity(); + albumEntity.ownerId = authUser.id; + albumEntity.id = albumId; + albumEntity.albumName = 'name'; + albumEntity.createdAt = 'date'; + albumEntity.assets = []; + albumEntity.sharedUsers = [ + { + id: '99', + albumId, + sharedUserId: ownedAlbumSharedWithId, + //@ts-expect-error Partial stub + albumInfo: {}, + //@ts-expect-error Partial stub + userInfo: { + id: ownedAlbumSharedWithId, + }, + }, + ]; + + return albumEntity; + }; + + const _getSharedWithAuthUserAlbum = () => { + const albumEntity = new AlbumEntity(); + albumEntity.ownerId = sharedAlbumOwnerId; + albumEntity.id = albumId; + albumEntity.albumName = 'name'; + albumEntity.createdAt = 'date'; + albumEntity.assets = []; + albumEntity.sharedUsers = [ + { + id: '99', + albumId, + sharedUserId: authUser.id, + //@ts-expect-error Partial stub + albumInfo: {}, + //@ts-expect-error Partial stub + userInfo: { + id: authUser.id, + }, + }, + { + id: '98', + albumId, + sharedUserId: sharedAlbumSharedAlsoWithId, + //@ts-expect-error Partial stub + albumInfo: {}, + //@ts-expect-error Partial stub + userInfo: { + id: sharedAlbumSharedAlsoWithId, + }, + }, + ]; + + return albumEntity; + }; + + const _getNotOwnedNotSharedAlbum = () => { + const albumEntity = new AlbumEntity(); + albumEntity.ownerId = '5555'; + albumEntity.id = albumId; + albumEntity.albumName = 'name'; + albumEntity.createdAt = 'date'; + albumEntity.sharedUsers = []; + albumEntity.assets = []; + + return albumEntity; + }; + + beforeAll(() => { + albumRepositoryMock = { + addAssets: jest.fn(), + addSharedUsers: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + get: jest.fn(), + getList: jest.fn(), + removeAssets: jest.fn(), + removeUser: jest.fn(), + updateAlbum: jest.fn(), + }; + sut = new AlbumService(albumRepositoryMock); + }); + + it('creates album', async () => { + const albumEntity = _getOwnedAlbum(); + albumRepositoryMock.create.mockImplementation(() => Promise.resolve(albumEntity)); + + const result = await sut.create(authUser, { + albumName: albumEntity.albumName, + }); + + expect(result.id).toEqual(albumEntity.id); + expect(result.albumName).toEqual(albumEntity.albumName); + }); + + it('gets list of albums for auth user', async () => { + const ownedAlbum = _getOwnedAlbum(); + const ownedSharedAlbum = _getOwnedSharedAlbum(); + const sharedWithMeAlbum = _getSharedWithAuthUserAlbum(); + const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum]; + + albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums)); + + const result = await sut.getAllAlbums(authUser, {}); + expect(result).toHaveLength(3); + expect(result[0].id).toEqual(ownedAlbum.id); + expect(result[1].id).toEqual(ownedSharedAlbum.id); + expect(result[2].id).toEqual(sharedWithMeAlbum.id); + }); + + it('gets an owned album', async () => { + const ownerId = authUser.id; + const albumId = '0001'; + + const albumEntity = _getOwnedAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + + const expectedResult: AlbumResponseDto = { + albumName: 'name', + albumThumbnailAssetId: undefined, + createdAt: 'date', + id: '0001', + ownerId, + shared: false, + assets: [], + sharedUsers: [], + }; + await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult); + }); + + it('gets a shared album', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + + const result = await sut.getAlbumInfo(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)); + await expect(sut.getAlbumInfo(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('throws a not found exception if the album is not found', async () => { + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(undefined)); + await expect(sut.getAlbumInfo(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('deletes an owned album', async () => { + const albumEntity = _getOwnedAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.delete.mockImplementation(() => Promise.resolve()); + await sut.deleteAlbum(authUser, albumId); + expect(albumRepositoryMock.delete).toHaveBeenCalledTimes(1); + expect(albumRepositoryMock.delete).toHaveBeenCalledWith(albumEntity); + }); + + it('prevents deleting a shared album (shared with auth user)', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + await expect(sut.deleteAlbum(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('removes a shared user from an owned album', async () => { + const albumEntity = _getOwnedSharedAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve()); + await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, ownedAlbumSharedWithId)).resolves.toBeUndefined(); + expect(albumRepositoryMock.removeUser).toHaveBeenCalledTimes(1); + expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, ownedAlbumSharedWithId); + }); + + it('prevents removing a shared user from a not owned album (shared with auth user)', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + const albumId = albumEntity.id; + const userIdToRemove = sharedAlbumSharedAlsoWithId; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + + await expect(sut.removeUserFromAlbum(authUser, albumId, userIdToRemove)).rejects.toBeInstanceOf(ForbiddenException); + expect(albumRepositoryMock.removeUser).not.toHaveBeenCalled(); + }); + + it('removes itself from a shared album', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve()); + + await sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id); + expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1); + expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id); + }); + + it('removes itself from a shared album using "me" as id', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve()); + + await sut.removeUserFromAlbum(authUser, albumEntity.id, 'me'); + expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1); + expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id); + }); + + it('prevents removing itself from a owned album', async () => { + const albumEntity = _getOwnedAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + + await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('updates a owned album', async () => { + const albumEntity = _getOwnedAlbum(); + const albumId = albumEntity.id; + const updatedAlbumName = 'new album name'; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.updateAlbum.mockImplementation(() => + Promise.resolve({ ...albumEntity, albumName: updatedAlbumName }), + ); + + const result = await sut.updateAlbumTitle( + authUser, + { + albumName: updatedAlbumName, + ownerId: 'this is not used and will be removed', + }, + albumId, + ); + + expect(result.id).toEqual(albumId); + expect(result.albumName).toEqual(updatedAlbumName); + expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1); + expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, { + albumName: updatedAlbumName, + ownerId: 'this is not used and will be removed', + }); + }); + + it('prevents updating a not owned album (shared with auth user)', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + const albumId = albumEntity.id; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + + await expect( + sut.updateAlbumTitle( + authUser, + { + albumName: 'new album name', + ownerId: 'this is not used and will be removed', + }, + albumId, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('adds assets to owned album', async () => { + const albumEntity = _getOwnedAlbum(); + const albumId = albumEntity.id; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumEntity)); + + const result = await sut.addAssetsToAlbum( + authUser, + { + assetIds: ['1'], + }, + albumId, + ); + + // TODO: stub and expect album rendered + expect(result.id).toEqual(albumId); + }); + + it('adds assets to shared album (shared with auth user)', async () => { + const albumEntity = _getSharedWithAuthUserAlbum(); + const albumId = albumEntity.id; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumEntity)); + + const result = await sut.addAssetsToAlbum( + authUser, + { + assetIds: ['1'], + }, + albumId, + ); + + // TODO: stub and expect album rendered + expect(result.id).toEqual(albumId); + }); + + it('prevents adding assets to a not owned / shared album', async () => { + const albumEntity = _getNotOwnedNotSharedAlbum(); + const albumId = albumEntity.id; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumEntity)); + + expect( + sut.addAssetsToAlbum( + authUser, + { + assetIds: ['1'], + }, + albumId, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('removes assets from owned album', async () => { + const albumEntity = _getOwnedAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true)); + + await expect( + sut.removeAssetsFromAlbum( + authUser, + { + assetIds: ['1'], + }, + albumEntity.id, + ), + ).resolves.toBeUndefined(); + expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); + expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { + assetIds: ['1'], + }); + }); + + it('removes assets from shared album (shared with auth user)', async () => { + const albumEntity = _getOwnedSharedAlbum(); + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true)); + + 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 albumId = albumEntity.id; + + albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumEntity)); + + expect( + sut.removeAssetsFromAlbum( + authUser, + { + assetIds: ['1'], + }, + albumId, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts new file mode 100644 index 0000000000..d5ee4ea763 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -0,0 +1,113 @@ +import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { AuthUserDto } from '../../decorators/auth-user.decorator'; +import { AddAssetsDto } from './dto/add-assets.dto'; +import { CreateAlbumDto } from './dto/create-album.dto'; +import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity'; +import { AddUsersDto } from './dto/add-users.dto'; +import { RemoveAssetsDto } from './dto/remove-assets.dto'; +import { UpdateAlbumDto } from './dto/update-album.dto'; +import { GetAlbumsDto } from './dto/get-albums.dto'; +import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto'; +import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository'; + +@Injectable() +export class AlbumService { + constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {} + + private async _getAlbum({ + authUser, + albumId, + validateIsOwner = true, + }: { + authUser: AuthUserDto; + albumId: string; + validateIsOwner?: boolean; + }): Promise { + const album = await this._albumRepository.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.sharedUserId == authUser.id)) { + throw new ForbiddenException('Unauthorized Album Access'); + } + return album; + } + + async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise { + const albumEntity = await this._albumRepository.create(authUser.id, createAlbumDto); + return mapAlbum(albumEntity); + } + + /** + * Get all shared album, including owned and shared one. + * @param authUser AuthUserDto + * @returns All Shared Album And Its Members + */ + async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise { + const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); + return albums.map((album) => mapAlbum(album)); + } + + async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise { + const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); + return mapAlbum(album); + } + + async addUsersToAlbum(authUser: AuthUserDto, addUsersDto: AddUsersDto, albumId: string): Promise { + const album = await this._getAlbum({ authUser, albumId }); + const updatedAlbum = await this._albumRepository.addSharedUsers(album, addUsersDto); + return mapAlbum(updatedAlbum); + } + + async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise { + const album = await this._getAlbum({ authUser, albumId }); + await this._albumRepository.delete(album); + } + + async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise { + const sharedUserId = userId == 'me' ? authUser.id : userId; + const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); + if (album.ownerId != authUser.id && authUser.id != sharedUserId) { + throw new ForbiddenException('Cannot remove a user from a album that is not owned'); + } + if (album.ownerId == sharedUserId) { + throw new BadRequestException('The owner of the album cannot be removed'); + } + await this._albumRepository.removeUser(album, sharedUserId); + } + + // async removeUsersFromAlbum() {} + + async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto, albumId: string): Promise { + const album = await this._getAlbum({ authUser, albumId }); + await this._albumRepository.removeAssets(album, removeAssetsDto); + } + + async addAssetsToAlbum( + authUser: AuthUserDto, + addAssetsDto: AddAssetsDto, + albumId: string, + ): Promise { + const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); + const updatedAlbum = await this._albumRepository.addAssets(album, addAssetsDto); + return mapAlbum(updatedAlbum); + } + + async updateAlbumTitle( + authUser: AuthUserDto, + updateAlbumDto: UpdateAlbumDto, + albumId: string, + ): Promise { + // TODO: this should not come from request DTO. To be removed from here and DTO + // if (authUser.id != updateAlbumDto.ownerId) { + // throw new BadRequestException('Unauthorized to change album info'); + // } + const album = await this._getAlbum({ authUser, albumId }); + const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto); + return mapAlbum(updatedAlbum); + } +} diff --git a/server/apps/immich/src/api-v1/sharing/dto/add-assets.dto.ts b/server/apps/immich/src/api-v1/album/dto/add-assets.dto.ts similarity index 52% rename from server/apps/immich/src/api-v1/sharing/dto/add-assets.dto.ts rename to server/apps/immich/src/api-v1/album/dto/add-assets.dto.ts index 22f57c1b3a..8bfbd05fd5 100644 --- a/server/apps/immich/src/api-v1/sharing/dto/add-assets.dto.ts +++ b/server/apps/immich/src/api-v1/album/dto/add-assets.dto.ts @@ -1,10 +1,6 @@ import { IsNotEmpty } from 'class-validator'; -import { AssetEntity } from '@app/database/entities/asset.entity'; export class AddAssetsDto { - @IsNotEmpty() - albumId: string; - @IsNotEmpty() assetIds: string[]; } diff --git a/server/apps/immich/src/api-v1/sharing/dto/add-users.dto.ts b/server/apps/immich/src/api-v1/album/dto/add-users.dto.ts similarity index 76% rename from server/apps/immich/src/api-v1/sharing/dto/add-users.dto.ts rename to server/apps/immich/src/api-v1/album/dto/add-users.dto.ts index 1014218bdf..b3f44ba4c3 100644 --- a/server/apps/immich/src/api-v1/sharing/dto/add-users.dto.ts +++ b/server/apps/immich/src/api-v1/album/dto/add-users.dto.ts @@ -1,9 +1,6 @@ import { IsNotEmpty } from 'class-validator'; export class AddUsersDto { - @IsNotEmpty() - albumId: string; - @IsNotEmpty() sharedUserIds: string[]; } diff --git a/server/apps/immich/src/api-v1/album/dto/create-album.dto.ts b/server/apps/immich/src/api-v1/album/dto/create-album.dto.ts new file mode 100644 index 0000000000..2f3a041752 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/dto/create-album.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsOptional } from 'class-validator'; + +export class CreateAlbumDto { + @IsNotEmpty() + albumName: string; + + @IsOptional() + sharedWithUserIds?: string[]; + + @IsOptional() + assetIds?: string[]; +} diff --git a/server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts b/server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts new file mode 100644 index 0000000000..e01ed655e5 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts @@ -0,0 +1,21 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsBoolean } from 'class-validator'; + +export class GetAlbumsDto { + @IsOptional() + @IsBoolean() + @Transform(({ value }) => { + if (value == 'true') { + return true; + } else if (value == 'false') { + return false; + } + return value; + }) + /** + * true: only shared albums + * false: only non-shared own albums + * undefined: shared and owned albums + */ + shared?: boolean; +} diff --git a/server/apps/immich/src/api-v1/sharing/dto/remove-assets.dto.ts b/server/apps/immich/src/api-v1/album/dto/remove-assets.dto.ts similarity index 76% rename from server/apps/immich/src/api-v1/sharing/dto/remove-assets.dto.ts rename to server/apps/immich/src/api-v1/album/dto/remove-assets.dto.ts index 158139c085..dd6943e52c 100644 --- a/server/apps/immich/src/api-v1/sharing/dto/remove-assets.dto.ts +++ b/server/apps/immich/src/api-v1/album/dto/remove-assets.dto.ts @@ -1,9 +1,6 @@ import { IsNotEmpty } from 'class-validator'; export class RemoveAssetsDto { - @IsNotEmpty() - albumId: string; - @IsNotEmpty() assetIds: string[]; } diff --git a/server/apps/immich/src/api-v1/sharing/dto/update-shared-album.dto.ts b/server/apps/immich/src/api-v1/album/dto/update-album.dto.ts similarity index 63% rename from server/apps/immich/src/api-v1/sharing/dto/update-shared-album.dto.ts rename to server/apps/immich/src/api-v1/album/dto/update-album.dto.ts index c67adfb087..8966fff91b 100644 --- a/server/apps/immich/src/api-v1/sharing/dto/update-shared-album.dto.ts +++ b/server/apps/immich/src/api-v1/album/dto/update-album.dto.ts @@ -1,9 +1,6 @@ import { IsNotEmpty } from 'class-validator'; -export class UpdateShareAlbumDto { - @IsNotEmpty() - albumId: string; - +export class UpdateAlbumDto { @IsNotEmpty() albumName: string; diff --git a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts b/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts new file mode 100644 index 0000000000..1f04c71ddf --- /dev/null +++ b/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts @@ -0,0 +1,28 @@ +import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity'; +import { User, mapUser } from '../../user/response-dto/user'; +import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto'; + +export interface AlbumResponseDto { + id: string; + ownerId: string; + albumName: string; + createdAt: string; + albumThumbnailAssetId: string | null; + shared: boolean; + sharedUsers: User[]; + assets: AssetResponseDto[]; +} + +export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { + const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || []; + return { + albumName: entity.albumName, + albumThumbnailAssetId: entity.albumThumbnailAssetId, + createdAt: entity.createdAt, + id: entity.id, + ownerId: entity.ownerId, + sharedUsers, + shared: sharedUsers.length > 0, + assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [], + }; +} diff --git a/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts new file mode 100644 index 0000000000..6a83953216 --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts @@ -0,0 +1,39 @@ +import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; +import { ExifResponseDto, mapExif } from './exif-response.dto'; +import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto'; + +export interface AssetResponseDto { + id: string; + deviceAssetId: string; + ownerId: string; + deviceId: string; + type: AssetType; + originalPath: string; + resizePath: string | null; + createdAt: string; + modifiedAt: string; + isFavorite: boolean; + mimeType: string | null; + duration: string | null; + exifInfo?: ExifResponseDto; + smartInfo?: SmartInfoResponseDto; +} + +export function mapAsset(entity: AssetEntity): AssetResponseDto { + return { + id: entity.id, + deviceAssetId: entity.deviceAssetId, + ownerId: entity.userId, + deviceId: entity.deviceId, + type: entity.type, + originalPath: entity.originalPath, + resizePath: entity.resizePath, + createdAt: entity.createdAt, + modifiedAt: entity.modifiedAt, + isFavorite: entity.isFavorite, + mimeType: entity.mimeType, + duration: entity.duration, + exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, + smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, + }; +} diff --git a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts new file mode 100644 index 0000000000..defd7a5f37 --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts @@ -0,0 +1,49 @@ +import { ExifEntity } from '@app/database/entities/exif.entity'; + +export interface ExifResponseDto { + id: string; + make: string | null; + model: string | null; + imageName: string | null; + exifImageWidth: number | null; + exifImageHeight: number | null; + fileSizeInByte: number | null; + orientation: string | null; + dateTimeOriginal: Date | null; + modifyDate: Date | null; + lensModel: string | null; + fNumber: number | null; + focalLength: number | null; + iso: number | null; + exposureTime: number | null; + latitude: number | null; + longitude: number | null; + city: string | null; + state: string | null; + country: string | null; +} + +export function mapExif(entity: ExifEntity): ExifResponseDto { + return { + id: entity.id, + make: entity.make, + model: entity.model, + imageName: entity.imageName, + exifImageWidth: entity.exifImageWidth, + exifImageHeight: entity.exifImageHeight, + fileSizeInByte: entity.fileSizeInByte, + orientation: entity.orientation, + dateTimeOriginal: entity.dateTimeOriginal, + modifyDate: entity.modifyDate, + lensModel: entity.lensModel, + fNumber: entity.fNumber, + focalLength: entity.focalLength, + iso: entity.iso, + exposureTime: entity.exposureTime, + latitude: entity.latitude, + longitude: entity.longitude, + city: entity.city, + state: entity.state, + country: entity.country, + }; +} diff --git a/server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts new file mode 100644 index 0000000000..efb05e0126 --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts @@ -0,0 +1,15 @@ +import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; + +export interface SmartInfoResponseDto { + id: string; + tags: string[] | null; + objects: string[] | null; +} + +export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto { + return { + id: entity.id, + tags: entity.tags, + objects: entity.objects, + }; +} diff --git a/server/apps/immich/src/api-v1/sharing/dto/create-shared-album.dto.ts b/server/apps/immich/src/api-v1/sharing/dto/create-shared-album.dto.ts deleted file mode 100644 index 6a7194ce3a..0000000000 --- a/server/apps/immich/src/api-v1/sharing/dto/create-shared-album.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsNotEmpty, IsOptional } from 'class-validator'; -import { AssetEntity } from '@app/database/entities/asset.entity'; - -export class CreateSharedAlbumDto { - @IsNotEmpty() - albumName: string; - - @IsNotEmpty() - sharedWithUserIds: string[]; - - @IsOptional() - assetIds: string[]; -} diff --git a/server/apps/immich/src/api-v1/sharing/sharing.controller.ts b/server/apps/immich/src/api-v1/sharing/sharing.controller.ts deleted file mode 100644 index 4a9543b047..0000000000 --- a/server/apps/immich/src/api-v1/sharing/sharing.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common'; -import { SharingService } from './sharing.service'; -import { CreateSharedAlbumDto } from './dto/create-shared-album.dto'; -import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; -import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { AddUsersDto } from './dto/add-users.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { UpdateShareAlbumDto } from './dto/update-shared-album.dto'; - -@UseGuards(JwtAuthGuard) -@Controller('shared') -export class SharingController { - constructor(private readonly sharingService: SharingService) {} - - @Post('/createAlbum') - async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) { - return await this.sharingService.create(authUser, createSharedAlbumDto); - } - - @Post('/addUsers') - async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) { - return await this.sharingService.addUsersToAlbum(addUsersDto); - } - - @Post('/addAssets') - async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) { - return await this.sharingService.addAssetsToAlbum(addAssetsDto); - } - - @Get('/allSharedAlbums') - async getAllSharedAlbums(@GetAuthUser() authUser) { - return await this.sharingService.getAllSharedAlbums(authUser); - } - - @Get('/:albumId') - async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) { - return await this.sharingService.getAlbumInfo(authUser, albumId); - } - - @Delete('/removeAssets') - async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) { - console.log('removeAssets'); - return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto); - } - - @Delete('/:albumId') - async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) { - return await this.sharingService.deleteAlbum(authUser, albumId); - } - - @Delete('/leaveAlbum/:albumId') - async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) { - return await this.sharingService.leaveAlbum(authUser, albumId); - } - - @Patch('/updateInfo') - async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) { - return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto); - } -} diff --git a/server/apps/immich/src/api-v1/sharing/sharing.module.ts b/server/apps/immich/src/api-v1/sharing/sharing.module.ts deleted file mode 100644 index f49e21850f..0000000000 --- a/server/apps/immich/src/api-v1/sharing/sharing.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SharingService } from './sharing.service'; -import { SharingController } from './sharing.controller'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AssetEntity } from '@app/database/entities/asset.entity'; -import { UserEntity } from '@app/database/entities/user.entity'; -import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity'; -import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity'; -import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([ - AssetEntity, - UserEntity, - SharedAlbumEntity, - AssetSharedAlbumEntity, - UserSharedAlbumEntity, - ]), - ], - controllers: [SharingController], - providers: [SharingService], -}) -export class SharingModule {} diff --git a/server/apps/immich/src/api-v1/sharing/sharing.service.ts b/server/apps/immich/src/api-v1/sharing/sharing.service.ts deleted file mode 100644 index 7f8207fcfc..0000000000 --- a/server/apps/immich/src/api-v1/sharing/sharing.service.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { getConnection, Repository } from 'typeorm'; -import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { AssetEntity } from '@app/database/entities/asset.entity'; -import { UserEntity } from '@app/database/entities/user.entity'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { CreateSharedAlbumDto } from './dto/create-shared-album.dto'; -import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity'; -import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity'; -import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity'; -import _ from 'lodash'; -import { AddUsersDto } from './dto/add-users.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { UpdateShareAlbumDto } from './dto/update-shared-album.dto'; - -@Injectable() -export class SharingService { - constructor( - @InjectRepository(AssetEntity) - private assetRepository: Repository, - - @InjectRepository(UserEntity) - private userRepository: Repository, - - @InjectRepository(SharedAlbumEntity) - private sharedAlbumRepository: Repository, - - @InjectRepository(AssetSharedAlbumEntity) - private assetSharedAlbumRepository: Repository, - - @InjectRepository(UserSharedAlbumEntity) - private userSharedAlbumRepository: Repository, - ) {} - - async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) { - return await getConnection().transaction(async (transactionalEntityManager) => { - // Create album entity - const newSharedAlbum = new SharedAlbumEntity(); - newSharedAlbum.ownerId = authUser.id; - newSharedAlbum.albumName = createSharedAlbumDto.albumName; - - const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum); - - // Add shared users - for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) { - const newSharedUser = new UserSharedAlbumEntity(); - newSharedUser.albumId = sharedAlbum.id; - newSharedUser.sharedUserId = sharedUserId; - - await transactionalEntityManager.save(newSharedUser); - } - - // Add shared assets - const newRecords: AssetSharedAlbumEntity[] = []; - - for (const assetId of createSharedAlbumDto.assetIds) { - const newAssetSharedAlbum = new AssetSharedAlbumEntity(); - newAssetSharedAlbum.assetId = assetId; - newAssetSharedAlbum.albumId = sharedAlbum.id; - - newRecords.push(newAssetSharedAlbum); - } - - if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) { - sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId; - await transactionalEntityManager.save(sharedAlbum); - } - - await transactionalEntityManager.save([...newRecords]); - - return sharedAlbum; - }); - } - - /** - * Get all shared album, including owned and shared one. - * @param authUser AuthUserDto - * @returns All Shared Album And Its Members - */ - async getAllSharedAlbums(authUser: AuthUserDto) { - const ownedAlbums = await this.sharedAlbumRepository.find({ - where: { ownerId: authUser.id }, - relations: ['sharedUsers', 'sharedUsers.userInfo'], - }); - - const isSharedWithAlbums = await this.userSharedAlbumRepository.find({ - where: { - sharedUserId: authUser.id, - }, - relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'], - select: ['albumInfo'], - }); - - return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort( - (a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(), - ); - } - - async getAlbumInfo(authUser: AuthUserDto, albumId: string) { - const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } }); - const personShared = await this.userSharedAlbumRepository.findOne({ - where: { albumId: albumId, sharedUserId: authUser.id }, - }); - - if (!(albumOwner || personShared)) { - throw new UnauthorizedException('Unauthorized Album Access'); - } - - const albumInfo = await this.sharedAlbumRepository.findOne({ - where: { id: albumId }, - relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'], - }); - - if (!albumInfo) { - throw new NotFoundException('Album Not Found'); - } - const sortedSharedAsset = albumInfo.sharedAssets.sort( - (a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(), - ); - - albumInfo.sharedAssets = sortedSharedAsset; - - return albumInfo; - } - - async addUsersToAlbum(addUsersDto: AddUsersDto) { - const newRecords: UserSharedAlbumEntity[] = []; - - for (const sharedUserId of addUsersDto.sharedUserIds) { - const newEntity = new UserSharedAlbumEntity(); - newEntity.albumId = addUsersDto.albumId; - newEntity.sharedUserId = sharedUserId; - - newRecords.push(newEntity); - } - - return await this.userSharedAlbumRepository.save([...newRecords]); - } - - async deleteAlbum(authUser: AuthUserDto, albumId: string) { - return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id }); - } - - async leaveAlbum(authUser: AuthUserDto, albumId: string) { - return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id }); - } - - async removeUsersFromAlbum() {} - - async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) { - let deleteAssetCount = 0; - const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId }); - - if (album.ownerId != authUser.id) { - throw new BadRequestException("You don't have permission to remove assets in this album"); - } - - for (const assetId of removeAssetsDto.assetIds) { - const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId }); - if (res.affected == 1) deleteAssetCount++; - } - - return deleteAssetCount == removeAssetsDto.assetIds.length; - } - - async addAssetsToAlbum(addAssetsDto: AddAssetsDto) { - const newRecords: AssetSharedAlbumEntity[] = []; - - for (const assetId of addAssetsDto.assetIds) { - const newAssetSharedAlbum = new AssetSharedAlbumEntity(); - newAssetSharedAlbum.assetId = assetId; - newAssetSharedAlbum.albumId = addAssetsDto.albumId; - - newRecords.push(newAssetSharedAlbum); - } - - // Add album thumbnail if not exist. - const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId }); - - if (!album.albumThumbnailAssetId && newRecords.length > 0) { - album.albumThumbnailAssetId = newRecords[0].assetId; - await this.sharedAlbumRepository.save(album); - } - - return await this.assetSharedAlbumRepository.save([...newRecords]); - } - - async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) { - if (authUser.id != updateShareAlbumDto.ownerId) { - throw new BadRequestException('Unauthorized to change album info'); - } - - const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } }); - sharedAlbum.albumName = updateShareAlbumDto.albumName; - - return await this.sharedAlbumRepository.save(sharedAlbum); - } -} diff --git a/server/apps/immich/src/api-v1/validation/parse-me-uuid-pipe.ts b/server/apps/immich/src/api-v1/validation/parse-me-uuid-pipe.ts new file mode 100644 index 0000000000..b4e598867c --- /dev/null +++ b/server/apps/immich/src/api-v1/validation/parse-me-uuid-pipe.ts @@ -0,0 +1,11 @@ +import { ParseUUIDPipe, Injectable, ArgumentMetadata } from '@nestjs/common'; + +@Injectable() +export class ParseMeUUIDPipe extends ParseUUIDPipe { + async transform(value: string, metadata: ArgumentMetadata) { + if (value == 'me') { + return value; + } + return super.transform(value, metadata); + } +} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 084b01b38c..88e8f606e6 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -11,7 +11,7 @@ import { BullModule } from '@nestjs/bull'; import { ServerInfoModule } from './api-v1/server-info/server-info.module'; import { BackgroundTaskModule } from './modules/background-task/background-task.module'; import { CommunicationModule } from './api-v1/communication/communication.module'; -import { SharingModule } from './api-v1/sharing/sharing.module'; +import { AlbumModule } from './api-v1/album/album.module'; import { AppController } from './app.controller'; import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; @@ -47,7 +47,7 @@ import { DatabaseModule } from '@app/database'; CommunicationModule, - SharingModule, + AlbumModule, ScheduleModule.forRoot(), diff --git a/server/apps/immich/test/album.e2e-spec.ts b/server/apps/immich/test/album.e2e-spec.ts new file mode 100644 index 0000000000..cb6e814d41 --- /dev/null +++ b/server/apps/immich/test/album.e2e-spec.ts @@ -0,0 +1,161 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import request from 'supertest'; +import { clearDb, getAuthUser, authCustom } from './test-utils'; +import { databaseConfig } from '@app/database/config/database.config'; +import { AlbumModule } from '../src/api-v1/album/album.module'; +import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto'; +import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module'; +import { AuthUserDto } from '../src/decorators/auth-user.decorator'; +import { UserService } from '../src/api-v1/user/user.service'; +import { UserModule } from '../src/api-v1/user/user.module'; + +function _createAlbum(app: INestApplication, data: CreateAlbumDto) { + return request(app.getHttpServer()).post('/album').send(data); +} + +describe('Album', () => { + let app: INestApplication; + + afterAll(async () => { + await clearDb(); + await app.close(); + }); + + describe('without auth', () => { + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AlbumModule, ImmichJwtModule, TypeOrmModule.forRoot(databaseConfig)], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('prevents fetching albums if not auth', async () => { + const { status } = await request(app.getHttpServer()).get('/album'); + expect(status).toEqual(401); + }); + }); + + describe('with auth', () => { + let authUser: AuthUserDto; + let userService: UserService; + + beforeAll(async () => { + const builder = Test.createTestingModule({ + imports: [AlbumModule, UserModule, TypeOrmModule.forRoot(databaseConfig)], + }); + authUser = getAuthUser(); // set default auth user + const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile(); + + app = moduleFixture.createNestApplication(); + userService = app.get(UserService); + await app.init(); + }); + + describe('with empty DB', () => { + afterEach(async () => { + await clearDb(); + }); + + it('creates an album', async () => { + const data: CreateAlbumDto = { + albumName: 'first albbum', + }; + const { status, body } = await _createAlbum(app, data); + expect(status).toEqual(201); + expect(body).toEqual( + expect.objectContaining({ + ownerId: authUser.id, + albumName: data.albumName, + }), + ); + }); + }); + + describe('with albums in DB', () => { + const userOneShared = 'userOneShared'; + const userOneNotShared = 'userOneNotShared'; + const userTwoShared = 'userTwoShared'; + const userTwoNotShared = 'userTwoNotShared'; + let userOne: AuthUserDto; + let userTwo: AuthUserDto; + + beforeAll(async () => { + // setup users + const result = await Promise.all([ + userService.createUser({ + email: 'one@test.com', + password: '1234', + firstName: 'one', + lastName: 'test', + }), + userService.createUser({ + email: 'two@test.com', + password: '1234', + firstName: 'two', + lastName: 'test', + }), + ]); + userOne = result[0]; + userTwo = result[1]; + // add user one albums + authUser = userOne; + await Promise.all([ + _createAlbum(app, { albumName: userOneShared, sharedWithUserIds: [userTwo.id] }), + _createAlbum(app, { albumName: userOneNotShared }), + ]); + // add user two albums + authUser = userTwo; + await Promise.all([ + _createAlbum(app, { albumName: userTwoShared, sharedWithUserIds: [userOne.id] }), + _createAlbum(app, { albumName: userTwoNotShared }), + ]); + // set user one as authed for next requests + authUser = userOne; + }); + + it('returns the album collection including owned and shared', async () => { + const { status, body } = await request(app.getHttpServer()).get('/album'); + expect(status).toEqual(200); + expect(body).toHaveLength(3); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }), + expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }), + expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }), + ]), + ); + }); + + it('returns the album collection filtered by shared', async () => { + const { status, body } = await request(app.getHttpServer()).get('/album?shared=true'); + expect(status).toEqual(200); + expect(body).toHaveLength(2); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }), + expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }), + ]), + ); + }); + + it('returns the album collection filtered by NOT shared', async () => { + const { status, body } = await request(app.getHttpServer()).get('/album?shared=false'); + expect(status).toEqual(200); + expect(body).toHaveLength(1); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }), + ]), + ); + }); + }); + }); +}); diff --git a/server/libs/database/src/entities/album.entity.ts b/server/libs/database/src/entities/album.entity.ts new file mode 100644 index 0000000000..8de60a3b39 --- /dev/null +++ b/server/libs/database/src/entities/album.entity.ts @@ -0,0 +1,27 @@ +import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { AssetAlbumEntity } from './asset-album.entity'; +import { UserAlbumEntity } from './user-album.entity'; + +@Entity('albums') +export class AlbumEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + ownerId: string; + + @Column({ default: 'Untitled Album' }) + albumName: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: string; + + @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true }) + albumThumbnailAssetId: string; + + @OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo) + sharedUsers: UserAlbumEntity[]; + + @OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo) + assets: AssetAlbumEntity[]; +} diff --git a/server/libs/database/src/entities/asset-shared-album.entity.ts b/server/libs/database/src/entities/asset-album.entity.ts similarity index 56% rename from server/libs/database/src/entities/asset-shared-album.entity.ts rename to server/libs/database/src/entities/asset-album.entity.ts index a26c440811..de76de55de 100644 --- a/server/libs/database/src/entities/asset-shared-album.entity.ts +++ b/server/libs/database/src/entities/asset-album.entity.ts @@ -1,10 +1,10 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { AlbumEntity } from './album.entity'; import { AssetEntity } from './asset.entity'; -import { SharedAlbumEntity } from './shared-album.entity'; -@Entity('asset_shared_album') +@Entity('asset_album') @Unique('PK_unique_asset_in_album', ['albumId', 'assetId']) -export class AssetSharedAlbumEntity { +export class AssetAlbumEntity { @PrimaryGeneratedColumn() id: string; @@ -14,12 +14,12 @@ export class AssetSharedAlbumEntity { @Column() assetId: string; - @ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedAssets, { + @ManyToOne(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', nullable: true, }) @JoinColumn({ name: 'albumId' }) - albumInfo: SharedAlbumEntity; + albumInfo: AlbumEntity; @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', diff --git a/server/libs/database/src/entities/shared-album.entity.ts b/server/libs/database/src/entities/shared-album.entity.ts deleted file mode 100644 index e8f27fcbcf..0000000000 --- a/server/libs/database/src/entities/shared-album.entity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; -import { AssetSharedAlbumEntity } from './asset-shared-album.entity'; -import { UserSharedAlbumEntity } from './user-shared-album.entity'; - -@Entity('shared_albums') -export class SharedAlbumEntity { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column() - ownerId: string; - - @Column({ default: 'Untitled Album' }) - albumName: string; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt: string; - - @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true }) - albumThumbnailAssetId: string; - - @OneToMany(() => UserSharedAlbumEntity, (userSharedAlbums) => userSharedAlbums.albumInfo) - sharedUsers: UserSharedAlbumEntity[]; - - @OneToMany(() => AssetSharedAlbumEntity, (assetSharedAlbumEntity) => assetSharedAlbumEntity.albumInfo) - sharedAssets: AssetSharedAlbumEntity[]; -} diff --git a/server/libs/database/src/entities/user-shared-album.entity.ts b/server/libs/database/src/entities/user-album.entity.ts similarity index 57% rename from server/libs/database/src/entities/user-shared-album.entity.ts rename to server/libs/database/src/entities/user-album.entity.ts index 2f4aeb599a..f910904a87 100644 --- a/server/libs/database/src/entities/user-shared-album.entity.ts +++ b/server/libs/database/src/entities/user-album.entity.ts @@ -1,10 +1,10 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { UserEntity } from './user.entity'; -import { SharedAlbumEntity } from './shared-album.entity'; +import { AlbumEntity } from './album.entity'; @Entity('user_shared_album') @Unique('PK_unique_user_in_album', ['albumId', 'sharedUserId']) -export class UserSharedAlbumEntity { +export class UserAlbumEntity { @PrimaryGeneratedColumn() id: string; @@ -14,12 +14,12 @@ export class UserSharedAlbumEntity { @Column() sharedUserId: string; - @ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedUsers, { + @ManyToOne(() => AlbumEntity, (album) => album.sharedUsers, { onDelete: 'CASCADE', nullable: true, }) @JoinColumn({ name: 'albumId' }) - albumInfo: SharedAlbumEntity; + albumInfo: AlbumEntity; @ManyToOne(() => UserEntity) @JoinColumn({ name: 'sharedUserId' }) diff --git a/server/libs/database/src/migrations/1655401127251-RenameSharedAlbums.ts b/server/libs/database/src/migrations/1655401127251-RenameSharedAlbums.ts new file mode 100644 index 0000000000..9bb71fb08c --- /dev/null +++ b/server/libs/database/src/migrations/1655401127251-RenameSharedAlbums.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameSharedAlbums1655401127251 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE shared_albums RENAME TO albums; + + ALTER TABLE asset_shared_album RENAME TO asset_album; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_album RENAME TO asset_shared_album; + + ALTER TABLE albums RENAME TO shared_albums; + `); + } +} diff --git a/server/package.json b/server/package.json index 07edf46f63..eb2d5b3ef2 100644 --- a/server/package.json +++ b/server/package.json @@ -92,6 +92,7 @@ "typescript": "^4.3.5" }, "jest": { + "clearMocks": true, "moduleFileExtensions": [ "js", "json",