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 5439d4820..3059e3dbe 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AddUsersDto.md b/mobile/openapi/doc/AddUsersDto.md index 9f7770d60..a8f772344 100644 Binary files a/mobile/openapi/doc/AddUsersDto.md and b/mobile/openapi/doc/AddUsersDto.md differ diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index 427181880..2cd6cb29b 100644 Binary files a/mobile/openapi/doc/AlbumApi.md and b/mobile/openapi/doc/AlbumApi.md differ diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index dd4a94e88..b7965b420 100644 Binary files a/mobile/openapi/doc/AlbumResponseDto.md and b/mobile/openapi/doc/AlbumResponseDto.md differ diff --git a/mobile/openapi/doc/AlbumUserAddDto.md b/mobile/openapi/doc/AlbumUserAddDto.md new file mode 100644 index 000000000..aae7c9eb6 Binary files /dev/null and b/mobile/openapi/doc/AlbumUserAddDto.md differ diff --git a/mobile/openapi/doc/AlbumUserResponseDto.md b/mobile/openapi/doc/AlbumUserResponseDto.md new file mode 100644 index 000000000..3f59d3142 Binary files /dev/null and b/mobile/openapi/doc/AlbumUserResponseDto.md differ diff --git a/mobile/openapi/doc/AlbumUserRole.md b/mobile/openapi/doc/AlbumUserRole.md new file mode 100644 index 000000000..d0f64ef3e Binary files /dev/null and b/mobile/openapi/doc/AlbumUserRole.md differ diff --git a/mobile/openapi/doc/UpdateAlbumUserDto.md b/mobile/openapi/doc/UpdateAlbumUserDto.md new file mode 100644 index 000000000..1a1050b4d Binary files /dev/null and b/mobile/openapi/doc/UpdateAlbumUserDto.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 44bd35a68..78f2e9ed5 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index f5fe2e3c1..4596eff88 100644 Binary files a/mobile/openapi/lib/api/album_api.dart and b/mobile/openapi/lib/api/album_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a92f1df7a..ae3b9dadf 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7ad74d951..8d92ad1f0 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 9ce47afc6..806bc60f4 100644 Binary files a/mobile/openapi/lib/model/add_users_dto.dart and b/mobile/openapi/lib/model/add_users_dto.dart differ diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index d76402855..cae01150f 100644 Binary files a/mobile/openapi/lib/model/album_response_dto.dart and b/mobile/openapi/lib/model/album_response_dto.dart differ 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 000000000..0e259828f Binary files /dev/null and b/mobile/openapi/lib/model/album_user_add_dto.dart differ diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart new file mode 100644 index 000000000..896c1cbb8 Binary files /dev/null and b/mobile/openapi/lib/model/album_user_response_dto.dart differ 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 000000000..991d6d182 Binary files /dev/null and b/mobile/openapi/lib/model/album_user_role.dart differ 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 000000000..8e8534931 Binary files /dev/null and b/mobile/openapi/lib/model/update_album_user_dto.dart differ diff --git a/mobile/openapi/test/add_users_dto_test.dart b/mobile/openapi/test/add_users_dto_test.dart index 3dadfd8b4..0c3bbb759 100644 Binary files a/mobile/openapi/test/add_users_dto_test.dart and b/mobile/openapi/test/add_users_dto_test.dart differ diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index aee26e095..1a6d3ab3b 100644 Binary files a/mobile/openapi/test/album_api_test.dart and b/mobile/openapi/test/album_api_test.dart differ diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index 5c79e5d2f..12218d382 100644 Binary files a/mobile/openapi/test/album_response_dto_test.dart and b/mobile/openapi/test/album_response_dto_test.dart differ diff --git a/mobile/openapi/test/album_user_add_dto_test.dart b/mobile/openapi/test/album_user_add_dto_test.dart new file mode 100644 index 000000000..3f315ea2b Binary files /dev/null and b/mobile/openapi/test/album_user_add_dto_test.dart differ 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 000000000..19f15a305 Binary files /dev/null and b/mobile/openapi/test/album_user_response_dto_test.dart differ diff --git a/mobile/openapi/test/album_user_role_test.dart b/mobile/openapi/test/album_user_role_test.dart new file mode 100644 index 000000000..bc0989621 Binary files /dev/null and b/mobile/openapi/test/album_user_role_test.dart differ diff --git a/mobile/openapi/test/update_album_user_dto_test.dart b/mobile/openapi/test/update_album_user_dto_test.dart new file mode 100644 index 000000000..a42ca38b2 Binary files /dev/null and b/mobile/openapi/test/update_album_user_dto_test.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c2483d690..5905f2ccf 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -630,6 +630,57 @@ "tags": [ "Album" ] + }, + "put": { + "operationId": "updateAlbumUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAlbumUserDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Album" + ] } }, "/album/{id}/users": { @@ -7251,7 +7302,15 @@ }, "AddUsersDto": { "properties": { + "albumUsers": { + "items": { + "$ref": "#/components/schemas/AlbumUserAddDto" + }, + "type": "array" + }, "sharedUserIds": { + "deprecated": true, + "description": "Deprecated in favor of albumUsers", "items": { "format": "uuid", "type": "string" @@ -7260,7 +7319,7 @@ } }, "required": [ - "sharedUserIds" + "albumUsers" ], "type": "object" }, @@ -7303,6 +7362,12 @@ "nullable": true, "type": "string" }, + "albumUsers": { + "items": { + "$ref": "#/components/schemas/AlbumUserResponseDto" + }, + "type": "array" + }, "assetCount": { "type": "integer" }, @@ -7349,6 +7414,8 @@ "type": "boolean" }, "sharedUsers": { + "deprecated": true, + "description": "Deprecated in favor of albumUsers", "items": { "$ref": "#/components/schemas/UserResponseDto" }, @@ -7366,6 +7433,7 @@ "required": [ "albumName", "albumThumbnailAssetId", + "albumUsers", "assetCount", "assets", "createdAt", @@ -7381,6 +7449,43 @@ ], "type": "object" }, + "AlbumUserAddDto": { + "properties": { + "role": { + "$ref": "#/components/schemas/AlbumUserRole" + }, + "userId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "userId" + ], + "type": "object" + }, + "AlbumUserResponseDto": { + "properties": { + "role": { + "$ref": "#/components/schemas/AlbumUserRole" + }, + "user": { + "$ref": "#/components/schemas/UserResponseDto" + } + }, + "required": [ + "role", + "user" + ], + "type": "object" + }, + "AlbumUserRole": { + "enum": [ + "editor", + "viewer" + ], + "type": "string" + }, "AllJobStatusResponseDto": { "properties": { "backgroundTask": { @@ -11190,6 +11295,17 @@ }, "type": "object" }, + "UpdateAlbumUserDto": { + "properties": { + "role": { + "$ref": "#/components/schemas/AlbumUserRole" + } + }, + "required": [ + "role" + ], + "type": "object" + }, "UpdateAssetDto": { "properties": { "dateTimeOriginal": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 41603bc0e..a517cb660 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -38,6 +38,28 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type UserResponseDto = { + avatarColor: UserAvatarColor; + createdAt: string; + deletedAt: string | null; + email: string; + id: string; + isAdmin: boolean; + memoriesEnabled?: boolean; + name: string; + oauthId: string; + profileImagePath: string; + quotaSizeInBytes: number | null; + quotaUsageInBytes: number | null; + shouldChangePassword: boolean; + status: UserStatus; + storageLabel: string | null; + updatedAt: string; +}; +export type AlbumUserResponseDto = { + role: AlbumUserRole; + user: UserResponseDto; +}; export type ExifResponseDto = { city?: string | null; country?: string | null; @@ -61,24 +83,6 @@ export type ExifResponseDto = { state?: string | null; timeZone?: string | null; }; -export type UserResponseDto = { - avatarColor: UserAvatarColor; - createdAt: string; - deletedAt: string | null; - email: string; - id: string; - isAdmin: boolean; - memoriesEnabled?: boolean; - name: string; - oauthId: string; - profileImagePath: string; - quotaSizeInBytes: number | null; - quotaUsageInBytes: number | null; - shouldChangePassword: boolean; - status: UserStatus; - storageLabel: string | null; - updatedAt: string; -}; export type AssetFaceWithoutPersonResponseDto = { boundingBoxX1: number; boundingBoxX2: number; @@ -144,6 +148,7 @@ export type AssetResponseDto = { export type AlbumResponseDto = { albumName: string; albumThumbnailAssetId: string | null; + albumUsers: AlbumUserResponseDto[]; assetCount: number; assets: AssetResponseDto[]; createdAt: string; @@ -157,6 +162,7 @@ export type AlbumResponseDto = { owner: UserResponseDto; ownerId: string; shared: boolean; + /** Deprecated in favor of albumUsers */ sharedUsers: UserResponseDto[]; startDate?: string; updatedAt: string; @@ -187,8 +193,17 @@ export type BulkIdResponseDto = { id: string; success: boolean; }; +export type UpdateAlbumUserDto = { + role: AlbumUserRole; +}; +export type AlbumUserAddDto = { + role?: AlbumUserRole; + userId: string; +}; export type AddUsersDto = { - sharedUserIds: string[]; + albumUsers: AlbumUserAddDto[]; + /** Deprecated in favor of albumUsers */ + sharedUserIds?: string[]; }; export type ApiKeyResponseDto = { createdAt: string; @@ -1209,6 +1224,17 @@ export function removeUserFromAlbum({ id, userId }: { method: "DELETE" })); } +export function updateAlbumUser({ id, userId, updateAlbumUserDto }: { + id: string; + userId: string; + updateAlbumUserDto: UpdateAlbumUserDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/album/${encodeURIComponent(id)}/user/${encodeURIComponent(userId)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: updateAlbumUserDto + }))); +} export function addUsersToAlbum({ id, addUsersDto }: { id: string; addUsersDto: AddUsersDto; @@ -2927,6 +2953,10 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum AlbumUserRole { + Editor = "editor", + Viewer = "viewer" +} export enum UserStatus { Active = "active", Removing = "removing", diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index c4b11fbb4..0e8d954ab 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -8,6 +8,7 @@ import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, + UpdateAlbumUserDto, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -88,6 +89,16 @@ export class AlbumController { return this.service.addUsers(auth, id, dto); } + @Put(':id/user/:userId') + updateAlbumUser( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string, + @Body() dto: UpdateAlbumUserDto, + ): Promise { + return this.service.updateUser(auth, id, userId, dto); + } + @Delete(':id/user/:userId') removeUserFromAlbum( @Auth() auth: AuthDto, diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index 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 @@