From 2943f9309894cf3e8f63ce5c36ce919bb5eb7410 Mon Sep 17 00:00:00 2001 From: mgabor <9047995+mgabor3141@users.noreply.github.com> Date: Thu, 25 Apr 2024 06:19:49 +0200 Subject: [PATCH] feat: readonly album sharing (#8720) * rename albums_shared_users_users to album_permissions and add readonly column * disable synchronize on the original join table * remove unnecessary FK names * set readonly=true as default for new album shares * separate and implement album READ and WRITE permission * expose albumPermissions on the API, deprecate sharedUsers * generate openapi * create readonly view on frontend * ??? move slideshow button out from ellipsis menu so that non-owners can have access too * correct sharedUsers joins * add album permission repository * remove a log * fix assetCount getting reset when adding users * fix lint * add set permission endpoint and UI * sort users * remove log * Revert "??? move slideshow button out from ellipsis menu so that non-owners can have access too" This reverts commit 1343bfa31125f7136f81db28f7aa4c5ef0204847. * rename stuff * fix db schema annotations * sql generate * change readonly default to follow migration * fix deprecation notice * change readonly boolean to role enum * fix joincolumn as primary key * rename albumUserRepository in album service * clean up userId and albumId * add write access to shared link * fix existing tests * switch to vitest * format and fix tests on web * add new test * fix one e2e test * rename new API field to albumUsers * capitalize serverside enum * remove unused ReadWrite type * missed rename from previous commit * rename to albumUsers in album entity as well * remove outdated Equals calls * unnecessary relation * rename to updateUser in album service * minor renamery * move sorting to backend * rename and separate ALBUM_WRITE as ADD_ASSET and REMOVE_ASSET * fix tests * fix "should migrate single moving picture" test failing on European system timezone * generated changes after merge * lint fix * fix correct page to open after removing user from album * fix e2e tests and some bugs * rename updateAlbumUser rest endpoint * add new e2e tests for updateAlbumUser endpoint * small optimizations * refactor album e2e test, add new album shared with viewer * add new test to check if viewer can see the album * add new e2e tests for readonly share * failing test: User delete doesn't cascade to UserAlbum entity * fix: handle deleted users * use lodash for sort * add role to addUsersToAlbum endpoint * add UI for adding editors * lint fixes * change role back to editor as DB default * fix server tests * redesign user selection modal editor selector * style tweaks * fix type error * Revert "style tweaks" This reverts commit ab604f4c8f3a6f12ab0b5fe2dd2ede723aa68775. * Revert "redesign user selection modal editor selector" This reverts commit e6f344856c6c05e4eb5c78f0dffb9f52498795f4. * chore: cleanup and improve add user modal * chore: open api * small styling --------- Co-authored-by: mgabor <> Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- e2e/src/api/specs/album.e2e-spec.ts | 180 +++++++++++-- e2e/src/utils.ts | 4 + mobile/openapi/.openapi-generator/FILES | 12 + mobile/openapi/README.md | Bin 26826 -> 27130 bytes mobile/openapi/doc/AddUsersDto.md | Bin 441 -> 577 bytes mobile/openapi/doc/AlbumApi.md | Bin 21403 -> 23641 bytes mobile/openapi/doc/AlbumResponseDto.md | Bin 1287 -> 1422 bytes mobile/openapi/doc/AlbumUserAddDto.md | Bin 0 -> 477 bytes mobile/openapi/doc/AlbumUserResponseDto.md | Bin 0 -> 500 bytes mobile/openapi/doc/AlbumUserRole.md | Bin 0 -> 379 bytes mobile/openapi/doc/UpdateAlbumUserDto.md | Bin 0 -> 439 bytes mobile/openapi/lib/api.dart | Bin 9254 -> 9411 bytes mobile/openapi/lib/api/album_api.dart | Bin 17226 -> 18730 bytes mobile/openapi/lib/api_client.dart | Bin 25237 -> 25592 bytes mobile/openapi/lib/api_helper.dart | Bin 6240 -> 6344 bytes mobile/openapi/lib/model/add_users_dto.dart | Bin 2911 -> 3243 bytes .../openapi/lib/model/album_response_dto.dart | Bin 9588 -> 9951 bytes .../openapi/lib/model/album_user_add_dto.dart | Bin 0 -> 3403 bytes .../lib/model/album_user_response_dto.dart | Bin 0 -> 3054 bytes mobile/openapi/lib/model/album_user_role.dart | Bin 0 -> 2625 bytes .../lib/model/update_album_user_dto.dart | Bin 0 -> 2808 bytes mobile/openapi/test/add_users_dto_test.dart | Bin 599 -> 786 bytes mobile/openapi/test/album_api_test.dart | Bin 1941 -> 2108 bytes .../openapi/test/album_response_dto_test.dart | Bin 2464 -> 2656 bytes .../openapi/test/album_user_add_dto_test.dart | Bin 0 -> 665 bytes .../test/album_user_response_dto_test.dart | Bin 0 -> 685 bytes mobile/openapi/test/album_user_role_test.dart | Bin 0 -> 423 bytes .../test/update_album_user_dto_test.dart | Bin 0 -> 577 bytes open-api/immich-openapi-specs.json | 118 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 68 +++-- server/src/controllers/album.controller.ts | 11 + server/src/cores/access.core.ts | 38 ++- server/src/dtos/album.dto.ts | 48 +++- server/src/entities/album-user.entity.ts | 31 +++ server/src/entities/album.entity.ts | 6 +- server/src/entities/index.ts | 2 + server/src/interfaces/access.interface.ts | 4 +- server/src/interfaces/album-user.interface.ts | 14 + .../1713337511945-AddAlbumUserRole.ts | 14 + server/src/queries/access.repository.sql | 33 ++- server/src/queries/album.repository.sql | 242 +++++++++--------- server/src/repositories/access.repository.ts | 20 +- .../src/repositories/album-user.repository.ts | 28 ++ server/src/repositories/album.repository.ts | 64 +++-- server/src/repositories/index.ts | 3 + server/src/services/album.service.spec.ts | 81 ++++-- server/src/services/album.service.ts | 48 ++-- server/test/fixtures/album.stub.ts | 52 +++- server/test/fixtures/asset.stub.ts | 2 + server/test/fixtures/shared-link.stub.ts | 3 +- .../album-user.repository.mock.ts | 10 + .../components/album-page/albums-list.svelte | 6 +- .../album-page/share-info-modal.svelte | 42 ++- .../album-page/user-selection-modal.svelte | 147 ++++++----- .../[[assetId=id]]/+page.svelte | 65 +++-- web/src/test-data/factories/album-factory.ts | 1 + 56 files changed, 1032 insertions(+), 365 deletions(-) create mode 100644 mobile/openapi/doc/AlbumUserAddDto.md create mode 100644 mobile/openapi/doc/AlbumUserResponseDto.md create mode 100644 mobile/openapi/doc/AlbumUserRole.md create mode 100644 mobile/openapi/doc/UpdateAlbumUserDto.md create mode 100644 mobile/openapi/lib/model/album_user_add_dto.dart create mode 100644 mobile/openapi/lib/model/album_user_response_dto.dart create mode 100644 mobile/openapi/lib/model/album_user_role.dart create mode 100644 mobile/openapi/lib/model/update_album_user_dto.dart create mode 100644 mobile/openapi/test/album_user_add_dto_test.dart create mode 100644 mobile/openapi/test/album_user_response_dto_test.dart create mode 100644 mobile/openapi/test/album_user_role_test.dart create mode 100644 mobile/openapi/test/update_album_user_dto_test.dart create mode 100644 server/src/entities/album-user.entity.ts create mode 100644 server/src/interfaces/album-user.interface.ts create mode 100644 server/src/migrations/1713337511945-AddAlbumUserRole.ts create mode 100644 server/src/repositories/album-user.repository.ts create mode 100644 server/test/repositories/album-user.repository.mock.ts diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index c877afc6b..a3459bea3 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 96994c7f0..ee4dad654 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 64229329a..2fd4fba05 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 5439d48208e7b6b1614d6d56e32c2839582eaa21..3059e3dbeffafe5aa01c04b6bcc261ade85b7f68 100644 GIT binary patch delta 180 zcmX?gk@44M#tnD*C-?F4P44FBW(h4$Et))wUxB3*#5lksGdYi+e=@ru-{#l+3TCEB zrMVy#jwvZFCHb)$Df!9z`i?m$qI$V0np_IH3ej-!$p>A8Ibez=KlBh)fN_HIb5hYw a0f}vL3zK63DVpq+iU0rr diff --git a/mobile/openapi/doc/AddUsersDto.md b/mobile/openapi/doc/AddUsersDto.md index 9f7770d60406088e0a6b9f685701f5facd647111..a8f7723441c20d3f196e26e6a655ee57840fe58c 100644 GIT binary patch delta 125 zcmdnVe2`_rHM_)|q|)5b;?$yIEiHu_g=j4;pUmPC8%LP1V@irkNxq$yR;&h^m|kv* z=ENT|6R)W2xug~pr6wnqq^2lj<|(8lmgN^I^npO=^uJMr%x09H6J AfB*mh delta 17 ZcmX@evXgnjwaG?|s!R$slUo`00026~21Wn? diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index 427181880dc02151d8fff757b614fb02c8a765f5..2cd6cb29b071d97bdd7fe265a63556dd62aec88b 100644 GIT binary patch delta 315 zcmbQeobl!k#tl!HCr@MMQw}XoEz;78)o{#7D$R8)$kfYCQ7$b=Ni0cC1c{Uar6)%+ z%1rKL=H2{+dAt8)2N{;hIo{kR5RF`R3J}#`{U9-o%oH63kTy>c4K@ibw|RqiD$C?B z57o*39_-4faxNwL3bqQNsDhIX0z}zCrUGRr?^KY{Lo*PcWgsmZJ#6{Wv`l97+%f_#^#VDPVRGJ%FoLW?@rKM1#5Ur)1W{zQFvWJup6rDfifKy$R?%U0Hk${58B3LVN zc$WcLG}5Y4(|}`Hjjxk_@#s$IEg6q>AjYx`o=6T5-VtUUSlNHbH)LJc4blasv?Ui{ zbK&nUsoRvC1e^OLos*6(e!8I0vxC$KU-0~C(^I4Gow7lZiReHfD#YSMa)E^S3p36g zjmUWvHk@Bqo5?o6Axb?IQ5SEtO|gJq+o}xC+k8bWtgLAsVlxm3_Nh-8l@D#3m*w`f wtLptOYO{g*NuvoeZ7R;>#N4Sb;Oef9kN@<>h6S3zk}}~Y;%DG$c)=L^26*6{_5c6? literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/AlbumUserRole.md b/mobile/openapi/doc/AlbumUserRole.md new file mode 100644 index 0000000000000000000000000000000000000000..d0f64ef3ec93f72643a5203e7653b1f7aa79deb5 GIT binary patch literal 379 zcma)1v2Fq}4Bh<|mTsU72|Jyxf&mE-LUck%mtNcwafzeE43+xvO|D3#U0N^Jv!CDd zYmg%a6P@;KY0&NPa`n%d(w+wbXc}Czu_$N4k$}5YaB`c^Z*sFL zSqYQJN*$GUsVtK?%CI$_@B4t242JLe6@nbv7@Xd>Q$~6itfiFo6aw*TZ8M7_JY3Es)whOKTH%GVSu8*2R0Vvm2jponB$vyA?W+wnke9W;3&8_ z>o;f5Rrbxm;x?nR(=MjZD2^uB8c+CvkMAKr4Nb@Bf;b*iv}H+zzBoy|h_wDI2Bjho zq*MZ1rM^en&tkW{5-M-Y#H9<+ws&bf6vjSfIC?>b7 jpE>GzF;j^@+4b)JpSfvGnnoMgjdEr2Rq$8w5&%8{tM7=5 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 44bd35a6838a99692a4c97328b6ed3feef902bc6..78f2e9ed5ae317b0b2b096d55da14aeb579b8c06 100644 GIT binary patch delta 77 zcmZ4HaoBUiHTKCH*jYGAi&Kl@6H`(qD{@IrUeC_T0uq=k$gTw9KxB*Zb0$yZ65D*0 aeYrSGPEu*^WC1zZ$pP{ln``7(u>b%)mm0(X delta 17 ZcmX@?xy)n3HTKP39LvNvODe2l0RTq|2IK$$ diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index f5fe2e3c1d3552743b8830a4cf177895f52cb600..4596eff88b72b9bfc8b9a9b57e0f57a08ef0db27 100644 GIT binary patch delta 321 zcmX@r#<*${;|34s$$AQslQ(dROulcyHF=A9_v8WBcx!9E}d8If~3+spYEW0swIHc%A?N delta 12 TcmZ2AiSbk$;|33BrdlolAwvXH diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a92f1df7a7bed8c052cc85c84d8993ab98f23266..ae3b9dadf1290afba8e8d2b31671db7a1bb4cdfc 100644 GIT binary patch delta 122 zcmbPwl<~)L#tmF@TuG(5p~b01jwvaV56a50!`KsLHCRCG$%b0;lj~(!nL>*vZ!pp2 zbc>O*fZ>wD1D}QK<(d diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7ad74d9516f0aa99ce8e13aa049a30a9ca0fedc5..8d92ad1f0a6517cba1b780e897a6e55a24c23b3b 100644 GIT binary patch delta 56 zcmaE0aKdnd5}SHXQfY2zacWUeeom^ULN%8H5EP}BlosVFI3meSzR2#dS&_|N2mn44 B6N>-< delta 16 XcmX?M_`qO;65HetY%H7m*sO#AJ52@x diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 9ce47afc6abcfd55d6f377c3beb13beaa2ffe0e6..806bc60f42e95b8e6a54bd93a7f8ea78ee8d7f9f 100644 GIT binary patch delta 318 zcmcaFwpwyS6{A>UPEu)ZXmM&$v4X8aa(-TMi9&R&&g23{BUeY5lw(SYOG&<+0z$1d z7ng#9zP`SKOKL$;YI0&pYKlT;og1ej1X>$+H+2iX$}YAW3OXmSCEQl z#NrI+{FGEp1?|a;nF1sb+H6%2Y#oKkpP9y1`xA>1atwjJO)|@7;|W0 Ya%E&yb7gW#WOFAh3LqeTI|_XY3Ma=E@Bjb+ diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index d764028558b4b575cc745a8c8b46d2a43a79bf34..cae01150f65a2a098c8b70946c03586cb93dea44 100644 GIT binary patch delta 295 zcmez3b>DZxDMqQ1jLc%a#GItk+|c6GqGAPGh2;Fa;u3}E*vW#-BAah8K4(<*$t*6h zafGP}N-Zt`DoJ%I$+uHL=-j-EIgnLVUteFrCAFX^H94^)HANvaPa!R_EWb!0KMh$a z2WK7Ag N2D1Vj{|S>5Cd^G_6QuwE 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 0000000000000000000000000000000000000000..0e259828f850dc450112f7615c24c37bda81ef79 GIT binary patch literal 3403 zcmbVPU2oeq6n*!vxG9Rp!Bo5J)8NcbgC-r?wegUq0|p}yXoedm&r ztT;{+?4gQ9-Y=eW?-ASYclv!g{c<&a`N#Ri`Q_==`5C=?|M5JbvoT$aujteG?ESlc z4q(QXZ*pP%@OAIk*ByRUb8W0lXU3*8RmcIAx^yy6Wg(YR`=owX%ayTyuZJksV&l?P z=~FBJEtQ447E3%=!s4%&)*-lc``t66ofq1tDn~*!Q_==^w>Md#v~+1<^c-e>A$0oN zt2A2(>pC5z^JHdZR#JfzsUr;`=JIXW$-h4?OMDy4>zeyPi%5Ys{)&}XGHiTIM-N@hST6J8ljOkfo}H_~xv z)Sne)E^)qG&O}*w`Ywu6Qnz$U&qI)B)z08qQ^8R}3$vErrA>m#Txw~Bmw6T5nbM+! z%pmfzbe?9SC}=9OZv<>nH^&B~RmtQxtAHRQ{uX?LYD*%|quHJUQ1=4|3<==r|!JC4m0fTh0{82rF+4&Snt?UQ=TV?in1=SQ-wyWF3Nn`@|k{DTmjH(+q0#z9m^;{$fJFdC#69t4u?>#v&eP z8#BH~QycUo#Nvh9YUZD;GbD6QQEVM4@8Y;Rnpv|%JRYD)m|#fL3okWg9 z*RGYT#-bAV0Y+QSAeljJZJD9fUCKj{LVUf-Qt0%;Q*#hADj-h6<*@C#|VY;xNREVkD@Ab z&`8sOmSXicYv6cx1zD=f75MTI4d2?Hs)YA_T)NN#?W&_)*&te5)&G*r`(0{ z=gXDf)FHtJG_G4kHfz>dcHQpRd_^vYTacr$ZDB^)6*7#@mi~Kl$lLJ{JxG3Rl9p%0 zwZ``j%9WQle5!5@)PU+=AxfKqwzlk|#k}t&qCR^B1y*g%*^ScHo*Hvx{VC+ZZWt+f zsPi$|+fl8rO(||JxS;VxEXw&E`mky@HGw^TQ_|IkVbJ4&9wjow=>@YEEsqcim?qKj zo7}Lv2LITPockxN!nGxhObcsR0LjEJ?^^ipv$qliQ2C$t7I9|1=5Vatb%oJGh=+zQ6kG z1kK3u-BK7AzlnZ*GoVkkF0_%!N}FV*ayf>osH|KjGM5`!xUl(Fn^GIs>ml;3*x974 zTw>%ul``mFhz&kVVer>UV==g~{qCtQtP_Px)Djm|D%!`%S4g< z_Dhmx!q~w8mvf+3pi5bZQbqWGHW;M2uof=zMYXxN(%ed0;)?RpX?Pd_j1Yud=_*rL z5Kzc1%=&ESpg7G`M(EUM5l}RM1_E%IvXRlb412~AV?*QnQ_Qd%5AA`Rc=-E#$%`l6 z>95il1Ppx_TIaN7Bb+u6$H8>*MWzUZ%cMDl_#8a2smKG$DQsrGee-XiegMzQmz+5V z;+Hm?qvp{DNZ7haB@-ly7~A5SBaCv`Wu0 z>IbU}jOqDasY2xN1&1JpN7fJfGtL;;VrGW4(r}KZtvtRnpydw+9#gL?(kO=^k3Gre zKWi66u>@br7L>E_Rpgblurhj6$D8pG%6)|fL#l}x!Y~h^sB+X9iE67XS3!rv(#RDS zB-gU3QdFW%NTUgswo_?>T_Dp-;?xiHR?zR92cuh~?elA(#*_lijueYYHRAwBB(O=8 z3hN%x5%_ZJ;MpgN0Mqn;#MZczD~XY?;v9+dmeJD_U*_aTq>bSopPgp&$j=jf@Wa%| zdCyP79f>h-*{+fMq;O+>X`e%mQ&&xT4TO|t2oKyQ)<$o|0$WTBE0J3n?i&m}f=1dZ zcO>NA1!i-fBN0Jii3dst`vD6xQuao>$)CN6S=T`8!;>n*lXHa*NY?0IJ64%&xK zeE<(PPy7d3Vq3s@p?)Lp2ShO98I`Bm+!U>>2t|Szs@z?*e0JkL?6cPP`z{MZmY? zBQ|l=>>55J`cl$1J0f=cuAav$2aDLY!_rUqMZjj>@o%}Krpo?aqm;lCB%^CvSh#ik zq*ljBX@7ohmCJC5YKkrueQdn;rs!FBQal{cg>G0B@zCwAI|m7=s=vI>$%&?Wwes8= z#u!}{J+a*X(b6+z@*K(4UAEI7eyS+k+cDvNtVBdv3t>lCVOzq7Vh&rid9fUI0$i>m zk`dfxveiR-&qL{1Jk14CUZGSL67VpzxGa^1fDfOiH*{sJdj+46?IrOe`L@@F^9P2+ T-x2(AGUQW&Z;EFF>^c7eHtOL3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..991d6d182ce2ffd01d64371622e321798bf6c273 GIT binary patch literal 2625 zcmai0ZExE)5dQ98aRG+L0X%u@r@@`;8i=!HXp^CJJ`9E-(9+4~N|72##W0He_uY|_ z5~*%$z&2&x`#txZr_<4N3d@J}{m*}_?p7a{>(w>f+`eB;;QAi!?$_|){`&Ujp9>OW z$#=Q1e*QZ7_4SA!u0X8bTe#$)#28Q;YxA z(vrFsC4E-H(zi$J$hdXq)ia}=7uu&XCx^0-XoI>t>r6?FE>%YFNz5OFPJjC)%?`r4 z(TLo6Fa?+jwWwr5?>D2-C{w~YSgKuJJ~*@=jY9Y`0+>?zx6!v09zlS{BcQHJI7)v2 ze?X|Ld8D{<*gOhV<2E6^5OCjKl8*gBx+Dl)k<(~>O%`gMqg5xV_YiJ5K$eY;N-TG1 z*=ixRQ1HpyPBNHzXCBd73V}qLOK;*~d_~m`iUg%)d&) zUdF3S$^oFK^Coj@4mo79yCqPVT7WA$tc>$MiQ`=<3jc%*QW~CR%l0qQKB7R zB{^m$$MK5}Hy$|IL3E(#x0|7>f2l1K__8xb!QPsq;Hu1_5X#}?=_+8d#eqdvD}N?$ zN{%y^1&rAq5RgK(U%di;PNcgtepOcf)EXp{)a!A?7rEsQu;}xB>onqXh>EUcq#Izt z{$m7D;bIQDR_diafS5oc^1Lfq)B|`58MNQ=$)c~VZneDOoW^aDW?i|0|EJLtQ`Z!_ z1`)KW6miap6Fg)>0|AUXwgo!NFy)eurMdEWwgj_q`Fl5*$GFDa3%Z`8Gk>MzNfLJtTAGaOn zR<~5vHIvwiTiG!ga}*20R|Mf`zh;Mc`;s;?;SjnVG;4a;k0ptuA8C*&rBP~@&^MIg z?Xyyjg&=~RQKs#S!@cN|c^MWd!sE;X><0Ed1X#PZPQs{};`h+tWXhK&*ZrrwWTW_GSc&3*(%%uMVODSZ! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8e85349318757dcac27057a75b77166531a8cb7f GIT binary patch literal 2808 zcmbVOU2oeq6n*!vxB-S(0aSVG(~wMFgT)#86$5GVFc^kFOSHvKCN+|(VWj@=yO$Id z8CKhD25d{z{XFMfQj^JOGJ)&Ihxw~NXLqyv>xbD5+`j!Vi{WMtck>7MIKO#&`_~zo zk>#6$X`8-IetA8jTd|Q!^K7kjwiXq?ghn=o7a6bkmP;Fld$FyRwu2t5+OfUK>c(c8 z|I-MK?vicsw`LmuE!PHvYcuSgD`_l~HWLLV6l=k?b2o#@Dj~VaDkV2)W=kfsUw_W> zl4&yUzj>z^iam-;modnuLJ<(- zJ7JvOdf?ZZuTl1HVsw+EOiV@s%5Z?J$TOU^QWZpoUl`Ep@2(05TnnVHE})17MXojy z6P6U#0gmu>CP~hWJwZD1#QTY$$r`94!V>+5Xt+F{j)ZV>6%v^pRTpP8PV~V9^mlN- zEnkFN$5-3^2Z80C%4-T0KDZ4@0t}Qah9@_L8?AP1g@c;HnpK9!hk%1eL37homUK1{ zezElR5tI~^kfc)J{M^YZ5#~7evG;1Q3ws`r7( zgzf}4p&w$#_w*7*U8uxa_W1b$vk2D0JO0i0w9`3%uT4t-9eUBV8zyWVZ)uBiI4OT9 z?Sw6Hi6)7T+*2lAc|-JT_dndP(ShDRC?3x4fHFu;LnAdM7kW6Tk(W*_#^|f(o5}u< zhF&O-mq@MM<$62950!+S9aDYEM?zF}@bv^0cSHL;zHz5AFOs9#$7DTmOyHs9oesKp zAe5ZJqm?1q4N7Fi0S_;OX=xxBy!$l2qnD{x1MWoYUZCESZ+C`IpBy0mz;ej}QO= literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/add_users_dto_test.dart b/mobile/openapi/test/add_users_dto_test.dart index 3dadfd8b4d3095eda84373bcb96875f9e5d2731a..0c3bbb759d81dfb84857859b2cdd8de005ddee0f 100644 GIT binary patch delta 97 zcmcc4GKp=&DMl&BoTSp+(Bjl0$CMP8l6*UbM3}(jdyF=m2%Z{~x`qM}=<6%Eq!tvV dCMTAprYL0QDWoNqq|)5b;?yFI;F6-uymW=k6deU9vlJ-o3FL;N fs&pyIN7p<#fLUsCBs(_?$fU{lnAIouv$FvJpO>sl^5PdBv$NCHZy=i7?U0Hq5r1Fi!F02W+~V ocQMtnD!QZ=6s0C7mZYXAWacTPC6?tEDdeXi^c8Q`=O|E-=&F5mg%srWY*?u@T2UHZFy?kri<>Kv?IhVKYG;H_Ewfiy;H{}q%UZ>J z27c?#=ojM9y4aT^;6r_HfjBF~OF#O3}ni7GyupGirtT*8S+zz28UXa`#HXhovHq!2O n7H0Dzo^O>VX)rjngM^n)j+8Ed|1{0>KkZxWe*tV3f)3Fa2B+0t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..19f15a305de06d784be70dbdfad923b7070e488b GIT binary patch literal 685 zcma)4%}N6?5WeqIjHk9xyV{eq6fA5j1?@tq2Ty6-owmWyGL>?POhC^ z811~&UZ|1{)m9-zx@$G7ltw2iqjwDRd#T0El_>U-T#}qJLdVMDMecB9ENW?0%4-m& z?u>syJUX9-as~v`&^zEKfob*J)Q=8nh0bzL%)Ck7j4Gj)N_TtkkpM7btF){@#X$;$ zNh`728z&6kk!ftV6HHoRoj!aCN9pefra~YttN}42iJET#G)a6%@8da^Y}t)A8D9 z=Y{rMHla|q66q)ICd-u6=(sYv3z$C(&F^k_eHP>x`@JJ1R@ASe!HG6u7|m^ zap{<-$WYV*O`zLBvsOs7tW~lhm^*UG9`0Cq { + 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 72644870d..6f8930d05 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 3f7af0f53..0f96e52b1 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 000000000..66ed58c4f --- /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 99fae4f23..39d5b72bf 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 59aa90719..0862dd48a 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 8b9bdcc4b..e07b877b6 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 000000000..d5742ad78 --- /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 000000000..a8d0d3d68 --- /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 3c6eca727..52cf28c77 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 50f775d2f..2037e320a 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 a624e8bfd..992f8f143 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 000000000..7fd18711a --- /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 bbaab2a12..536c9b666 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 6ab09ac74..712e925cf 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 78ee92395..3a050cd59 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 b3b7f6d08..1cc049d85 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 ff9348167..f6047d522 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 ce2b07067..c6fe89d6f 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 ccd76c328..aa785a241 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 000000000..70c048725 --- /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 ef2a9ce3f..0d3c287a2 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 6d66fb970..ffe8adf48 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 3879fc26f..5a70c1291 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 @@