diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index c877afc6bb..a3459bea3d 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -1,12 +1,13 @@ import { + addAssetsToAlbum, AlbumResponseDto, + AlbumUserRole, AssetFileUploadResponseDto, AssetOrder, - LoginResponseDto, - SharedLinkType, - addAssetsToAlbum, deleteUser, getAlbumInfo, + LoginResponseDto, + SharedLinkType, } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -14,7 +15,8 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const user1SharedUser = 'user1SharedUser'; +const user1SharedEditorUser = 'user1SharedEditorUser'; +const user1SharedViewerUser = 'user1SharedViewerUser'; const user1SharedLink = 'user1SharedLink'; const user1NotShared = 'user1NotShared'; const user2SharedUser = 'user2SharedUser'; @@ -49,35 +51,61 @@ describe('/album', () => { const albums = await Promise.all([ // user 1 + /* 0 */ utils.createAlbum(user1.accessToken, { - albumName: user1SharedUser, + albumName: user1SharedEditorUser, sharedWithUserIds: [user2.userId], assetIds: [user1Asset1.id], }), + /* 1 */ utils.createAlbum(user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset1.id], }), + /* 2 */ utils.createAlbum(user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset1.id, user1Asset2.id], }), // user 2 + /* 3 */ utils.createAlbum(user2.accessToken, { albumName: user2SharedUser, - sharedWithUserIds: [user1.userId], + sharedWithUserIds: [user1.userId, user3.userId], }), + /* 4 */ utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), + /* 5 */ utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), // user 3 + /* 6 */ utils.createAlbum(user3.accessToken, { albumName: 'Deleted', sharedWithUserIds: [user1.userId], }), + + // user1 shared with an editor + /* 7 */ + utils.createAlbum(user1.accessToken, { + albumName: user1SharedViewerUser, + sharedWithUserIds: [user2.userId], + assetIds: [user1Asset1.id], + }), ]); + // Make viewer + await utils.updateAlbumUser(user1.accessToken, { + id: albums[7].id, + userId: user2.userId, + updateAlbumUserDto: { role: AlbumUserRole.Viewer }, + }); + + albums[0].albumUsers[0].role = AlbumUserRole.Editor; + albums[3].albumUsers[0].role = AlbumUserRole.Editor; + albums[6].albumUsers[0].role = AlbumUserRole.Editor; + await addAssetsToAlbum( { id: albums[3].id, bulkIdsDto: { ids: [user1Asset1.id] } }, { headers: asBearerAuth(user1.accessToken) }, @@ -85,7 +113,7 @@ describe('/album', () => { albums[3] = await getAlbumInfo({ id: albums[3].id }, { headers: asBearerAuth(user2.accessToken) }); - user1Albums = albums.slice(0, 3); + user1Albums = [...albums.slice(0, 3), albums[7]]; user2Albums = albums.slice(3, 6); await Promise.all([ @@ -144,7 +172,7 @@ describe('/album', () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(3); + expect(body).toHaveLength(4); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -154,7 +182,12 @@ describe('/album', () => { }), expect.objectContaining({ ownerId: user1.userId, - albumName: user1SharedUser, + albumName: user1SharedEditorUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedViewerUser, shared: true, }), expect.objectContaining({ @@ -169,12 +202,17 @@ describe('/album', () => { it('should return the album collection including owned and shared', async () => { const { status, body } = await request(app).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(3); + expect(body).toHaveLength(4); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ ownerId: user1.userId, - albumName: user1SharedUser, + albumName: user1SharedEditorUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedViewerUser, shared: true, }), expect.objectContaining({ @@ -196,12 +234,17 @@ describe('/album', () => { .get('/album?shared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(3); + expect(body).toHaveLength(4); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ ownerId: user1.userId, - albumName: user1SharedUser, + albumName: user1SharedEditorUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedViewerUser, shared: true, }), expect.objectContaining({ @@ -248,7 +291,7 @@ describe('/album', () => { .get(`/album?shared=true&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); }); it('should return the album collection filtered by assetId and ignores shared=false', async () => { @@ -256,7 +299,7 @@ describe('/album', () => { .get(`/album?shared=false&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); }); }); @@ -279,16 +322,22 @@ describe('/album', () => { }); }); - it('should return album info for shared album', async () => { + it('should return album info for shared album (editor)', async () => { const { status, body } = await request(app) .get(`/album/${user2Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ - ...user2Albums[0], - assets: [expect.objectContaining({ id: user2Albums[0].assets[0].id })], - }); + expect(body).toMatchObject({ id: user2Albums[0].id }); + }); + + it('should return album info for shared album (viewer)', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[3].id}?withoutAssets=false`) + .set('Authorization', `Bearer ${user2.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id: user1Albums[3].id }); }); it('should return album info with assets when withoutAssets is undefined', async () => { @@ -330,7 +379,7 @@ describe('/album', () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); + expect(body).toEqual({ owned: 4, shared: 4, notShared: 1 }); }); }); @@ -357,6 +406,7 @@ describe('/album', () => { albumThumbnailAssetId: null, shared: false, sharedUsers: [], + albumUsers: [], hasSharedLink: false, assets: [], assetCount: 0, @@ -395,6 +445,17 @@ describe('/album', () => { expect(status).toBe(200); expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); }); + + it('should not be able to add assets to album as a viewer', async () => { + const asset = await utils.createAsset(user2.accessToken); + const { status, body } = await request(app) + .put(`/album/${user1Albums[3].id}/assets`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ ids: [asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no album.addAsset access')); + }); }); describe('PATCH /album/:id', () => { @@ -425,6 +486,26 @@ describe('/album', () => { description: 'An album description', }); }); + + it('should not be able to update as a viewer', async () => { + const { status, body } = await request(app) + .patch(`/album/${user1Albums[3].id}`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ albumName: 'New album name' }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no album.update access')); + }); + + it('should not be able to update as an editor', async () => { + const { status, body } = await request(app) + .patch(`/album/${user1Albums[0].id}`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ albumName: 'New album name' }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no album.update access')); + }); }); describe('DELETE /album/:id/assets', () => { @@ -488,6 +569,16 @@ describe('/album', () => { expect(status).toBe(200); expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]); }); + + it('should not be able to remove assets from album as a viewer', async () => { + const { status, body } = await request(app) + .delete(`/album/${user1Albums[3].id}/assets`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ ids: [user1Asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no album.removeAsset access')); + }); }); describe('PUT :id/users', () => { @@ -510,7 +601,7 @@ describe('/album', () => { const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ sharedUserIds: [user2.userId] }); + .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] }); expect(status).toBe(200); expect(body).toEqual( @@ -524,7 +615,7 @@ describe('/album', () => { const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ sharedUserIds: [user1.userId] }); + .send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner')); @@ -534,15 +625,54 @@ describe('/album', () => { await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ sharedUserIds: [user2.userId] }); + .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] }); const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ sharedUserIds: [user2.userId] }); + .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('User already added')); }); }); + + describe('PUT :id/user/:userId', () => { + it('should allow the album owner to change the role of a shared user', async () => { + const album = await utils.createAlbum(user1.accessToken, { + albumName: 'testAlbum', + sharedWithUserIds: [user2.userId], + }); + + const { status } = await request(app) + .put(`/album/${album.id}/user/${user2.userId}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ role: AlbumUserRole.Editor }); + + expect(status).toBe(200); + + // Get album to verify the role change + const { body } = await request(app).get(`/album/${album.id}`).set('Authorization', `Bearer ${user1.accessToken}`); + expect(body).toEqual( + expect.objectContaining({ + albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })], + }), + ); + }); + + it('should not allow a shared user to change the role of another shared user', async () => { + const album = await utils.createAlbum(user1.accessToken, { + albumName: 'testAlbum', + sharedWithUserIds: [user2.userId], + }); + + const { status, body } = await request(app) + .put(`/album/${album.id}/user/${user2.userId}`) + .set('Authorization', `Bearer ${user2.accessToken}`) + .send({ role: AlbumUserRole.Editor }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no album.share access')); + }); + }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 96994c7f0a..ee4dad654d 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -26,6 +26,7 @@ import { searchMetadata, signUpAdmin, updateAdminOnboarding, + updateAlbumUser, updateConfig, validate, } from '@immich/sdk'; @@ -286,6 +287,9 @@ export const utils = { createAlbum: (accessToken: string, dto: CreateAlbumDto) => createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }), + updateAlbumUser: (accessToken: string, args: Parameters[0]) => + updateAlbumUser(args, { headers: asBearerAuth(accessToken) }), + createAsset: async ( accessToken: string, dto?: Partial> & { assetData?: AssetData }, diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 64229329aa..2fd4fba057 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -17,6 +17,9 @@ doc/AdminOnboardingUpdateDto.md doc/AlbumApi.md doc/AlbumCountResponseDto.md doc/AlbumResponseDto.md +doc/AlbumUserAddDto.md +doc/AlbumUserResponseDto.md +doc/AlbumUserRole.md doc/AllJobStatusResponseDto.md doc/AssetApi.md doc/AssetBulkDeleteDto.md @@ -189,6 +192,7 @@ doc/TranscodeHWAccel.md doc/TranscodePolicy.md doc/TrashApi.md doc/UpdateAlbumDto.md +doc/UpdateAlbumUserDto.md doc/UpdateAssetDto.md doc/UpdateLibraryDto.md doc/UpdatePartnerDto.md @@ -249,6 +253,9 @@ lib/model/add_users_dto.dart lib/model/admin_onboarding_update_dto.dart lib/model/album_count_response_dto.dart lib/model/album_response_dto.dart +lib/model/album_user_add_dto.dart +lib/model/album_user_response_dto.dart +lib/model/album_user_role.dart lib/model/all_job_status_response_dto.dart lib/model/api_key_create_dto.dart lib/model/api_key_create_response_dto.dart @@ -403,6 +410,7 @@ lib/model/tone_mapping.dart lib/model/transcode_hw_accel.dart lib/model/transcode_policy.dart lib/model/update_album_dto.dart +lib/model/update_album_user_dto.dart lib/model/update_asset_dto.dart lib/model/update_library_dto.dart lib/model/update_partner_dto.dart @@ -429,6 +437,9 @@ test/admin_onboarding_update_dto_test.dart test/album_api_test.dart test/album_count_response_dto_test.dart test/album_response_dto_test.dart +test/album_user_add_dto_test.dart +test/album_user_response_dto_test.dart +test/album_user_role_test.dart test/all_job_status_response_dto_test.dart test/api_key_api_test.dart test/api_key_create_dto_test.dart @@ -606,6 +617,7 @@ test/transcode_hw_accel_test.dart test/transcode_policy_test.dart test/trash_api_test.dart test/update_album_dto_test.dart +test/update_album_user_dto_test.dart test/update_asset_dto_test.dart test/update_library_dto_test.dart test/update_partner_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5439d48208..3059e3dbef 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -91,6 +91,7 @@ Class | Method | HTTP request | Description *AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets | *AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{id}/user/{userId} | *AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /album/{id} | +*AlbumApi* | [**updateAlbumUser**](doc//AlbumApi.md#updatealbumuser) | **PUT** /album/{id}/user/{userId} | *AssetApi* | [**checkBulkUpload**](doc//AssetApi.md#checkbulkupload) | **POST** /asset/bulk-upload-check | *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | *AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | @@ -238,6 +239,9 @@ Class | Method | HTTP request | Description - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md) - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md) + - [AlbumUserAddDto](doc//AlbumUserAddDto.md) + - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) + - [AlbumUserRole](doc//AlbumUserRole.md) - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md) - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md) @@ -388,6 +392,7 @@ Class | Method | HTTP request | Description - [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodePolicy](doc//TranscodePolicy.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) + - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdatePartnerDto](doc//UpdatePartnerDto.md) diff --git a/mobile/openapi/doc/AddUsersDto.md b/mobile/openapi/doc/AddUsersDto.md index 9f7770d604..a8f7723441 100644 --- a/mobile/openapi/doc/AddUsersDto.md +++ b/mobile/openapi/doc/AddUsersDto.md @@ -8,7 +8,8 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**sharedUserIds** | **List** | | [default to const []] +**albumUsers** | [**List**](AlbumUserAddDto.md) | | [default to const []] +**sharedUserIds** | **List** | Deprecated in favor of albumUsers | [optional] [default to const []] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index 427181880d..2cd6cb29b0 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -19,6 +19,7 @@ Method | HTTP request | Description [**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets | [**removeUserFromAlbum**](AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{id}/user/{userId} | [**updateAlbumInfo**](AlbumApi.md#updatealbuminfo) | **PATCH** /album/{id} | +[**updateAlbumUser**](AlbumApi.md#updatealbumuser) | **PUT** /album/{id}/user/{userId} | # **addAssetsToAlbum** @@ -583,3 +584,61 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **updateAlbumUser** +> updateAlbumUser(id, userId, updateAlbumUserDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AlbumApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final userId = userId_example; // String | +final updateAlbumUserDto = UpdateAlbumUserDto(); // UpdateAlbumUserDto | + +try { + api_instance.updateAlbumUser(id, userId, updateAlbumUserDto); +} catch (e) { + print('Exception when calling AlbumApi->updateAlbumUser: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + **userId** | **String**| | + **updateAlbumUserDto** | [**UpdateAlbumUserDto**](UpdateAlbumUserDto.md)| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index dd4a94e883..b7965b4200 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **albumName** | **String** | | **albumThumbnailAssetId** | **String** | | +**albumUsers** | [**List**](AlbumUserResponseDto.md) | | [default to const []] **assetCount** | **int** | | **assets** | [**List**](AssetResponseDto.md) | | [default to const []] **createdAt** | [**DateTime**](DateTime.md) | | @@ -23,7 +24,7 @@ Name | Type | Description | Notes **owner** | [**UserResponseDto**](UserResponseDto.md) | | **ownerId** | **String** | | **shared** | **bool** | | -**sharedUsers** | [**List**](UserResponseDto.md) | | [default to const []] +**sharedUsers** | [**List**](UserResponseDto.md) | Deprecated in favor of albumUsers | [default to const []] **startDate** | [**DateTime**](DateTime.md) | | [optional] **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/AlbumUserAddDto.md b/mobile/openapi/doc/AlbumUserAddDto.md new file mode 100644 index 0000000000..aae7c9eb6c --- /dev/null +++ b/mobile/openapi/doc/AlbumUserAddDto.md @@ -0,0 +1,16 @@ +# openapi.model.AlbumUserAddDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**role** | [**AlbumUserRole**](AlbumUserRole.md) | | [optional] +**userId** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/AlbumUserResponseDto.md b/mobile/openapi/doc/AlbumUserResponseDto.md new file mode 100644 index 0000000000..3f59d3142f --- /dev/null +++ b/mobile/openapi/doc/AlbumUserResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.AlbumUserResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**role** | [**AlbumUserRole**](AlbumUserRole.md) | | +**user** | [**UserResponseDto**](UserResponseDto.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/AlbumUserRole.md b/mobile/openapi/doc/AlbumUserRole.md new file mode 100644 index 0000000000..d0f64ef3ec --- /dev/null +++ b/mobile/openapi/doc/AlbumUserRole.md @@ -0,0 +1,14 @@ +# openapi.model.AlbumUserRole + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/UpdateAlbumUserDto.md b/mobile/openapi/doc/UpdateAlbumUserDto.md new file mode 100644 index 0000000000..1a1050b4db --- /dev/null +++ b/mobile/openapi/doc/UpdateAlbumUserDto.md @@ -0,0 +1,15 @@ +# openapi.model.UpdateAlbumUserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**role** | [**AlbumUserRole**](AlbumUserRole.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 44bd35a683..78f2e9ed5a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -67,6 +67,9 @@ part 'model/add_users_dto.dart'; part 'model/admin_onboarding_update_dto.dart'; part 'model/album_count_response_dto.dart'; part 'model/album_response_dto.dart'; +part 'model/album_user_add_dto.dart'; +part 'model/album_user_response_dto.dart'; +part 'model/album_user_role.dart'; part 'model/all_job_status_response_dto.dart'; part 'model/asset_bulk_delete_dto.dart'; part 'model/asset_bulk_update_dto.dart'; @@ -217,6 +220,7 @@ part 'model/tone_mapping.dart'; part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; part 'model/update_album_dto.dart'; +part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index f5fe2e3c1d..4596eff88b 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -536,4 +536,53 @@ class AlbumApi { } return null; } + + /// Performs an HTTP 'PUT /album/{id}/user/{userId}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] userId (required): + /// + /// * [UpdateAlbumUserDto] updateAlbumUserDto (required): + Future updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto,) async { + // ignore: prefer_const_declarations + final path = r'/album/{id}/user/{userId}' + .replaceAll('{id}', id) + .replaceAll('{userId}', userId); + + // ignore: prefer_final_locals + Object? postBody = updateAlbumUserDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] userId (required): + /// + /// * [UpdateAlbumUserDto] updateAlbumUserDto (required): + Future updateAlbumUser(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto,) async { + final response = await updateAlbumUserWithHttpInfo(id, userId, updateAlbumUserDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a92f1df7a7..ae3b9dadf1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -204,6 +204,12 @@ class ApiClient { return AlbumCountResponseDto.fromJson(value); case 'AlbumResponseDto': return AlbumResponseDto.fromJson(value); + case 'AlbumUserAddDto': + return AlbumUserAddDto.fromJson(value); + case 'AlbumUserResponseDto': + return AlbumUserResponseDto.fromJson(value); + case 'AlbumUserRole': + return AlbumUserRoleTypeTransformer().decode(value); case 'AllJobStatusResponseDto': return AllJobStatusResponseDto.fromJson(value); case 'AssetBulkDeleteDto': @@ -504,6 +510,8 @@ class ApiClient { return TranscodePolicyTypeTransformer().decode(value); case 'UpdateAlbumDto': return UpdateAlbumDto.fromJson(value); + case 'UpdateAlbumUserDto': + return UpdateAlbumUserDto.fromJson(value); case 'UpdateAssetDto': return UpdateAssetDto.fromJson(value); case 'UpdateLibraryDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7ad74d9516..8d92ad1f0a 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -55,6 +55,9 @@ String parameterToString(dynamic value) { if (value is DateTime) { return value.toUtc().toIso8601String(); } + if (value is AlbumUserRole) { + return AlbumUserRoleTypeTransformer().encode(value).toString(); + } if (value is AssetJobName) { return AssetJobNameTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 9ce47afc6a..806bc60f42 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -13,25 +13,32 @@ part of openapi.api; class AddUsersDto { /// Returns a new [AddUsersDto] instance. AddUsersDto({ + this.albumUsers = const [], this.sharedUserIds = const [], }); + List albumUsers; + + /// Deprecated in favor of albumUsers List sharedUserIds; @override bool operator ==(Object other) => identical(this, other) || other is AddUsersDto && + _deepEquality.equals(other.albumUsers, albumUsers) && _deepEquality.equals(other.sharedUserIds, sharedUserIds); @override int get hashCode => // ignore: unnecessary_parenthesis + (albumUsers.hashCode) + (sharedUserIds.hashCode); @override - String toString() => 'AddUsersDto[sharedUserIds=$sharedUserIds]'; + String toString() => 'AddUsersDto[albumUsers=$albumUsers, sharedUserIds=$sharedUserIds]'; Map toJson() { final json = {}; + json[r'albumUsers'] = this.albumUsers; json[r'sharedUserIds'] = this.sharedUserIds; return json; } @@ -44,6 +51,7 @@ class AddUsersDto { final json = value.cast(); return AddUsersDto( + albumUsers: AlbumUserAddDto.listFromJson(json[r'albumUsers']), sharedUserIds: json[r'sharedUserIds'] is Iterable ? (json[r'sharedUserIds'] as Iterable).cast().toList(growable: false) : const [], @@ -94,7 +102,7 @@ class AddUsersDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'sharedUserIds', + 'albumUsers', }; } diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index d764028558..cae01150f6 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -15,6 +15,7 @@ class AlbumResponseDto { AlbumResponseDto({ required this.albumName, required this.albumThumbnailAssetId, + this.albumUsers = const [], required this.assetCount, this.assets = const [], required this.createdAt, @@ -37,6 +38,8 @@ class AlbumResponseDto { String? albumThumbnailAssetId; + List albumUsers; + int assetCount; List assets; @@ -81,6 +84,7 @@ class AlbumResponseDto { bool shared; + /// Deprecated in favor of albumUsers List sharedUsers; /// @@ -97,6 +101,7 @@ class AlbumResponseDto { bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto && other.albumName == albumName && other.albumThumbnailAssetId == albumThumbnailAssetId && + _deepEquality.equals(other.albumUsers, albumUsers) && other.assetCount == assetCount && _deepEquality.equals(other.assets, assets) && other.createdAt == createdAt && @@ -119,6 +124,7 @@ class AlbumResponseDto { // ignore: unnecessary_parenthesis (albumName.hashCode) + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + + (albumUsers.hashCode) + (assetCount.hashCode) + (assets.hashCode) + (createdAt.hashCode) + @@ -137,7 +143,7 @@ class AlbumResponseDto { (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -147,6 +153,7 @@ class AlbumResponseDto { } else { // json[r'albumThumbnailAssetId'] = null; } + json[r'albumUsers'] = this.albumUsers; json[r'assetCount'] = this.assetCount; json[r'assets'] = this.assets; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); @@ -192,6 +199,7 @@ class AlbumResponseDto { return AlbumResponseDto( albumName: mapValueOfType(json, r'albumName')!, albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), + albumUsers: AlbumUserResponseDto.listFromJson(json[r'albumUsers']), assetCount: mapValueOfType(json, r'assetCount')!, assets: AssetResponseDto.listFromJson(json[r'assets']), createdAt: mapDateTime(json, r'createdAt', r'')!, @@ -257,6 +265,7 @@ class AlbumResponseDto { static const requiredKeys = { 'albumName', 'albumThumbnailAssetId', + 'albumUsers', 'assetCount', 'assets', 'createdAt', diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart new file mode 100644 index 0000000000..0e259828f8 --- /dev/null +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumUserAddDto { + /// Returns a new [AlbumUserAddDto] instance. + AlbumUserAddDto({ + this.role, + required this.userId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AlbumUserRole? role; + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumUserAddDto && + other.role == role && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (role == null ? 0 : role!.hashCode) + + (userId.hashCode); + + @override + String toString() => 'AlbumUserAddDto[role=$role, userId=$userId]'; + + Map toJson() { + final json = {}; + if (this.role != null) { + json[r'role'] = this.role; + } else { + // json[r'role'] = null; + } + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [AlbumUserAddDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumUserAddDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AlbumUserAddDto( + role: AlbumUserRole.fromJson(json[r'role']), + userId: mapValueOfType(json, r'userId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumUserAddDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AlbumUserAddDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumUserAddDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumUserAddDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart new file mode 100644 index 0000000000..896c1cbb8f --- /dev/null +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumUserResponseDto { + /// Returns a new [AlbumUserResponseDto] instance. + AlbumUserResponseDto({ + required this.role, + required this.user, + }); + + AlbumUserRole role; + + UserResponseDto user; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumUserResponseDto && + other.role == role && + other.user == user; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (role.hashCode) + + (user.hashCode); + + @override + String toString() => 'AlbumUserResponseDto[role=$role, user=$user]'; + + Map toJson() { + final json = {}; + json[r'role'] = this.role; + json[r'user'] = this.user; + return json; + } + + /// Returns a new [AlbumUserResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumUserResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AlbumUserResponseDto( + role: AlbumUserRole.fromJson(json[r'role'])!, + user: UserResponseDto.fromJson(json[r'user'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumUserResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AlbumUserResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumUserResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumUserResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'role', + 'user', + }; +} + diff --git a/mobile/openapi/lib/model/album_user_role.dart b/mobile/openapi/lib/model/album_user_role.dart new file mode 100644 index 0000000000..991d6d182c --- /dev/null +++ b/mobile/openapi/lib/model/album_user_role.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AlbumUserRole { + /// Instantiate a new enum with the provided [value]. + const AlbumUserRole._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const editor = AlbumUserRole._(r'editor'); + static const viewer = AlbumUserRole._(r'viewer'); + + /// List of all possible values in this [enum][AlbumUserRole]. + static const values = [ + editor, + viewer, + ]; + + static AlbumUserRole? fromJson(dynamic value) => AlbumUserRoleTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumUserRole.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AlbumUserRole] to String, +/// and [decode] dynamic data back to [AlbumUserRole]. +class AlbumUserRoleTypeTransformer { + factory AlbumUserRoleTypeTransformer() => _instance ??= const AlbumUserRoleTypeTransformer._(); + + const AlbumUserRoleTypeTransformer._(); + + String encode(AlbumUserRole data) => data.value; + + /// Decodes a [dynamic value][data] to a AlbumUserRole. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AlbumUserRole? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'editor': return AlbumUserRole.editor; + case r'viewer': return AlbumUserRole.viewer; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AlbumUserRoleTypeTransformer] instance. + static AlbumUserRoleTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart new file mode 100644 index 0000000000..8e85349318 --- /dev/null +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UpdateAlbumUserDto { + /// Returns a new [UpdateAlbumUserDto] instance. + UpdateAlbumUserDto({ + required this.role, + }); + + AlbumUserRole role; + + @override + bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserDto && + other.role == role; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (role.hashCode); + + @override + String toString() => 'UpdateAlbumUserDto[role=$role]'; + + Map toJson() { + final json = {}; + json[r'role'] = this.role; + return json; + } + + /// Returns a new [UpdateAlbumUserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UpdateAlbumUserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UpdateAlbumUserDto( + role: AlbumUserRole.fromJson(json[r'role'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UpdateAlbumUserDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UpdateAlbumUserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UpdateAlbumUserDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UpdateAlbumUserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'role', + }; +} + diff --git a/mobile/openapi/test/add_users_dto_test.dart b/mobile/openapi/test/add_users_dto_test.dart index 3dadfd8b4d..0c3bbb759d 100644 --- a/mobile/openapi/test/add_users_dto_test.dart +++ b/mobile/openapi/test/add_users_dto_test.dart @@ -16,6 +16,12 @@ void main() { // final instance = AddUsersDto(); group('test AddUsersDto', () { + // List albumUsers (default value: const []) + test('to test the property `albumUsers`', () async { + // TODO + }); + + // Deprecated in favor of albumUsers // List sharedUserIds (default value: const []) test('to test the property `sharedUserIds`', () async { // TODO diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index aee26e095d..1a6d3ab3bb 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -67,5 +67,10 @@ void main() { // TODO }); + //Future updateAlbumUser(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto) async + test('test updateAlbumUser', () async { + // TODO + }); + }); } diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index 5c79e5d2fc..12218d3829 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // List albumUsers (default value: const []) + test('to test the property `albumUsers`', () async { + // TODO + }); + // int assetCount test('to test the property `assetCount`', () async { // TODO @@ -91,6 +96,7 @@ void main() { // TODO }); + // Deprecated in favor of albumUsers // List sharedUsers (default value: const []) test('to test the property `sharedUsers`', () async { // TODO diff --git a/mobile/openapi/test/album_user_add_dto_test.dart b/mobile/openapi/test/album_user_add_dto_test.dart new file mode 100644 index 0000000000..3f315ea2bb --- /dev/null +++ b/mobile/openapi/test/album_user_add_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for AlbumUserAddDto +void main() { + // final instance = AlbumUserAddDto(); + + group('test AlbumUserAddDto', () { + // AlbumUserRole role + test('to test the property `role`', () async { + // TODO + }); + + // String userId + test('to test the property `userId`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/album_user_response_dto_test.dart b/mobile/openapi/test/album_user_response_dto_test.dart new file mode 100644 index 0000000000..19f15a305d --- /dev/null +++ b/mobile/openapi/test/album_user_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for AlbumUserResponseDto +void main() { + // final instance = AlbumUserResponseDto(); + + group('test AlbumUserResponseDto', () { + // AlbumUserRole role + test('to test the property `role`', () async { + // TODO + }); + + // UserResponseDto user + test('to test the property `user`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/album_user_role_test.dart b/mobile/openapi/test/album_user_role_test.dart new file mode 100644 index 0000000000..bc09896215 --- /dev/null +++ b/mobile/openapi/test/album_user_role_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for AlbumUserRole +void main() { + + group('test AlbumUserRole', () { + + }); + +} diff --git a/mobile/openapi/test/update_album_user_dto_test.dart b/mobile/openapi/test/update_album_user_dto_test.dart new file mode 100644 index 0000000000..a42ca38b2c --- /dev/null +++ b/mobile/openapi/test/update_album_user_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for UpdateAlbumUserDto +void main() { + // final instance = UpdateAlbumUserDto(); + + group('test UpdateAlbumUserDto', () { + // AlbumUserRole role + test('to test the property `role`', () async { + // TODO + }); + + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c2483d690c..5905f2ccfc 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -630,6 +630,57 @@ "tags": [ "Album" ] + }, + "put": { + "operationId": "updateAlbumUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAlbumUserDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Album" + ] } }, "/album/{id}/users": { @@ -7251,7 +7302,15 @@ }, "AddUsersDto": { "properties": { + "albumUsers": { + "items": { + "$ref": "#/components/schemas/AlbumUserAddDto" + }, + "type": "array" + }, "sharedUserIds": { + "deprecated": true, + "description": "Deprecated in favor of albumUsers", "items": { "format": "uuid", "type": "string" @@ -7260,7 +7319,7 @@ } }, "required": [ - "sharedUserIds" + "albumUsers" ], "type": "object" }, @@ -7303,6 +7362,12 @@ "nullable": true, "type": "string" }, + "albumUsers": { + "items": { + "$ref": "#/components/schemas/AlbumUserResponseDto" + }, + "type": "array" + }, "assetCount": { "type": "integer" }, @@ -7349,6 +7414,8 @@ "type": "boolean" }, "sharedUsers": { + "deprecated": true, + "description": "Deprecated in favor of albumUsers", "items": { "$ref": "#/components/schemas/UserResponseDto" }, @@ -7366,6 +7433,7 @@ "required": [ "albumName", "albumThumbnailAssetId", + "albumUsers", "assetCount", "assets", "createdAt", @@ -7381,6 +7449,43 @@ ], "type": "object" }, + "AlbumUserAddDto": { + "properties": { + "role": { + "$ref": "#/components/schemas/AlbumUserRole" + }, + "userId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "userId" + ], + "type": "object" + }, + "AlbumUserResponseDto": { + "properties": { + "role": { + "$ref": "#/components/schemas/AlbumUserRole" + }, + "user": { + "$ref": "#/components/schemas/UserResponseDto" + } + }, + "required": [ + "role", + "user" + ], + "type": "object" + }, + "AlbumUserRole": { + "enum": [ + "editor", + "viewer" + ], + "type": "string" + }, "AllJobStatusResponseDto": { "properties": { "backgroundTask": { @@ -11190,6 +11295,17 @@ }, "type": "object" }, + "UpdateAlbumUserDto": { + "properties": { + "role": { + "$ref": "#/components/schemas/AlbumUserRole" + } + }, + "required": [ + "role" + ], + "type": "object" + }, "UpdateAssetDto": { "properties": { "dateTimeOriginal": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 41603bc0e8..a517cb6602 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -38,6 +38,28 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type UserResponseDto = { + avatarColor: UserAvatarColor; + createdAt: string; + deletedAt: string | null; + email: string; + id: string; + isAdmin: boolean; + memoriesEnabled?: boolean; + name: string; + oauthId: string; + profileImagePath: string; + quotaSizeInBytes: number | null; + quotaUsageInBytes: number | null; + shouldChangePassword: boolean; + status: UserStatus; + storageLabel: string | null; + updatedAt: string; +}; +export type AlbumUserResponseDto = { + role: AlbumUserRole; + user: UserResponseDto; +}; export type ExifResponseDto = { city?: string | null; country?: string | null; @@ -61,24 +83,6 @@ export type ExifResponseDto = { state?: string | null; timeZone?: string | null; }; -export type UserResponseDto = { - avatarColor: UserAvatarColor; - createdAt: string; - deletedAt: string | null; - email: string; - id: string; - isAdmin: boolean; - memoriesEnabled?: boolean; - name: string; - oauthId: string; - profileImagePath: string; - quotaSizeInBytes: number | null; - quotaUsageInBytes: number | null; - shouldChangePassword: boolean; - status: UserStatus; - storageLabel: string | null; - updatedAt: string; -}; export type AssetFaceWithoutPersonResponseDto = { boundingBoxX1: number; boundingBoxX2: number; @@ -144,6 +148,7 @@ export type AssetResponseDto = { export type AlbumResponseDto = { albumName: string; albumThumbnailAssetId: string | null; + albumUsers: AlbumUserResponseDto[]; assetCount: number; assets: AssetResponseDto[]; createdAt: string; @@ -157,6 +162,7 @@ export type AlbumResponseDto = { owner: UserResponseDto; ownerId: string; shared: boolean; + /** Deprecated in favor of albumUsers */ sharedUsers: UserResponseDto[]; startDate?: string; updatedAt: string; @@ -187,8 +193,17 @@ export type BulkIdResponseDto = { id: string; success: boolean; }; +export type UpdateAlbumUserDto = { + role: AlbumUserRole; +}; +export type AlbumUserAddDto = { + role?: AlbumUserRole; + userId: string; +}; export type AddUsersDto = { - sharedUserIds: string[]; + albumUsers: AlbumUserAddDto[]; + /** Deprecated in favor of albumUsers */ + sharedUserIds?: string[]; }; export type ApiKeyResponseDto = { createdAt: string; @@ -1209,6 +1224,17 @@ export function removeUserFromAlbum({ id, userId }: { method: "DELETE" })); } +export function updateAlbumUser({ id, userId, updateAlbumUserDto }: { + id: string; + userId: string; + updateAlbumUserDto: UpdateAlbumUserDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/album/${encodeURIComponent(id)}/user/${encodeURIComponent(userId)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: updateAlbumUserDto + }))); +} export function addUsersToAlbum({ id, addUsersDto }: { id: string; addUsersDto: AddUsersDto; @@ -2927,6 +2953,10 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum AlbumUserRole { + Editor = "editor", + Viewer = "viewer" +} export enum UserStatus { Active = "active", Removing = "removing", diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index c4b11fbb4c..0e8d954ab8 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -8,6 +8,7 @@ import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, + UpdateAlbumUserDto, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -88,6 +89,16 @@ export class AlbumController { return this.service.addUsers(auth, id, dto); } + @Put(':id/user/:userId') + updateAlbumUser( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string, + @Body() dto: UpdateAlbumUserDto, + ): Promise { + return this.service.updateUser(auth, id, userId, dto); + } + @Delete(':id/user/:userId') removeUserFromAlbum( @Auth() auth: AuthDto, diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index 72644870d3..6f8930d05a 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -1,5 +1,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AlbumUserRole } from 'src/entities/album-user.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { setDifference, setIsEqual, setUnion } from 'src/utils/set'; @@ -22,6 +23,7 @@ export enum Permission { ALBUM_READ = 'album.read', ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', + ALBUM_ADD_ASSET = 'album.addAsset', ALBUM_REMOVE_ASSET = 'album.removeAsset', ALBUM_SHARE = 'album.share', ALBUM_DOWNLOAD = 'album.download', @@ -142,6 +144,12 @@ export class AccessCore { : new Set(); } + case Permission.ALBUM_ADD_ASSET: { + return sharedLink.allowUpload + ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) + : new Set(); + } + default: { return new Set(); } @@ -215,7 +223,21 @@ export class AccessCore { case Permission.ALBUM_READ: { const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isShared = await this.repository.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_ADD_ASSET: { + const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await this.repository.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); return setUnion(isOwner, isShared); } @@ -233,12 +255,22 @@ export class AccessCore { case Permission.ALBUM_DOWNLOAD: { const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isShared = await this.repository.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); return setUnion(isOwner, isShared); } case Permission.ALBUM_REMOVE_ASSET: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); + const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await this.repository.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); } case Permission.ASSET_UPLOAD: { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 3f7af0f538..0f96e52b12 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -1,8 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayNotEmpty, IsEnum, IsString } from 'class-validator'; +import _ from 'lodash'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { AlbumUserRole } from 'src/entities/album-user.entity'; import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; @@ -11,10 +13,23 @@ export class AlbumInfoDto { withoutAssets?: boolean; } +export class AlbumUserAddDto { + @ValidateUUID() + userId!: string; + + @IsEnum(AlbumUserRole) + @ApiProperty({ enum: AlbumUserRole, enumName: 'AlbumUserRole', default: AlbumUserRole.EDITOR }) + role?: AlbumUserRole; +} + export class AddUsersDto { - @ValidateUUID({ each: true }) + @ValidateUUID({ each: true, optional: true }) @ArrayNotEmpty() - sharedUserIds!: string[]; + @ApiProperty({ deprecated: true, description: 'Deprecated in favor of albumUsers' }) + sharedUserIds?: string[]; + + @ArrayNotEmpty() + albumUsers!: AlbumUserAddDto[]; } export class CreateAlbumDto { @@ -83,6 +98,18 @@ export class AlbumCountResponseDto { notShared!: number; } +export class UpdateAlbumUserDto { + @IsEnum(AlbumUserRole) + @ApiProperty({ enum: AlbumUserRole, enumName: 'AlbumUserRole' }) + role!: AlbumUserRole; +} + +export class AlbumUserResponseDto { + user!: UserResponseDto; + @ApiProperty({ enum: AlbumUserRole, enumName: 'AlbumUserRole' }) + role!: AlbumUserRole; +} + export class AlbumResponseDto { id!: string; ownerId!: string; @@ -92,7 +119,9 @@ export class AlbumResponseDto { updatedAt!: Date; albumThumbnailAssetId!: string | null; shared!: boolean; + @ApiProperty({ deprecated: true, description: 'Deprecated in favor of albumUsers' }) sharedUsers!: UserResponseDto[]; + albumUsers!: AlbumUserResponseDto[]; hasSharedLink!: boolean; assets!: AssetResponseDto[]; owner!: UserResponseDto; @@ -109,13 +138,21 @@ export class AlbumResponseDto { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { const sharedUsers: UserResponseDto[] = []; + const albumUsers: AlbumUserResponseDto[] = []; - if (entity.sharedUsers) { - for (const user of entity.sharedUsers) { - sharedUsers.push(mapUser(user)); + if (entity.albumUsers) { + for (const albumUser of entity.albumUsers) { + const user = mapUser(albumUser.user); + sharedUsers.push(user); + albumUsers.push({ + user, + role: albumUser.role, + }); } } + const albumUsersSorted = _.orderBy(albumUsers, ['role', 'user.name']); + const assets = entity.assets || []; const hasSharedLink = entity.sharedLinks?.length > 0; @@ -138,6 +175,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt ownerId: entity.ownerId, owner: mapUser(entity.owner), sharedUsers, + albumUsers: albumUsersSorted, shared: hasSharedUser || hasSharedLink, hasSharedLink, startDate, diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts new file mode 100644 index 0000000000..66ed58c4f1 --- /dev/null +++ b/server/src/entities/album-user.entity.ts @@ -0,0 +1,31 @@ +import { AlbumEntity } from 'src/entities/album.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; + +export enum AlbumUserRole { + EDITOR = 'editor', + VIEWER = 'viewer', +} + +@Entity('albums_shared_users_users') +// Pre-existing indices from original album <--> user ManyToMany mapping +@Index('IDX_427c350ad49bd3935a50baab73', ['album']) +@Index('IDX_f48513bf9bccefd6ff3ad30bd0', ['user']) +export class AlbumUserEntity { + @PrimaryColumn({ type: 'uuid', name: 'albumsId' }) + albumId!: string; + + @PrimaryColumn({ type: 'uuid', name: 'usersId' }) + userId!: string; + + @JoinColumn({ name: 'albumsId' }) + @ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + album!: AlbumEntity; + + @JoinColumn({ name: 'usersId' }) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + user!: UserEntity; + + @Column({ type: 'varchar', default: AlbumUserRole.EDITOR }) + role!: AlbumUserRole; +} diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 99fae4f23d..39d5b72bf2 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -1,3 +1,4 @@ +import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; @@ -52,9 +53,8 @@ export class AlbumEntity { @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true }) albumThumbnailAssetId!: string | null; - @ManyToMany(() => UserEntity) - @JoinTable() - sharedUsers!: UserEntity[]; + @OneToMany(() => AlbumUserEntity, ({ album }) => album, { cascade: true, onDelete: 'CASCADE' }) + albumUsers!: AlbumUserEntity[]; @ManyToMany(() => AssetEntity, (asset) => asset.albums) @JoinTable() diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 59aa907199..0862dd48a2 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -1,4 +1,5 @@ import { ActivityEntity } from 'src/entities/activity.entity'; +import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { APIKeyEntity } from 'src/entities/api-key.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -25,6 +26,7 @@ import { UserEntity } from 'src/entities/user.entity'; export const entities = [ ActivityEntity, AlbumEntity, + AlbumUserEntity, APIKeyEntity, AssetEntity, AssetStackEntity, diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 8b9bdcc4b5..e07b877b66 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -1,3 +1,5 @@ +import { AlbumUserRole } from 'src/entities/album-user.entity'; + export const IAccessRepository = 'IAccessRepository'; export interface IAccessRepository { @@ -20,7 +22,7 @@ export interface IAccessRepository { album: { checkOwnerAccess(userId: string, albumIds: Set): Promise>; - checkSharedAlbumAccess(userId: string, albumIds: Set): Promise>; + checkSharedAlbumAccess(userId: string, albumIds: Set, access: AlbumUserRole): Promise>; checkSharedLinkAccess(sharedLinkId: string, albumIds: Set): Promise>; }; diff --git a/server/src/interfaces/album-user.interface.ts b/server/src/interfaces/album-user.interface.ts new file mode 100644 index 0000000000..d5742ad788 --- /dev/null +++ b/server/src/interfaces/album-user.interface.ts @@ -0,0 +1,14 @@ +import { AlbumUserEntity } from 'src/entities/album-user.entity'; + +export const IAlbumUserRepository = 'IAlbumUserRepository'; + +export type AlbumPermissionId = { + albumId: string; + userId: string; +}; + +export interface IAlbumUserRepository { + create(albumUser: Partial): Promise; + update({ userId, albumId }: AlbumPermissionId, albumPermission: Partial): Promise; + delete({ userId, albumId }: AlbumPermissionId): Promise; +} diff --git a/server/src/migrations/1713337511945-AddAlbumUserRole.ts b/server/src/migrations/1713337511945-AddAlbumUserRole.ts new file mode 100644 index 0000000000..a8d0d3d685 --- /dev/null +++ b/server/src/migrations/1713337511945-AddAlbumUserRole.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAlbumUserRole1713337511945 implements MigrationInterface { + name = 'AddAlbumUserRole1713337511945' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" ADD "role" character varying NOT NULL DEFAULT 'editor'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP COLUMN "role"`); + } + +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 3c6eca7270..52cf28c77c 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -37,16 +37,16 @@ SELECT "album"."id" AS "album_id" FROM "albums" "album" - LEFT JOIN "albums_shared_users_users" "album_sharedUsers" ON "album_sharedUsers"."albumsId" = "album"."id" - LEFT JOIN "users" "sharedUsers" ON "sharedUsers"."id" = "album_sharedUsers"."usersId" - AND ("sharedUsers"."deletedAt" IS NULL) + LEFT JOIN "albums_shared_users_users" "album_albumUsers_users" ON "album_albumUsers_users"."albumsId" = "album"."id" + LEFT JOIN "users" "albumUsers" ON "albumUsers"."id" = "album_albumUsers_users"."usersId" + AND ("albumUsers"."deletedAt" IS NULL) WHERE ( "album"."id" IN ($1) AND "album"."isActivityEnabled" = true AND ( "album"."ownerId" = $2 - OR "sharedUsers"."id" = $2 + OR "albumUsers"."id" = $2 ) ) AND ("album"."deletedAt" IS NULL) @@ -70,10 +70,10 @@ SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" + LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) WHERE ( @@ -81,7 +81,16 @@ WHERE ("AlbumEntity"."id" IN ($1)) AND ( ( - ("AlbumEntity__AlbumEntity_sharedUsers"."id" = $2) + ( + ( + ( + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = $2 + ) + ) + ) + AND ( + "AlbumEntity__AlbumEntity_albumUsers"."role" IN ($3, $4) + ) ) ) ) @@ -109,15 +118,15 @@ FROM INNER JOIN "albums_assets_assets" "album_asset" ON "album_asset"."albumsId" = "album"."id" INNER JOIN "assets" "asset" ON "asset"."id" = "album_asset"."assetsId" AND ("asset"."deletedAt" IS NULL) - LEFT JOIN "albums_shared_users_users" "album_sharedUsers" ON "album_sharedUsers"."albumsId" = "album"."id" - LEFT JOIN "users" "sharedUsers" ON "sharedUsers"."id" = "album_sharedUsers"."usersId" - AND ("sharedUsers"."deletedAt" IS NULL) + LEFT JOIN "albums_shared_users_users" "album_albumUsers_users" ON "album_albumUsers_users"."albumsId" = "album"."id" + LEFT JOIN "users" "albumUsers" ON "albumUsers"."id" = "album_albumUsers_users"."usersId" + AND ("albumUsers"."deletedAt" IS NULL) WHERE ( array["asset"."id", "asset"."livePhotoVideoId"] && array[$1]::uuid [] AND ( "album"."ownerId" = $2 - OR "sharedUsers"."id" = $2 + OR "albumUsers"."id" = $2 ) ) AND ("album"."deletedAt" IS NULL) diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 50f775d2f5..2037e320a1 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -32,22 +32,25 @@ FROM "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", - "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", - "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", - "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", - "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", - "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", - "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", - "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", - "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", - "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", - "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", + "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", + "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -66,10 +69,10 @@ FROM AND ( "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL ) - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" + LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id" WHERE @@ -109,32 +112,35 @@ SELECT "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", - "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", - "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", - "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", - "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", - "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", - "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", - "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", - "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", - "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", - "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", + "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", + "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" FROM "albums" "AlbumEntity" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" AND ( "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL ) - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" + LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) WHERE ((("AlbumEntity"."id" IN ($1)))) @@ -168,32 +174,35 @@ SELECT "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", - "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", - "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", - "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", - "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", - "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", - "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", - "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", - "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", - "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", - "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", + "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", + "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" FROM "albums" "AlbumEntity" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" AND ( "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL ) - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" + LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId" = "AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" @@ -213,7 +222,9 @@ WHERE ( ( ( - ("AlbumEntity__AlbumEntity_sharedUsers"."id" = $3) + ( + "AlbumEntity__AlbumEntity_albumUsers"."usersId" = $3 + ) ) ) AND ((("AlbumEntity__AlbumEntity_assets"."id" = $4))) @@ -283,22 +294,25 @@ SELECT "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", - "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", - "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", - "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", - "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", - "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", - "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", - "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", - "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", - "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", - "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", + "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", + "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -329,10 +343,10 @@ SELECT "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM "albums" "AlbumEntity" - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" + LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" @@ -357,22 +371,25 @@ SELECT "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", - "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", - "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", - "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", - "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", - "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", - "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", - "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", - "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", - "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", - "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", + "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", + "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -403,10 +420,10 @@ SELECT "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM "albums" "AlbumEntity" - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" + LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" @@ -420,7 +437,9 @@ WHERE ( ( ( - ("AlbumEntity__AlbumEntity_sharedUsers"."id" = $1) + ( + "AlbumEntity__AlbumEntity_albumUsers"."usersId" = $1 + ) ) ) ) @@ -443,7 +462,7 @@ WHERE ( ( NOT ( - "AlbumEntity__AlbumEntity_sharedUsers"."id" IS NULL + "AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL ) ) ) @@ -468,22 +487,9 @@ SELECT "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", - "AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", - "AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", - "AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", - "AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", - "AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", - "AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", - "AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", - "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", - "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", - "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", - "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", + "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", + "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -514,11 +520,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM "albums" "AlbumEntity" - LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" - AND ( - "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL - ) + LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" AND ( @@ -531,7 +533,7 @@ WHERE AND ( ( ( - "AlbumEntity__AlbumEntity_sharedUsers"."id" IS NULL + "AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL ) ) ) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index a624e8bfdc..992f8f143f 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { ActivityEntity } from 'src/entities/activity.entity'; +import { AlbumUserRole } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -81,12 +82,13 @@ class ActivityAccess implements IActivityAccess { return this.albumRepository .createQueryBuilder('album') .select('album.id') - .leftJoin('album.sharedUsers', 'sharedUsers') + .leftJoin('album.albumUsers', 'album_albumUsers_users') + .leftJoin('album_albumUsers_users.user', 'albumUsers') .where('album.id IN (:...albumIds)', { albumIds: [...albumIds] }) .andWhere('album.isActivityEnabled = true') .andWhere( new Brackets((qb) => { - qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId }); + qb.where('album.ownerId = :userId', { userId }).orWhere('albumUsers.id = :userId', { userId }); }), ) .getMany() @@ -120,7 +122,7 @@ class AlbumAccess implements IAlbumAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkSharedAlbumAccess(userId: string, albumIds: Set): Promise> { + async checkSharedAlbumAccess(userId: string, albumIds: Set, access: AlbumUserRole): Promise> { if (albumIds.size === 0) { return new Set(); } @@ -130,8 +132,11 @@ class AlbumAccess implements IAlbumAccess { select: { id: true }, where: { id: In([...albumIds]), - sharedUsers: { - id: userId, + albumUsers: { + user: { id: userId }, + // If editor access is needed we check for it, otherwise both are accepted + role: + access === AlbumUserRole.EDITOR ? AlbumUserRole.EDITOR : In([AlbumUserRole.EDITOR, AlbumUserRole.VIEWER]), }, }, }) @@ -177,7 +182,8 @@ class AssetAccess implements IAssetAccess { return this.albumRepository .createQueryBuilder('album') .innerJoin('album.assets', 'asset') - .leftJoin('album.sharedUsers', 'sharedUsers') + .leftJoin('album.albumUsers', 'album_albumUsers_users') + .leftJoin('album_albumUsers_users.user', 'albumUsers') .select('asset.id', 'assetId') .addSelect('asset.livePhotoVideoId', 'livePhotoVideoId') .where('array["asset"."id", "asset"."livePhotoVideoId"] && array[:...assetIds]::uuid[]', { @@ -185,7 +191,7 @@ class AssetAccess implements IAssetAccess { }) .andWhere( new Brackets((qb) => { - qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId }); + qb.where('album.ownerId = :userId', { userId }).orWhere('albumUsers.id = :userId', { userId }); }), ) .getRawMany() diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts new file mode 100644 index 0000000000..7fd18711aa --- /dev/null +++ b/server/src/repositories/album-user.repository.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AlbumUserEntity } from 'src/entities/album-user.entity'; +import { AlbumPermissionId, IAlbumUserRepository } from 'src/interfaces/album-user.interface'; +import { Instrumentation } from 'src/utils/instrumentation'; +import { Repository } from 'typeorm'; + +@Instrumentation() +@Injectable() +export class AlbumUserRepository implements IAlbumUserRepository { + constructor(@InjectRepository(AlbumUserEntity) private repository: Repository) {} + + async create(albumUser: Partial): Promise { + const { userId, albumId } = await this.repository.save(albumUser); + return this.repository.findOneOrFail({ where: { userId, albumId } }); + } + + async update({ userId, albumId }: AlbumPermissionId, dto: Partial): Promise { + await this.repository.update({ userId, albumId }, dto); + return this.repository.findOneOrFail({ + where: { userId, albumId }, + }); + } + + async delete({ userId, albumId }: AlbumPermissionId): Promise { + await this.repository.delete({ userId, albumId }); + } +} diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index bbaab2a12b..536c9b666d 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -10,6 +10,13 @@ import { Instrumentation } from 'src/utils/instrumentation'; import { setUnion } from 'src/utils/set'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; +const withoutDeletedUsers = (album: T) => { + if (album) { + album.albumUsers = album.albumUsers.filter((albumUser) => albumUser.user && !albumUser.user.deletedAt); + } + return album; +}; + @Instrumentation() @Injectable() export class AlbumRepository implements IAlbumRepository { @@ -20,10 +27,10 @@ export class AlbumRepository implements IAlbumRepository { ) {} @GenerateSql({ params: [DummyValue.UUID, {}] }) - getById(id: string, options: AlbumInfoOptions): Promise { + async getById(id: string, options: AlbumInfoOptions): Promise { const relations: FindOptionsRelations = { owner: true, - sharedUsers: true, + albumUsers: { user: true }, assets: false, sharedLinks: true, }; @@ -40,33 +47,38 @@ export class AlbumRepository implements IAlbumRepository { }; } - return this.repository.findOne({ where: { id }, relations, order }); + const album = await this.repository.findOne({ where: { id }, relations, order }); + return withoutDeletedUsers(album); } @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() - getByIds(ids: string[]): Promise { - return this.repository.find({ + async getByIds(ids: string[]): Promise { + const albums = await this.repository.find({ where: { id: In(ids), }, relations: { owner: true, - sharedUsers: true, + albumUsers: { user: true }, }, }); + + return albums.map((album) => withoutDeletedUsers(album)); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - getByAssetId(ownerId: string, assetId: string): Promise { - return this.repository.find({ + async getByAssetId(ownerId: string, assetId: string): Promise { + const albums = await this.repository.find({ where: [ { ownerId, assets: { id: assetId } }, - { sharedUsers: { id: ownerId }, assets: { id: assetId } }, + { albumUsers: { userId: ownerId }, assets: { id: assetId } }, ], - relations: { owner: true, sharedUsers: true }, + relations: { owner: true, albumUsers: { user: true } }, order: { createdAt: 'DESC' }, }); + + return albums.map((album) => withoutDeletedUsers(album)); } @GenerateSql({ params: [[DummyValue.UUID]] }) @@ -127,40 +139,46 @@ export class AlbumRepository implements IAlbumRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getOwned(ownerId: string): Promise { - return this.repository.find({ - relations: { sharedUsers: true, sharedLinks: true, owner: true }, + async getOwned(ownerId: string): Promise { + const albums = await this.repository.find({ + relations: { albumUsers: { user: true }, sharedLinks: true, owner: true }, where: { ownerId }, order: { createdAt: 'DESC' }, }); + + return albums.map((album) => withoutDeletedUsers(album)); } /** * Get albums shared with and shared by owner. */ @GenerateSql({ params: [DummyValue.UUID] }) - getShared(ownerId: string): Promise { - return this.repository.find({ - relations: { sharedUsers: true, sharedLinks: true, owner: true }, + async getShared(ownerId: string): Promise { + const albums = await this.repository.find({ + relations: { albumUsers: { user: true }, sharedLinks: true, owner: true }, where: [ - { sharedUsers: { id: ownerId } }, + { albumUsers: { userId: ownerId } }, { sharedLinks: { userId: ownerId } }, - { ownerId, sharedUsers: { id: Not(IsNull()) } }, + { ownerId, albumUsers: { user: Not(IsNull()) } }, ], order: { createdAt: 'DESC' }, }); + + return albums.map((album) => withoutDeletedUsers(album)); } /** * Get albums of owner that are _not_ shared */ @GenerateSql({ params: [DummyValue.UUID] }) - getNotShared(ownerId: string): Promise { - return this.repository.find({ - relations: { sharedUsers: true, sharedLinks: true, owner: true }, - where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } }, + async getNotShared(ownerId: string): Promise { + const albums = await this.repository.find({ + relations: { albumUsers: true, sharedLinks: true, owner: true }, + where: { ownerId, albumUsers: { user: IsNull() }, sharedLinks: { id: IsNull() } }, order: { createdAt: 'DESC' }, }); + + return albums.map((album) => withoutDeletedUsers(album)); } async restoreAll(userId: string): Promise { @@ -282,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository { where: { id }, relations: { owner: true, - sharedUsers: true, + albumUsers: { user: true }, sharedLinks: true, assets: true, }, diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 6ab09ac746..712e925cf2 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,5 +1,6 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; @@ -31,6 +32,7 @@ import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetStackRepository } from 'src/repositories/asset-stack.repository'; @@ -65,6 +67,7 @@ export const repositories = [ { provide: IActivityRepository, useClass: ActivityRepository }, { provide: IAccessRepository, useClass: AccessRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, + { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, { provide: IAssetStackRepository, useClass: AssetStackRepository }, diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 78ee92395d..3a050cd594 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,6 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { AlbumUserRole } from 'src/entities/album-user.entity'; +import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -9,6 +11,7 @@ import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; @@ -20,14 +23,16 @@ describe(AlbumService.name, () => { let albumMock: Mocked; let assetMock: Mocked; let userMock: Mocked; + let albumUserMock: Mocked; beforeEach(() => { accessMock = newAccessRepositoryMock(); albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); userMock = newUserRepositoryMock(); + albumUserMock = newAlbumUserRepositoryMock(); - sut = new AlbumService(accessMock, albumMock, assetMock, userMock); + sut = new AlbumService(accessMock, albumMock, assetMock, userMock, albumUserMock); }); it('should work', () => { @@ -189,7 +194,7 @@ describe(AlbumService.name, () => { ownerId: authStub.admin.user.id, albumName: albumStub.empty.albumName, description: albumStub.empty.description, - sharedUsers: [{ id: 'user-id' }], + albumUsers: [{ user: { id: 'user-id' } }], assets: [{ id: '123' }], albumThumbnailAssetId: '123', }); @@ -225,7 +230,7 @@ describe(AlbumService.name, () => { ownerId: authStub.admin.user.id, albumName: 'Test album', description: '', - sharedUsers: [], + albumUsers: [], assets: [{ id: 'asset-1' }], albumThumbnailAssetId: 'asset-1', }); @@ -327,7 +332,7 @@ describe(AlbumService.name, () => { describe('addUsers', () => { it('should throw an error if the auth user is not the owner', async () => { await expect( - sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }), + sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-1' }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); }); @@ -336,7 +341,9 @@ describe(AlbumService.name, () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( - sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.user.id] }), + sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { + albumUsers: [{ userId: authStub.admin.user.id }], + }), ).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); }); @@ -346,7 +353,7 @@ describe(AlbumService.name, () => { albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(null); await expect( - sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }), + sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); }); @@ -356,11 +363,19 @@ describe(AlbumService.name, () => { albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(userStub.user2); - await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.user.id] }); - expect(albumMock.update).toHaveBeenCalledWith({ - id: albumStub.sharedWithAdmin.id, - updatedAt: expect.any(Date), - sharedUsers: [userStub.admin, { id: authStub.user2.user.id }], + albumUserMock.create.mockResolvedValue({ + userId: userStub.user2.id, + user: userStub.user2, + albumId: albumStub.sharedWithAdmin.id, + album: albumStub.sharedWithAdmin, + role: AlbumUserRole.EDITOR, + }); + await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { + albumUsers: [{ userId: authStub.user2.user.id }], + }); + expect(albumUserMock.create).toHaveBeenCalledWith({ + userId: authStub.user2.user.id, + albumId: albumStub.sharedWithAdmin.id, }); }); }); @@ -381,11 +396,10 @@ describe(AlbumService.name, () => { sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), ).resolves.toBeUndefined(); - expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith({ - id: albumStub.sharedWithUser.id, - updatedAt: expect.any(Date), - sharedUsers: [], + expect(albumUserMock.delete).toHaveBeenCalledTimes(1); + expect(albumUserMock.delete).toHaveBeenCalledWith({ + albumId: albumStub.sharedWithUser.id, + userId: userStub.user1.id, }); expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); }); @@ -397,7 +411,7 @@ describe(AlbumService.name, () => { sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id), ).rejects.toBeInstanceOf(BadRequestException); - expect(albumMock.update).not.toHaveBeenCalled(); + expect(albumUserMock.delete).not.toHaveBeenCalled(); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set([albumStub.sharedWithMultiple.id]), @@ -409,11 +423,10 @@ describe(AlbumService.name, () => { await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id); - expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith({ - id: albumStub.sharedWithUser.id, - updatedAt: expect.any(Date), - sharedUsers: [], + expect(albumUserMock.delete).toHaveBeenCalledTimes(1); + expect(albumUserMock.delete).toHaveBeenCalledWith({ + albumId: albumStub.sharedWithUser.id, + userId: authStub.user1.user.id, }); }); @@ -422,11 +435,10 @@ describe(AlbumService.name, () => { await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); - expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith({ - id: albumStub.sharedWithUser.id, - updatedAt: expect.any(Date), - sharedUsers: [], + expect(albumUserMock.delete).toHaveBeenCalledTimes(1); + expect(albumUserMock.delete).toHaveBeenCalledWith({ + albumId: albumStub.sharedWithUser.id, + userId: authStub.user1.user.id, }); }); @@ -512,6 +524,7 @@ describe(AlbumService.name, () => { expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set(['album-123']), + AlbumUserRole.VIEWER, ); }); @@ -522,6 +535,7 @@ describe(AlbumService.name, () => { expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['album-123']), + AlbumUserRole.VIEWER, ); }); }); @@ -589,6 +603,17 @@ describe(AlbumService.name, () => { expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); }); + it('should not allow a shared user with viewer access to add assets', async () => { + accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set([])); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + + await expect( + sut.addAssets(authStub.user2, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + it('should allow a shared link user to add assets', async () => { accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); @@ -709,7 +734,7 @@ describe(AlbumService.name, () => { expect(albumMock.update).not.toHaveBeenCalled(); }); - it('should skip assets without user permission to remove', async () => { + it('should skip assets when user has remove permission on album but not on asset', async () => { accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index b3b7f6d08a..1cc049d851 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -14,10 +14,11 @@ import { } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AlbumUserEntity, AlbumUserRole } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -31,6 +32,7 @@ export class AlbumService { @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, ) { this.access = AccessCore.create(accessRepository); } @@ -126,7 +128,7 @@ export class AlbumService { ownerId: auth.user.id, albumName: dto.albumName, description: dto.description, - sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value }) as UserEntity) ?? [], + albumUsers: dto.sharedWithUserIds?.map((userId) => ({ user: { id: userId } }) as AlbumUserEntity) ?? [], assets, albumThumbnailAssetId: assets[0]?.id || null, }); @@ -167,7 +169,7 @@ export class AlbumService { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.access.requirePermission(auth, Permission.ALBUM_READ, id); + await this.access.requirePermission(auth, Permission.ALBUM_ADD_ASSET, id); const results = await addAssets( auth, @@ -190,7 +192,7 @@ export class AlbumService { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.access.requirePermission(auth, Permission.ALBUM_READ, id); + await this.access.requirePermission(auth, Permission.ALBUM_REMOVE_ASSET, id); const results = await removeAssets( auth, @@ -209,17 +211,25 @@ export class AlbumService { return results; } - async addUsers(auth: AuthDto, id: string, dto: AddUsersDto): Promise { + async addUsers(auth: AuthDto, id: string, { albumUsers, sharedUserIds }: AddUsersDto): Promise { + // Remove once deprecated sharedUserIds is removed + if (!albumUsers) { + if (!sharedUserIds) { + throw new BadRequestException('No users provided'); + } + albumUsers = sharedUserIds.map((userId) => ({ userId, role: AlbumUserRole.EDITOR })); + } + await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); const album = await this.findOrFail(id, { withAssets: false }); - for (const userId of dto.sharedUserIds) { + for (const { userId, role } of albumUsers) { if (album.ownerId === userId) { throw new BadRequestException('Cannot be shared with owner'); } - const exists = album.sharedUsers.find((user) => user.id === userId); + const exists = album.albumUsers.find(({ user: { id } }) => id === userId); if (exists) { throw new BadRequestException('User already added'); } @@ -229,16 +239,10 @@ export class AlbumService { throw new BadRequestException('User not found'); } - album.sharedUsers.push({ id: userId } as UserEntity); + await this.albumUserRepository.create({ userId: userId, albumId: id, role }); } - return this.albumRepository - .update({ - id: album.id, - updatedAt: new Date(), - sharedUsers: album.sharedUsers, - }) - .then(mapAlbumWithoutAssets); + return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); } async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise { @@ -252,7 +256,7 @@ export class AlbumService { throw new BadRequestException('Cannot remove album owner'); } - const exists = album.sharedUsers.find((user) => user.id === userId); + const exists = album.albumUsers.find(({ user: { id } }) => id === userId); if (!exists) { throw new BadRequestException('Album not shared with user'); } @@ -262,11 +266,13 @@ export class AlbumService { await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); } - await this.albumRepository.update({ - id: album.id, - updatedAt: new Date(), - sharedUsers: album.sharedUsers.filter((user) => user.id !== userId), - }); + await this.albumUserRepository.delete({ albumId: id, userId }); + } + + async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { + await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + + await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } private async findOrFail(id: string, options: AlbumInfoOptions) { diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index ff9348167d..f6047d522e 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -1,3 +1,4 @@ +import { AlbumUserRole } from 'src/entities/album-user.entity'; import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -17,7 +18,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -34,7 +35,15 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [userStub.user1], + albumUsers: [ + { + user: userStub.user1, + album: undefined as unknown as AlbumEntity, + role: AlbumUserRole.EDITOR, + userId: userStub.user1.id, + albumId: 'album-2', + }, + ], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -51,7 +60,22 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [userStub.user1, userStub.user2], + albumUsers: [ + { + user: userStub.user1, + album: undefined as unknown as AlbumEntity, + role: AlbumUserRole.EDITOR, + userId: userStub.user1.id, + albumId: 'album-3', + }, + { + user: userStub.user2, + album: undefined as unknown as AlbumEntity, + role: AlbumUserRole.EDITOR, + userId: userStub.user2.id, + albumId: 'album-3', + }, + ], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -68,7 +92,15 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [userStub.admin], + albumUsers: [ + { + user: userStub.admin, + album: undefined as unknown as AlbumEntity, + role: AlbumUserRole.EDITOR, + userId: userStub.admin.id, + albumId: 'album-3', + }, + ], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -85,7 +117,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -102,7 +134,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -119,7 +151,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -136,7 +168,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -153,7 +185,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), @@ -170,7 +202,7 @@ export const albumStub = { updatedAt: new Date(), deletedAt: null, sharedLinks: [], - sharedUsers: [], + albumUsers: [], isActivityEnabled: true, order: AssetOrder.DESC, }), diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index ce2b070672..c6fe89d6fe 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -467,6 +467,7 @@ export const assetStub = { library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 100_000, + timeZone: `America/New_York`, }, } as AssetEntity), @@ -483,6 +484,7 @@ export const assetStub = { library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 25_000, + timeZone: `America/New_York`, }, } as AssetEntity), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index ccd76c328e..aa785a2413 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -103,6 +103,7 @@ const albumResponse: AlbumResponseDto = { ownerId: 'admin_id', owner: mapUser(userStub.admin), sharedUsers: [], + albumUsers: [], shared: false, hasSharedLink: false, assets: [], @@ -186,7 +187,7 @@ export const sharedLinkStub = { deletedAt: null, albumThumbnailAsset: null, albumThumbnailAssetId: null, - sharedUsers: [], + albumUsers: [], sharedLinks: [], isActivityEnabled: true, order: AssetOrder.DESC, diff --git a/server/test/repositories/album-user.repository.mock.ts b/server/test/repositories/album-user.repository.mock.ts new file mode 100644 index 0000000000..70c0487256 --- /dev/null +++ b/server/test/repositories/album-user.repository.mock.ts @@ -0,0 +1,10 @@ +import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; +import { Mocked } from 'vitest'; + +export const newAlbumUserRepositoryMock = (): Mocked => { + return { + create: vitest.fn(), + delete: vitest.fn(), + update: vitest.fn(), + }; +}; diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index ef2a9ce3fe..0d3c287a22 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -1,7 +1,7 @@ {#if !selectedRemoveUser} @@ -78,7 +99,7 @@

Owner

- {#each album.sharedUsers as user} + {#each album.albumUsers as { user, role }}
@@ -87,7 +108,14 @@

{user.name}

-
+
+
+ {#if role === AlbumUserRole.Viewer} + Viewer + {:else} + Editor + {/if} +
{#if isOwned}
(selectedMenuUser = null)}> + {#if role === AlbumUserRole.Viewer} + handleSetReadonly(user, AlbumUserRole.Editor)} text="Allow edits" /> + {:else} + handleSetReadonly(user, AlbumUserRole.Viewer)} + text="Disallow edits" + /> + {/if} {/if} diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 6d66fb9706..ffe8adf480 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -1,27 +1,36 @@ - {#if selectedUsers.length > 0} -
-

To

- - {#each selectedUsers as user} - {#key user.id} - - {/key} - {/each} -
- {/if} - -
- {#if users.length > 0} -

SUGGESTIONS

- -
- {#each users as user} - + {/key} + {/each} +
+
+ {/if} + + {#if users.length + Object.keys(selectedUsers).length === 0} +

+ Looks like you have shared this album with all users or you don't have any user to share with. +

+ {/if} + +
+ {#if users.length > 0 && users.length !== Object.keys(selectedUsers).length} +

SUGGESTIONS

+ +
+ {#each users as user} + {#if !Object.keys(selectedUsers).includes(user.id)} +
+ +
+ {/if} {/each}
- {:else} -

- Looks like you have shared this album with all users or you don't have any user to share with. -

{/if}
@@ -117,8 +146,12 @@ size="sm" fullwidth rounded="full" - disabled={selectedUsers.length === 0} - on:click={() => dispatch('select', selectedUsers)}>Add + dispatch( + 'select', + Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), + )}>Add
{/if} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3879fc26f4..5a70c12910 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,9 @@