feat: readonly album sharing (#8720)

* rename albums_shared_users_users to album_permissions and add readonly column

* disable synchronize on the original join table

* remove unnecessary FK names

* set readonly=true as default for new album shares

* separate and implement album READ and WRITE permission

* expose albumPermissions on the API, deprecate sharedUsers

* generate openapi

* create readonly view on frontend

* ??? move slideshow button out from ellipsis menu so that non-owners can have access too

* correct sharedUsers joins

* add album permission repository

* remove a log

* fix assetCount getting reset when adding users

* fix lint

* add set permission endpoint and UI

* sort users

* remove log

* Revert "??? move slideshow button out from ellipsis menu so that non-owners can have access too"

This reverts commit 1343bfa31125f7136f81db28f7aa4c5ef0204847.

* rename stuff

* fix db schema annotations

* sql generate

* change readonly default to follow migration

* fix deprecation notice

* change readonly boolean to role enum

* fix joincolumn as primary key

* rename albumUserRepository in album service

* clean up userId and albumId

* add write access to shared link

* fix existing tests

* switch to vitest

* format and fix tests on web

* add new test

* fix one e2e test

* rename new API field to albumUsers

* capitalize serverside enum

* remove unused ReadWrite type

* missed rename from previous commit

* rename to albumUsers in album entity as well

* remove outdated Equals calls

* unnecessary relation

* rename to updateUser in album service

* minor renamery

* move sorting to backend

* rename and separate ALBUM_WRITE as ADD_ASSET and REMOVE_ASSET

* fix tests

* fix "should migrate single moving picture" test failing on European system timezone

* generated changes after merge

* lint fix

* fix correct page to open after removing user from album

* fix e2e tests and some bugs

* rename updateAlbumUser rest endpoint

* add new e2e tests for updateAlbumUser endpoint

* small optimizations

* refactor album e2e test, add new album shared with viewer

* add new test to check if viewer can see the album

* add new e2e tests for readonly share

* failing test: User delete doesn't cascade to UserAlbum entity

* fix: handle deleted users

* use lodash for sort

* add role to addUsersToAlbum endpoint

* add UI for adding editors

* lint fixes

* change role back to editor as DB default

* fix server tests

* redesign user selection modal editor selector

* style tweaks

* fix type error

* Revert "style tweaks"

This reverts commit ab604f4c8f3a6f12ab0b5fe2dd2ede723aa68775.

* Revert "redesign user selection modal editor selector"

This reverts commit e6f344856c6c05e4eb5c78f0dffb9f52498795f4.

* chore: cleanup and improve add user modal

* chore: open api

* small styling

---------

Co-authored-by: mgabor <>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
mgabor 2024-04-25 06:19:49 +02:00 committed by GitHub
parent 0b3373c552
commit 2943f93098
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1032 additions and 365 deletions

View file

@ -1,12 +1,13 @@
import { import {
addAssetsToAlbum,
AlbumResponseDto, AlbumResponseDto,
AlbumUserRole,
AssetFileUploadResponseDto, AssetFileUploadResponseDto,
AssetOrder, AssetOrder,
LoginResponseDto,
SharedLinkType,
addAssetsToAlbum,
deleteUser, deleteUser,
getAlbumInfo, getAlbumInfo,
LoginResponseDto,
SharedLinkType,
} from '@immich/sdk'; } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@ -14,7 +15,8 @@ import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const user1SharedUser = 'user1SharedUser'; const user1SharedEditorUser = 'user1SharedEditorUser';
const user1SharedViewerUser = 'user1SharedViewerUser';
const user1SharedLink = 'user1SharedLink'; const user1SharedLink = 'user1SharedLink';
const user1NotShared = 'user1NotShared'; const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser'; const user2SharedUser = 'user2SharedUser';
@ -49,35 +51,61 @@ describe('/album', () => {
const albums = await Promise.all([ const albums = await Promise.all([
// user 1 // user 1
/* 0 */
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1SharedUser, albumName: user1SharedEditorUser,
sharedWithUserIds: [user2.userId], sharedWithUserIds: [user2.userId],
assetIds: [user1Asset1.id], assetIds: [user1Asset1.id],
}), }),
/* 1 */
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink, albumName: user1SharedLink,
assetIds: [user1Asset1.id], assetIds: [user1Asset1.id],
}), }),
/* 2 */
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1NotShared, albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id], assetIds: [user1Asset1.id, user1Asset2.id],
}), }),
// user 2 // user 2
/* 3 */
utils.createAlbum(user2.accessToken, { utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser, albumName: user2SharedUser,
sharedWithUserIds: [user1.userId], sharedWithUserIds: [user1.userId, user3.userId],
}), }),
/* 4 */
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
/* 5 */
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
// user 3 // user 3
/* 6 */
utils.createAlbum(user3.accessToken, { utils.createAlbum(user3.accessToken, {
albumName: 'Deleted', albumName: 'Deleted',
sharedWithUserIds: [user1.userId], 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( await addAssetsToAlbum(
{ id: albums[3].id, bulkIdsDto: { ids: [user1Asset1.id] } }, { id: albums[3].id, bulkIdsDto: { ids: [user1Asset1.id] } },
{ headers: asBearerAuth(user1.accessToken) }, { headers: asBearerAuth(user1.accessToken) },
@ -85,7 +113,7 @@ describe('/album', () => {
albums[3] = await getAlbumInfo({ id: albums[3].id }, { headers: asBearerAuth(user2.accessToken) }); 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); user2Albums = albums.slice(3, 6);
await Promise.all([ await Promise.all([
@ -144,7 +172,7 @@ describe('/album', () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(4);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@ -154,7 +182,12 @@ describe('/album', () => {
}), }),
expect.objectContaining({ expect.objectContaining({
ownerId: user1.userId, ownerId: user1.userId,
albumName: user1SharedUser, albumName: user1SharedEditorUser,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
shared: true, shared: true,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -169,12 +202,17 @@ describe('/album', () => {
it('should return the album collection including owned and shared', async () => { it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(app).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); const { status, body } = await request(app).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(4);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
ownerId: user1.userId, ownerId: user1.userId,
albumName: user1SharedUser, albumName: user1SharedEditorUser,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
shared: true, shared: true,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -196,12 +234,17 @@ describe('/album', () => {
.get('/album?shared=true') .get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(3); expect(body).toHaveLength(4);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
ownerId: user1.userId, ownerId: user1.userId,
albumName: user1SharedUser, albumName: user1SharedEditorUser,
shared: true,
}),
expect.objectContaining({
ownerId: user1.userId,
albumName: user1SharedViewerUser,
shared: true, shared: true,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -248,7 +291,7 @@ describe('/album', () => {
.get(`/album?shared=true&assetId=${user1Asset1.id}`) .get(`/album?shared=true&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); 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 () => { 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}`) .get(`/album?shared=false&assetId=${user1Asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); 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) const { status, body } = await request(app)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`) .get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ expect(body).toMatchObject({ id: user2Albums[0].id });
...user2Albums[0],
assets: [expect.objectContaining({ id: user2Albums[0].assets[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 () => { it('should return album info with assets when withoutAssets is undefined', async () => {
@ -330,7 +379,7 @@ describe('/album', () => {
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); 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, albumThumbnailAssetId: null,
shared: false, shared: false,
sharedUsers: [], sharedUsers: [],
albumUsers: [],
hasSharedLink: false, hasSharedLink: false,
assets: [], assets: [],
assetCount: 0, assetCount: 0,
@ -395,6 +445,17 @@ describe('/album', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); 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', () => { describe('PATCH /album/:id', () => {
@ -425,6 +486,26 @@ describe('/album', () => {
description: 'An album description', 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', () => { describe('DELETE /album/:id/assets', () => {
@ -488,6 +569,16 @@ describe('/album', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]); 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', () => { describe('PUT :id/users', () => {
@ -510,7 +601,7 @@ describe('/album', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
@ -524,7 +615,7 @@ describe('/album', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] }); .send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner')); expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner'));
@ -534,15 +625,54 @@ describe('/album', () => {
await request(app) await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/album/${album.id}/users`) .put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] }); .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('User already added')); 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'));
});
});
}); });

View file

@ -26,6 +26,7 @@ import {
searchMetadata, searchMetadata,
signUpAdmin, signUpAdmin,
updateAdminOnboarding, updateAdminOnboarding,
updateAlbumUser,
updateConfig, updateConfig,
validate, validate,
} from '@immich/sdk'; } from '@immich/sdk';
@ -286,6 +287,9 @@ export const utils = {
createAlbum: (accessToken: string, dto: CreateAlbumDto) => createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }), createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }),
updateAlbumUser: (accessToken: string, args: Parameters<typeof updateAlbumUser>[0]) =>
updateAlbumUser(args, { headers: asBearerAuth(accessToken) }),
createAsset: async ( createAsset: async (
accessToken: string, accessToken: string,
dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData }, dto?: Partial<Omit<CreateAssetDto, 'assetData'>> & { assetData?: AssetData },

View file

@ -17,6 +17,9 @@ doc/AdminOnboardingUpdateDto.md
doc/AlbumApi.md doc/AlbumApi.md
doc/AlbumCountResponseDto.md doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md doc/AlbumResponseDto.md
doc/AlbumUserAddDto.md
doc/AlbumUserResponseDto.md
doc/AlbumUserRole.md
doc/AllJobStatusResponseDto.md doc/AllJobStatusResponseDto.md
doc/AssetApi.md doc/AssetApi.md
doc/AssetBulkDeleteDto.md doc/AssetBulkDeleteDto.md
@ -189,6 +192,7 @@ doc/TranscodeHWAccel.md
doc/TranscodePolicy.md doc/TranscodePolicy.md
doc/TrashApi.md doc/TrashApi.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateAlbumUserDto.md
doc/UpdateAssetDto.md doc/UpdateAssetDto.md
doc/UpdateLibraryDto.md doc/UpdateLibraryDto.md
doc/UpdatePartnerDto.md doc/UpdatePartnerDto.md
@ -249,6 +253,9 @@ lib/model/add_users_dto.dart
lib/model/admin_onboarding_update_dto.dart lib/model/admin_onboarding_update_dto.dart
lib/model/album_count_response_dto.dart lib/model/album_count_response_dto.dart
lib/model/album_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/all_job_status_response_dto.dart
lib/model/api_key_create_dto.dart lib/model/api_key_create_dto.dart
lib/model/api_key_create_response_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_hw_accel.dart
lib/model/transcode_policy.dart lib/model/transcode_policy.dart
lib/model/update_album_dto.dart lib/model/update_album_dto.dart
lib/model/update_album_user_dto.dart
lib/model/update_asset_dto.dart lib/model/update_asset_dto.dart
lib/model/update_library_dto.dart lib/model/update_library_dto.dart
lib/model/update_partner_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_api_test.dart
test/album_count_response_dto_test.dart test/album_count_response_dto_test.dart
test/album_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/all_job_status_response_dto_test.dart
test/api_key_api_test.dart test/api_key_api_test.dart
test/api_key_create_dto_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/transcode_policy_test.dart
test/trash_api_test.dart test/trash_api_test.dart
test/update_album_dto_test.dart test/update_album_dto_test.dart
test/update_album_user_dto_test.dart
test/update_asset_dto_test.dart test/update_asset_dto_test.dart
test/update_library_dto_test.dart test/update_library_dto_test.dart
test/update_partner_dto_test.dart test/update_partner_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/AlbumUserAddDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/AlbumUserResponseDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/AlbumUserRole.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/UpdateAlbumUserDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -630,6 +630,57 @@
"tags": [ "tags": [
"Album" "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": { "/album/{id}/users": {
@ -7251,7 +7302,15 @@
}, },
"AddUsersDto": { "AddUsersDto": {
"properties": { "properties": {
"albumUsers": {
"items": {
"$ref": "#/components/schemas/AlbumUserAddDto"
},
"type": "array"
},
"sharedUserIds": { "sharedUserIds": {
"deprecated": true,
"description": "Deprecated in favor of albumUsers",
"items": { "items": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@ -7260,7 +7319,7 @@
} }
}, },
"required": [ "required": [
"sharedUserIds" "albumUsers"
], ],
"type": "object" "type": "object"
}, },
@ -7303,6 +7362,12 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"albumUsers": {
"items": {
"$ref": "#/components/schemas/AlbumUserResponseDto"
},
"type": "array"
},
"assetCount": { "assetCount": {
"type": "integer" "type": "integer"
}, },
@ -7349,6 +7414,8 @@
"type": "boolean" "type": "boolean"
}, },
"sharedUsers": { "sharedUsers": {
"deprecated": true,
"description": "Deprecated in favor of albumUsers",
"items": { "items": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserResponseDto"
}, },
@ -7366,6 +7433,7 @@
"required": [ "required": [
"albumName", "albumName",
"albumThumbnailAssetId", "albumThumbnailAssetId",
"albumUsers",
"assetCount", "assetCount",
"assets", "assets",
"createdAt", "createdAt",
@ -7381,6 +7449,43 @@
], ],
"type": "object" "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": { "AllJobStatusResponseDto": {
"properties": { "properties": {
"backgroundTask": { "backgroundTask": {
@ -11190,6 +11295,17 @@
}, },
"type": "object" "type": "object"
}, },
"UpdateAlbumUserDto": {
"properties": {
"role": {
"$ref": "#/components/schemas/AlbumUserRole"
}
},
"required": [
"role"
],
"type": "object"
},
"UpdateAssetDto": { "UpdateAssetDto": {
"properties": { "properties": {
"dateTimeOriginal": { "dateTimeOriginal": {

View file

@ -38,6 +38,28 @@ export type ActivityCreateDto = {
export type ActivityStatisticsResponseDto = { export type ActivityStatisticsResponseDto = {
comments: number; 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 = { export type ExifResponseDto = {
city?: string | null; city?: string | null;
country?: string | null; country?: string | null;
@ -61,24 +83,6 @@ export type ExifResponseDto = {
state?: string | null; state?: string | null;
timeZone?: 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 = { export type AssetFaceWithoutPersonResponseDto = {
boundingBoxX1: number; boundingBoxX1: number;
boundingBoxX2: number; boundingBoxX2: number;
@ -144,6 +148,7 @@ export type AssetResponseDto = {
export type AlbumResponseDto = { export type AlbumResponseDto = {
albumName: string; albumName: string;
albumThumbnailAssetId: string | null; albumThumbnailAssetId: string | null;
albumUsers: AlbumUserResponseDto[];
assetCount: number; assetCount: number;
assets: AssetResponseDto[]; assets: AssetResponseDto[];
createdAt: string; createdAt: string;
@ -157,6 +162,7 @@ export type AlbumResponseDto = {
owner: UserResponseDto; owner: UserResponseDto;
ownerId: string; ownerId: string;
shared: boolean; shared: boolean;
/** Deprecated in favor of albumUsers */
sharedUsers: UserResponseDto[]; sharedUsers: UserResponseDto[];
startDate?: string; startDate?: string;
updatedAt: string; updatedAt: string;
@ -187,8 +193,17 @@ export type BulkIdResponseDto = {
id: string; id: string;
success: boolean; success: boolean;
}; };
export type UpdateAlbumUserDto = {
role: AlbumUserRole;
};
export type AlbumUserAddDto = {
role?: AlbumUserRole;
userId: string;
};
export type AddUsersDto = { export type AddUsersDto = {
sharedUserIds: string[]; albumUsers: AlbumUserAddDto[];
/** Deprecated in favor of albumUsers */
sharedUserIds?: string[];
}; };
export type ApiKeyResponseDto = { export type ApiKeyResponseDto = {
createdAt: string; createdAt: string;
@ -1209,6 +1224,17 @@ export function removeUserFromAlbum({ id, userId }: {
method: "DELETE" 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 }: { export function addUsersToAlbum({ id, addUsersDto }: {
id: string; id: string;
addUsersDto: AddUsersDto; addUsersDto: AddUsersDto;
@ -2927,6 +2953,10 @@ export enum UserAvatarColor {
Gray = "gray", Gray = "gray",
Amber = "amber" Amber = "amber"
} }
export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum UserStatus { export enum UserStatus {
Active = "active", Active = "active",
Removing = "removing", Removing = "removing",

View file

@ -8,6 +8,7 @@ import {
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
UpdateAlbumDto, UpdateAlbumDto,
UpdateAlbumUserDto,
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -88,6 +89,16 @@ export class AlbumController {
return this.service.addUsers(auth, id, dto); 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<void> {
return this.service.updateUser(auth, id, userId, dto);
}
@Delete(':id/user/:userId') @Delete(':id/user/:userId')
removeUserFromAlbum( removeUserFromAlbum(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,

View file

@ -1,5 +1,6 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole } from 'src/entities/album-user.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { setDifference, setIsEqual, setUnion } from 'src/utils/set'; import { setDifference, setIsEqual, setUnion } from 'src/utils/set';
@ -22,6 +23,7 @@ export enum Permission {
ALBUM_READ = 'album.read', ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update', ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete', ALBUM_DELETE = 'album.delete',
ALBUM_ADD_ASSET = 'album.addAsset',
ALBUM_REMOVE_ASSET = 'album.removeAsset', ALBUM_REMOVE_ASSET = 'album.removeAsset',
ALBUM_SHARE = 'album.share', ALBUM_SHARE = 'album.share',
ALBUM_DOWNLOAD = 'album.download', ALBUM_DOWNLOAD = 'album.download',
@ -142,6 +144,12 @@ export class AccessCore {
: new Set(); : new Set();
} }
case Permission.ALBUM_ADD_ASSET: {
return sharedLink.allowUpload
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
: new Set();
}
default: { default: {
return new Set(); return new Set();
} }
@ -215,7 +223,21 @@ export class AccessCore {
case Permission.ALBUM_READ: { case Permission.ALBUM_READ: {
const isOwner = 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)); 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); return setUnion(isOwner, isShared);
} }
@ -233,12 +255,22 @@ export class AccessCore {
case Permission.ALBUM_DOWNLOAD: { case Permission.ALBUM_DOWNLOAD: {
const isOwner = 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)); const isShared = await this.repository.album.checkSharedAlbumAccess(
auth.user.id,
setDifference(ids, isOwner),
AlbumUserRole.VIEWER,
);
return setUnion(isOwner, isShared); return setUnion(isOwner, isShared);
} }
case Permission.ALBUM_REMOVE_ASSET: { 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: { case Permission.ASSET_UPLOAD: {

View file

@ -1,8 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsEnum, IsString } from 'class-validator'; import { ArrayNotEmpty, IsEnum, IsString } from 'class-validator';
import _ from 'lodash';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.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 { AlbumEntity, AssetOrder } from 'src/entities/album.entity';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
@ -11,10 +13,23 @@ export class AlbumInfoDto {
withoutAssets?: boolean; withoutAssets?: boolean;
} }
export class AlbumUserAddDto {
@ValidateUUID()
userId!: string;
@IsEnum(AlbumUserRole)
@ApiProperty({ enum: AlbumUserRole, enumName: 'AlbumUserRole', default: AlbumUserRole.EDITOR })
role?: AlbumUserRole;
}
export class AddUsersDto { export class AddUsersDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true, optional: true })
@ArrayNotEmpty() @ArrayNotEmpty()
sharedUserIds!: string[]; @ApiProperty({ deprecated: true, description: 'Deprecated in favor of albumUsers' })
sharedUserIds?: string[];
@ArrayNotEmpty()
albumUsers!: AlbumUserAddDto[];
} }
export class CreateAlbumDto { export class CreateAlbumDto {
@ -83,6 +98,18 @@ export class AlbumCountResponseDto {
notShared!: number; 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 { export class AlbumResponseDto {
id!: string; id!: string;
ownerId!: string; ownerId!: string;
@ -92,7 +119,9 @@ export class AlbumResponseDto {
updatedAt!: Date; updatedAt!: Date;
albumThumbnailAssetId!: string | null; albumThumbnailAssetId!: string | null;
shared!: boolean; shared!: boolean;
@ApiProperty({ deprecated: true, description: 'Deprecated in favor of albumUsers' })
sharedUsers!: UserResponseDto[]; sharedUsers!: UserResponseDto[];
albumUsers!: AlbumUserResponseDto[];
hasSharedLink!: boolean; hasSharedLink!: boolean;
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
owner!: UserResponseDto; owner!: UserResponseDto;
@ -109,13 +138,21 @@ export class AlbumResponseDto {
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
const sharedUsers: UserResponseDto[] = []; const sharedUsers: UserResponseDto[] = [];
const albumUsers: AlbumUserResponseDto[] = [];
if (entity.sharedUsers) { if (entity.albumUsers) {
for (const user of entity.sharedUsers) { for (const albumUser of entity.albumUsers) {
sharedUsers.push(mapUser(user)); 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 assets = entity.assets || [];
const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedLink = entity.sharedLinks?.length > 0;
@ -138,6 +175,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
ownerId: entity.ownerId, ownerId: entity.ownerId,
owner: mapUser(entity.owner), owner: mapUser(entity.owner),
sharedUsers, sharedUsers,
albumUsers: albumUsersSorted,
shared: hasSharedUser || hasSharedLink, shared: hasSharedUser || hasSharedLink,
hasSharedLink, hasSharedLink,
startDate, startDate,

View file

@ -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;
}

View file

@ -1,3 +1,4 @@
import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.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 }) @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
albumThumbnailAssetId!: string | null; albumThumbnailAssetId!: string | null;
@ManyToMany(() => UserEntity) @OneToMany(() => AlbumUserEntity, ({ album }) => album, { cascade: true, onDelete: 'CASCADE' })
@JoinTable() albumUsers!: AlbumUserEntity[];
sharedUsers!: UserEntity[];
@ManyToMany(() => AssetEntity, (asset) => asset.albums) @ManyToMany(() => AssetEntity, (asset) => asset.albums)
@JoinTable() @JoinTable()

View file

@ -1,4 +1,5 @@
import { ActivityEntity } from 'src/entities/activity.entity'; import { ActivityEntity } from 'src/entities/activity.entity';
import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { APIKeyEntity } from 'src/entities/api-key.entity'; import { APIKeyEntity } from 'src/entities/api-key.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@ -25,6 +26,7 @@ import { UserEntity } from 'src/entities/user.entity';
export const entities = [ export const entities = [
ActivityEntity, ActivityEntity,
AlbumEntity, AlbumEntity,
AlbumUserEntity,
APIKeyEntity, APIKeyEntity,
AssetEntity, AssetEntity,
AssetStackEntity, AssetStackEntity,

View file

@ -1,3 +1,5 @@
import { AlbumUserRole } from 'src/entities/album-user.entity';
export const IAccessRepository = 'IAccessRepository'; export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository { export interface IAccessRepository {
@ -20,7 +22,7 @@ export interface IAccessRepository {
album: { album: {
checkOwnerAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>; checkOwnerAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
checkSharedAlbumAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>; checkSharedAlbumAccess(userId: string, albumIds: Set<string>, access: AlbumUserRole): Promise<Set<string>>;
checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>>; checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>>;
}; };

View file

@ -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<AlbumUserEntity>): Promise<AlbumUserEntity>;
update({ userId, albumId }: AlbumPermissionId, albumPermission: Partial<AlbumUserEntity>): Promise<AlbumUserEntity>;
delete({ userId, albumId }: AlbumPermissionId): Promise<void>;
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAlbumUserRole1713337511945 implements MigrationInterface {
name = 'AddAlbumUserRole1713337511945'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums_shared_users_users" ADD "role" character varying NOT NULL DEFAULT 'editor'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums_shared_users_users" DROP COLUMN "role"`);
}
}

View file

@ -37,16 +37,16 @@ SELECT
"album"."id" AS "album_id" "album"."id" AS "album_id"
FROM FROM
"albums" "album" "albums" "album"
LEFT JOIN "albums_shared_users_users" "album_sharedUsers" ON "album_sharedUsers"."albumsId" = "album"."id" LEFT JOIN "albums_shared_users_users" "album_albumUsers_users" ON "album_albumUsers_users"."albumsId" = "album"."id"
LEFT JOIN "users" "sharedUsers" ON "sharedUsers"."id" = "album_sharedUsers"."usersId" LEFT JOIN "users" "albumUsers" ON "albumUsers"."id" = "album_albumUsers_users"."usersId"
AND ("sharedUsers"."deletedAt" IS NULL) AND ("albumUsers"."deletedAt" IS NULL)
WHERE WHERE
( (
"album"."id" IN ($1) "album"."id" IN ($1)
AND "album"."isActivityEnabled" = true AND "album"."isActivityEnabled" = true
AND ( AND (
"album"."ownerId" = $2 "album"."ownerId" = $2
OR "sharedUsers"."id" = $2 OR "albumUsers"."id" = $2
) )
) )
AND ("album"."deletedAt" IS NULL) AND ("album"."deletedAt" IS NULL)
@ -70,10 +70,10 @@ SELECT
"AlbumEntity"."id" AS "AlbumEntity_id" "AlbumEntity"."id" AS "AlbumEntity_id"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND ( AND (
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL
) )
WHERE WHERE
( (
@ -81,7 +81,16 @@ WHERE
("AlbumEntity"."id" IN ($1)) ("AlbumEntity"."id" IN ($1))
AND ( 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 "albums_assets_assets" "album_asset" ON "album_asset"."albumsId" = "album"."id"
INNER JOIN "assets" "asset" ON "asset"."id" = "album_asset"."assetsId" INNER JOIN "assets" "asset" ON "asset"."id" = "album_asset"."assetsId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
LEFT JOIN "albums_shared_users_users" "album_sharedUsers" ON "album_sharedUsers"."albumsId" = "album"."id" LEFT JOIN "albums_shared_users_users" "album_albumUsers_users" ON "album_albumUsers_users"."albumsId" = "album"."id"
LEFT JOIN "users" "sharedUsers" ON "sharedUsers"."id" = "album_sharedUsers"."usersId" LEFT JOIN "users" "albumUsers" ON "albumUsers"."id" = "album_albumUsers_users"."usersId"
AND ("sharedUsers"."deletedAt" IS NULL) AND ("albumUsers"."deletedAt" IS NULL)
WHERE WHERE
( (
array["asset"."id", "asset"."livePhotoVideoId"] && array[$1]::uuid [] array["asset"."id", "asset"."livePhotoVideoId"] && array[$1]::uuid []
AND ( AND (
"album"."ownerId" = $2 "album"."ownerId" = $2
OR "sharedUsers"."id" = $2 OR "albumUsers"."id" = $2
) )
) )
AND ("album"."deletedAt" IS NULL) AND ("album"."deletedAt" IS NULL)

View file

@ -32,22 +32,25 @@ FROM
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", "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"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -66,10 +69,10 @@ FROM
AND ( AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL "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 "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND ( 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 "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id"
WHERE WHERE
@ -109,32 +112,35 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes" "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND ( AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL "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 "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND ( AND (
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL
) )
WHERE WHERE
((("AlbumEntity"."id" IN ($1)))) ((("AlbumEntity"."id" IN ($1))))
@ -168,32 +174,35 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes" "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND ( AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL "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 "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND ( 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 "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" 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))) AND ((("AlbumEntity__AlbumEntity_assets"."id" = $4)))
@ -283,22 +294,25 @@ SELECT
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", "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"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -329,10 +343,10 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND ( 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 "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" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
@ -357,22 +371,25 @@ SELECT
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes", "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"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -403,10 +420,10 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND ( 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 "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" 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 ( NOT (
"AlbumEntity__AlbumEntity_sharedUsers"."id" IS NULL "AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL
) )
) )
) )
@ -468,22 +487,9 @@ SELECT
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"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_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -514,11 +520,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM FROM
"albums" "AlbumEntity" "albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."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 "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "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" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND ( AND (
@ -531,7 +533,7 @@ WHERE
AND ( AND (
( (
( (
"AlbumEntity__AlbumEntity_sharedUsers"."id" IS NULL "AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL
) )
) )
) )

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { ActivityEntity } from 'src/entities/activity.entity'; import { ActivityEntity } from 'src/entities/activity.entity';
import { AlbumUserRole } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
@ -81,12 +82,13 @@ class ActivityAccess implements IActivityAccess {
return this.albumRepository return this.albumRepository
.createQueryBuilder('album') .createQueryBuilder('album')
.select('album.id') .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] }) .where('album.id IN (:...albumIds)', { albumIds: [...albumIds] })
.andWhere('album.isActivityEnabled = true') .andWhere('album.isActivityEnabled = true')
.andWhere( .andWhere(
new Brackets((qb) => { 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() .getMany()
@ -120,7 +122,7 @@ class AlbumAccess implements IAlbumAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkSharedAlbumAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> { async checkSharedAlbumAccess(userId: string, albumIds: Set<string>, access: AlbumUserRole): Promise<Set<string>> {
if (albumIds.size === 0) { if (albumIds.size === 0) {
return new Set(); return new Set();
} }
@ -130,8 +132,11 @@ class AlbumAccess implements IAlbumAccess {
select: { id: true }, select: { id: true },
where: { where: {
id: In([...albumIds]), id: In([...albumIds]),
sharedUsers: { albumUsers: {
id: userId, 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 return this.albumRepository
.createQueryBuilder('album') .createQueryBuilder('album')
.innerJoin('album.assets', 'asset') .innerJoin('album.assets', 'asset')
.leftJoin('album.sharedUsers', 'sharedUsers') .leftJoin('album.albumUsers', 'album_albumUsers_users')
.leftJoin('album_albumUsers_users.user', 'albumUsers')
.select('asset.id', 'assetId') .select('asset.id', 'assetId')
.addSelect('asset.livePhotoVideoId', 'livePhotoVideoId') .addSelect('asset.livePhotoVideoId', 'livePhotoVideoId')
.where('array["asset"."id", "asset"."livePhotoVideoId"] && array[:...assetIds]::uuid[]', { .where('array["asset"."id", "asset"."livePhotoVideoId"] && array[:...assetIds]::uuid[]', {
@ -185,7 +191,7 @@ class AssetAccess implements IAssetAccess {
}) })
.andWhere( .andWhere(
new Brackets((qb) => { 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() .getRawMany()

View file

@ -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<AlbumUserEntity>) {}
async create(albumUser: Partial<AlbumUserEntity>): Promise<AlbumUserEntity> {
const { userId, albumId } = await this.repository.save(albumUser);
return this.repository.findOneOrFail({ where: { userId, albumId } });
}
async update({ userId, albumId }: AlbumPermissionId, dto: Partial<AlbumUserEntity>): Promise<AlbumUserEntity> {
await this.repository.update({ userId, albumId }, dto);
return this.repository.findOneOrFail({
where: { userId, albumId },
});
}
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.repository.delete({ userId, albumId });
}
}

View file

@ -10,6 +10,13 @@ import { Instrumentation } from 'src/utils/instrumentation';
import { setUnion } from 'src/utils/set'; import { setUnion } from 'src/utils/set';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => {
if (album) {
album.albumUsers = album.albumUsers.filter((albumUser) => albumUser.user && !albumUser.user.deletedAt);
}
return album;
};
@Instrumentation() @Instrumentation()
@Injectable() @Injectable()
export class AlbumRepository implements IAlbumRepository { export class AlbumRepository implements IAlbumRepository {
@ -20,10 +27,10 @@ export class AlbumRepository implements IAlbumRepository {
) {} ) {}
@GenerateSql({ params: [DummyValue.UUID, {}] }) @GenerateSql({ params: [DummyValue.UUID, {}] })
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> { async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
const relations: FindOptionsRelations<AlbumEntity> = { const relations: FindOptionsRelations<AlbumEntity> = {
owner: true, owner: true,
sharedUsers: true, albumUsers: { user: true },
assets: false, assets: false,
sharedLinks: true, 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]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray() @ChunkedArray()
getByIds(ids: string[]): Promise<AlbumEntity[]> { async getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({ const albums = await this.repository.find({
where: { where: {
id: In(ids), id: In(ids),
}, },
relations: { relations: {
owner: true, owner: true,
sharedUsers: true, albumUsers: { user: true },
}, },
}); });
return albums.map((album) => withoutDeletedUsers(album));
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> { async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
return this.repository.find({ const albums = await this.repository.find({
where: [ where: [
{ ownerId, assets: { id: assetId } }, { 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' }, order: { createdAt: 'DESC' },
}); });
return albums.map((album) => withoutDeletedUsers(album));
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@ -127,40 +139,46 @@ export class AlbumRepository implements IAlbumRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getOwned(ownerId: string): Promise<AlbumEntity[]> { async getOwned(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({ const albums = await this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true }, relations: { albumUsers: { user: true }, sharedLinks: true, owner: true },
where: { ownerId }, where: { ownerId },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
return albums.map((album) => withoutDeletedUsers(album));
} }
/** /**
* Get albums shared with and shared by owner. * Get albums shared with and shared by owner.
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getShared(ownerId: string): Promise<AlbumEntity[]> { async getShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({ const albums = await this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true }, relations: { albumUsers: { user: true }, sharedLinks: true, owner: true },
where: [ where: [
{ sharedUsers: { id: ownerId } }, { albumUsers: { userId: ownerId } },
{ sharedLinks: { userId: ownerId } }, { sharedLinks: { userId: ownerId } },
{ ownerId, sharedUsers: { id: Not(IsNull()) } }, { ownerId, albumUsers: { user: Not(IsNull()) } },
], ],
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
return albums.map((album) => withoutDeletedUsers(album));
} }
/** /**
* Get albums of owner that are _not_ shared * Get albums of owner that are _not_ shared
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getNotShared(ownerId: string): Promise<AlbumEntity[]> { async getNotShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({ const albums = await this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true }, relations: { albumUsers: true, sharedLinks: true, owner: true },
where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } }, where: { ownerId, albumUsers: { user: IsNull() }, sharedLinks: { id: IsNull() } },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
return albums.map((album) => withoutDeletedUsers(album));
} }
async restoreAll(userId: string): Promise<void> { async restoreAll(userId: string): Promise<void> {
@ -282,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository {
where: { id }, where: { id },
relations: { relations: {
owner: true, owner: true,
sharedUsers: true, albumUsers: { user: true },
sharedLinks: true, sharedLinks: true,
assets: true, assets: true,
}, },

View file

@ -1,5 +1,6 @@
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.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 { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository'; import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetStackRepository } from 'src/repositories/asset-stack.repository'; import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
@ -65,6 +67,7 @@ export const repositories = [
{ provide: IActivityRepository, useClass: ActivityRepository }, { provide: IActivityRepository, useClass: ActivityRepository },
{ provide: IAccessRepository, useClass: AccessRepository }, { provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository },
{ provide: IAssetRepository, useClass: AssetRepository }, { provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
{ provide: IAssetStackRepository, useClass: AssetStackRepository }, { provide: IAssetStackRepository, useClass: AssetStackRepository },

View file

@ -1,6 +1,8 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; 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 { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IUserRepository } from 'src/interfaces/user.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 { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; 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 { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
@ -20,14 +23,16 @@ describe(AlbumService.name, () => {
let albumMock: Mocked<IAlbumRepository>; let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let albumUserMock: Mocked<IAlbumUserRepository>;
beforeEach(() => { beforeEach(() => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
albumMock = newAlbumRepositoryMock(); albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
albumUserMock = newAlbumUserRepositoryMock();
sut = new AlbumService(accessMock, albumMock, assetMock, userMock); sut = new AlbumService(accessMock, albumMock, assetMock, userMock, albumUserMock);
}); });
it('should work', () => { it('should work', () => {
@ -189,7 +194,7 @@ describe(AlbumService.name, () => {
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
albumName: albumStub.empty.albumName, albumName: albumStub.empty.albumName,
description: albumStub.empty.description, description: albumStub.empty.description,
sharedUsers: [{ id: 'user-id' }], albumUsers: [{ user: { id: 'user-id' } }],
assets: [{ id: '123' }], assets: [{ id: '123' }],
albumThumbnailAssetId: '123', albumThumbnailAssetId: '123',
}); });
@ -225,7 +230,7 @@ describe(AlbumService.name, () => {
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
albumName: 'Test album', albumName: 'Test album',
description: '', description: '',
sharedUsers: [], albumUsers: [],
assets: [{ id: 'asset-1' }], assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
@ -327,7 +332,7 @@ describe(AlbumService.name, () => {
describe('addUsers', () => { describe('addUsers', () => {
it('should throw an error if the auth user is not the owner', async () => { it('should throw an error if the auth user is not the owner', async () => {
await expect( 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); ).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
@ -336,7 +341,9 @@ describe(AlbumService.name, () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect( 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); ).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
@ -346,7 +353,7 @@ describe(AlbumService.name, () => {
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(null);
await expect( 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); ).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
@ -356,11 +363,19 @@ describe(AlbumService.name, () => {
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(userStub.user2); userMock.get.mockResolvedValue(userStub.user2);
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.user.id] }); albumUserMock.create.mockResolvedValue({
expect(albumMock.update).toHaveBeenCalledWith({ userId: userStub.user2.id,
id: albumStub.sharedWithAdmin.id, user: userStub.user2,
updatedAt: expect.any(Date), albumId: albumStub.sharedWithAdmin.id,
sharedUsers: [userStub.admin, { id: authStub.user2.user.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), sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(albumMock.update).toHaveBeenCalledTimes(1); expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumUserMock.delete).toHaveBeenCalledWith({
id: albumStub.sharedWithUser.id, albumId: albumStub.sharedWithUser.id,
updatedAt: expect.any(Date), userId: userStub.user1.id,
sharedUsers: [],
}); });
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false }); 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), sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.user.id),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled(); expect(albumUserMock.delete).not.toHaveBeenCalled();
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.user1.user.id, authStub.user1.user.id,
new Set([albumStub.sharedWithMultiple.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); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.user.id);
expect(albumMock.update).toHaveBeenCalledTimes(1); expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumUserMock.delete).toHaveBeenCalledWith({
id: albumStub.sharedWithUser.id, albumId: albumStub.sharedWithUser.id,
updatedAt: expect.any(Date), userId: authStub.user1.user.id,
sharedUsers: [],
}); });
}); });
@ -422,11 +435,10 @@ describe(AlbumService.name, () => {
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
expect(albumMock.update).toHaveBeenCalledTimes(1); expect(albumUserMock.delete).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumUserMock.delete).toHaveBeenCalledWith({
id: albumStub.sharedWithUser.id, albumId: albumStub.sharedWithUser.id,
updatedAt: expect.any(Date), userId: authStub.user1.user.id,
sharedUsers: [],
}); });
}); });
@ -512,6 +524,7 @@ describe(AlbumService.name, () => {
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
authStub.user1.user.id, authStub.user1.user.id,
new Set(['album-123']), new Set(['album-123']),
AlbumUserRole.VIEWER,
); );
}); });
@ -522,6 +535,7 @@ describe(AlbumService.name, () => {
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith( expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set(['album-123']), 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']); 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 () => { it('should allow a shared link user to add assets', async () => {
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
@ -709,7 +734,7 @@ describe(AlbumService.name, () => {
expect(albumMock.update).not.toHaveBeenCalled(); 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'])); accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id']));

View file

@ -14,10 +14,11 @@ import {
} from 'src/dtos/album.dto'; } from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.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 { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; 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 { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@ -31,6 +32,7 @@ export class AlbumService {
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository,
) { ) {
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
} }
@ -126,7 +128,7 @@ export class AlbumService {
ownerId: auth.user.id, ownerId: auth.user.id,
albumName: dto.albumName, albumName: dto.albumName,
description: dto.description, description: dto.description,
sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value }) as UserEntity) ?? [], albumUsers: dto.sharedWithUserIds?.map((userId) => ({ user: { id: userId } }) as AlbumUserEntity) ?? [],
assets, assets,
albumThumbnailAssetId: assets[0]?.id || null, albumThumbnailAssetId: assets[0]?.id || null,
}); });
@ -167,7 +169,7 @@ export class AlbumService {
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false }); 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( const results = await addAssets(
auth, auth,
@ -190,7 +192,7 @@ export class AlbumService {
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false }); 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( const results = await removeAssets(
auth, auth,
@ -209,17 +211,25 @@ export class AlbumService {
return results; return results;
} }
async addUsers(auth: AuthDto, id: string, dto: AddUsersDto): Promise<AlbumResponseDto> { async addUsers(auth: AuthDto, id: string, { albumUsers, sharedUserIds }: AddUsersDto): Promise<AlbumResponseDto> {
// 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); await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id);
const album = await this.findOrFail(id, { withAssets: false }); const album = await this.findOrFail(id, { withAssets: false });
for (const userId of dto.sharedUserIds) { for (const { userId, role } of albumUsers) {
if (album.ownerId === userId) { if (album.ownerId === userId) {
throw new BadRequestException('Cannot be shared with owner'); 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) { if (exists) {
throw new BadRequestException('User already added'); throw new BadRequestException('User already added');
} }
@ -229,16 +239,10 @@ export class AlbumService {
throw new BadRequestException('User not found'); 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 return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
.update({
id: album.id,
updatedAt: new Date(),
sharedUsers: album.sharedUsers,
})
.then(mapAlbumWithoutAssets);
} }
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> { async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@ -252,7 +256,7 @@ export class AlbumService {
throw new BadRequestException('Cannot remove album owner'); 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) { if (!exists) {
throw new BadRequestException('Album not shared with user'); 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.access.requirePermission(auth, Permission.ALBUM_SHARE, id);
} }
await this.albumRepository.update({ await this.albumUserRepository.delete({ albumId: id, userId });
id: album.id, }
updatedAt: new Date(),
sharedUsers: album.sharedUsers.filter((user) => user.id !== userId), async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> {
}); 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) { private async findOrFail(id: string, options: AlbumInfoOptions) {

View file

@ -1,3 +1,4 @@
import { AlbumUserRole } from 'src/entities/album-user.entity';
import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; import { AlbumEntity, AssetOrder } from 'src/entities/album.entity';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
@ -17,7 +18,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -34,7 +35,15 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], 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, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -51,7 +60,22 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], 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, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -68,7 +92,15 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], 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, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -85,7 +117,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -102,7 +134,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -119,7 +151,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -136,7 +168,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -153,7 +185,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),
@ -170,7 +202,7 @@ export const albumStub = {
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: null, deletedAt: null,
sharedLinks: [], sharedLinks: [],
sharedUsers: [], albumUsers: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,
}), }),

View file

@ -467,6 +467,7 @@ export const assetStub = {
library: libraryStub.uploadLibrary1, library: libraryStub.uploadLibrary1,
exifInfo: { exifInfo: {
fileSizeInByte: 100_000, fileSizeInByte: 100_000,
timeZone: `America/New_York`,
}, },
} as AssetEntity), } as AssetEntity),
@ -483,6 +484,7 @@ export const assetStub = {
library: libraryStub.uploadLibrary1, library: libraryStub.uploadLibrary1,
exifInfo: { exifInfo: {
fileSizeInByte: 25_000, fileSizeInByte: 25_000,
timeZone: `America/New_York`,
}, },
} as AssetEntity), } as AssetEntity),

View file

@ -103,6 +103,7 @@ const albumResponse: AlbumResponseDto = {
ownerId: 'admin_id', ownerId: 'admin_id',
owner: mapUser(userStub.admin), owner: mapUser(userStub.admin),
sharedUsers: [], sharedUsers: [],
albumUsers: [],
shared: false, shared: false,
hasSharedLink: false, hasSharedLink: false,
assets: [], assets: [],
@ -186,7 +187,7 @@ export const sharedLinkStub = {
deletedAt: null, deletedAt: null,
albumThumbnailAsset: null, albumThumbnailAsset: null,
albumThumbnailAssetId: null, albumThumbnailAssetId: null,
sharedUsers: [], albumUsers: [],
sharedLinks: [], sharedLinks: [],
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.DESC, order: AssetOrder.DESC,

View file

@ -0,0 +1,10 @@
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { Mocked } from 'vitest';
export const newAlbumUserRepositoryMock = (): Mocked<IAlbumUserRepository> => {
return {
create: vitest.fn(),
delete: vitest.fn(),
update: vitest.fn(),
};
};

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { groupBy, orderBy } from 'lodash-es'; import { groupBy, orderBy } from 'lodash-es';
import { addUsersToAlbum, deleteAlbum, type UserResponseDto, type AlbumResponseDto } from '@immich/sdk'; import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto } from '@immich/sdk';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js'; import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte'; import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
@ -328,7 +328,7 @@
updateAlbumInfo(album); updateAlbumInfo(album);
}; };
const handleAddUsers = async (users: UserResponseDto[]) => { const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
if (!albumToShare) { if (!albumToShare) {
return; return;
} }
@ -336,7 +336,7 @@
const album = await addUsersToAlbum({ const album = await addUsersToAlbum({
id: albumToShare.id, id: albumToShare.id,
addUsersDto: { addUsersDto: {
sharedUserIds: [...users].map(({ id }) => id), albumUsers,
}, },
}); });
updateAlbumInfo(album); updateAlbumInfo(album);

View file

@ -1,5 +1,12 @@
<script lang="ts"> <script lang="ts">
import { getMyUserInfo, removeUserFromAlbum, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk'; import {
getMyUserInfo,
removeUserFromAlbum,
type AlbumResponseDto,
type UserResponseDto,
updateAlbumUser,
AlbumUserRole,
} from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js'; import { mdiDotsVertical } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { getContextMenuPosition } from '../../utils/context-menu'; import { getContextMenuPosition } from '../../utils/context-menu';
@ -17,6 +24,7 @@
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
remove: string; remove: string;
refreshAlbum: void;
}>(); }>();
let currentUser: UserResponseDto; let currentUser: UserResponseDto;
@ -63,6 +71,19 @@
selectedRemoveUser = null; selectedRemoveUser = null;
} }
}; };
const handleSetReadonly = async (user: UserResponseDto, role: AlbumUserRole) => {
try {
await updateAlbumUser({ id: album.id, userId: user.id, updateAlbumUserDto: { role } });
const message = `Set ${user.name} as ${role}`;
dispatch('refreshAlbum');
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, 'Unable to set user role');
} finally {
selectedRemoveUser = null;
}
};
</script> </script>
{#if !selectedRemoveUser} {#if !selectedRemoveUser}
@ -78,7 +99,7 @@
<p class="text-sm">Owner</p> <p class="text-sm">Owner</p>
</div> </div>
</div> </div>
{#each album.sharedUsers as user} {#each album.albumUsers as { user, role }}
<div <div
class="flex w-full place-items-center justify-between gap-4 p-5 rounded-xl transition-colors hover:bg-gray-50 dark:hover:bg-gray-700" class="flex w-full place-items-center justify-between gap-4 p-5 rounded-xl transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
> >
@ -87,7 +108,14 @@
<p class="text-sm font-medium">{user.name}</p> <p class="text-sm font-medium">{user.name}</p>
</div> </div>
<div id="icon-{user.id}" class="flex place-items-center"> <div id="icon-{user.id}" class="flex place-items-center gap-2 text-sm">
<div>
{#if role === AlbumUserRole.Viewer}
Viewer
{:else}
Editor
{/if}
</div>
{#if isOwned} {#if isOwned}
<div> <div>
<CircleIconButton <CircleIconButton
@ -101,6 +129,14 @@
{#if selectedMenuUser === user} {#if selectedMenuUser === user}
<ContextMenu {...position} on:outclick={() => (selectedMenuUser = null)}> <ContextMenu {...position} on:outclick={() => (selectedMenuUser = null)}>
{#if role === AlbumUserRole.Viewer}
<MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text="Allow edits" />
{:else}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
text="Disallow edits"
/>
{/if}
<MenuOption on:click={handleMenuRemove} text="Remove" /> <MenuOption on:click={handleMenuRemove} text="Remove" />
</ContextMenu> </ContextMenu>
{/if} {/if}

View file

@ -1,27 +1,36 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { import {
AlbumUserRole,
getAllSharedLinks, getAllSharedLinks,
getAllUsers, getAllUsers,
type AlbumResponseDto, type AlbumResponseDto,
type AlbumUserAddDto,
type SharedLinkResponseDto, type SharedLinkResponseDto,
type UserResponseDto, type UserResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiCheck, mdiLink, mdiShareCircle } from '@mdi/js'; import { mdiCheck, mdiEye, mdiLink, mdiPencil, mdiShareCircle } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
export let onClose: () => void; export let onClose: () => void;
let users: UserResponseDto[] = []; let users: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = []; let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {};
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
{ title: 'Editor', value: AlbumUserRole.Editor, icon: mdiPencil },
{ title: 'Viewer', value: AlbumUserRole.Viewer, icon: mdiEye },
{ title: 'Remove', value: 'none' },
];
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
select: UserResponseDto[]; select: AlbumUserAddDto[];
share: void; share: void;
}>(); }>();
let sharedLinks: SharedLinkResponseDto[] = []; let sharedLinks: SharedLinkResponseDto[] = [];
@ -43,57 +52,79 @@
sharedLinks = data.filter((link) => link.album?.id === album.id); sharedLinks = data.filter((link) => link.album?.id === album.id);
}; };
const handleSelect = (user: UserResponseDto) => { const handleToggle = (user: UserResponseDto) => {
selectedUsers = selectedUsers.includes(user) if (Object.keys(selectedUsers).includes(user.id)) {
? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id) delete selectedUsers[user.id];
: [...selectedUsers, user]; selectedUsers = selectedUsers;
} else {
selectedUsers[user.id] = { user, role: AlbumUserRole.Editor };
}
}; };
const handleUnselect = (user: UserResponseDto) => { const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); if (role === 'none') {
delete selectedUsers[user.id];
selectedUsers = selectedUsers;
} else {
selectedUsers[user.id].role = role;
}
}; };
</script> </script>
<FullScreenModal id="user-selection-modal" title="Invite to album" showLogo {onClose}> <FullScreenModal id="user-selection-modal" title="Invite to album" showLogo {onClose}>
{#if selectedUsers.length > 0} {#if Object.keys(selectedUsers).length > 0}
<div class="mb-2 flex flex-wrap place-items-center gap-4 overflow-x-auto px-5 py-2 sticky"> <div class="mb-2 py-2 sticky">
<p class="font-medium">To</p> <p class="text-xs font-medium">SELECTED</p>
<div class="my-2">
{#each selectedUsers as user} {#each Object.values(selectedUsers) as { user }}
{#key user.id} {#key user.id}
<button <div class="flex place-items-center gap-4 p-4">
on:click={() => handleUnselect(user)}
class="flex place-items-center gap-1 rounded-full border border-gray-500 p-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
>
<UserAvatar {user} size="sm" />
<p class="text-xs font-medium">{user.name}</p>
</button>
{/key}
{/each}
</div>
{/if}
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
{#if users.length > 0}
<p class="text-xs font-medium">SUGGESTIONS</p>
<div class="my-4">
{#each users as user}
<button
on:click={() => handleSelect(user)}
class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
{#if selectedUsers.includes(user)}
<div <div
class="flex h-10 w-10 items-center justify-center rounded-full border bg-immich-primary text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-primary dark:text-immich-dark-bg" class="flex h-10 w-10 items-center justify-center rounded-full border bg-immich-dark-success text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-success"
> >
<Icon path={mdiCheck} size={24} /> <Icon path={mdiCheck} size={24} />
</div> </div>
{:else}
<UserAvatar {user} size="md" /> <!-- <UserAvatar {user} size="md" /> -->
<div class="text-left flex-grow">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.name}
</p>
<p class="text-xs">
{user.email}
</p>
</div>
<Dropdown
title="Role"
options={roleOptions}
render={({ title, icon }) => ({ title, icon })}
on:select={({ detail: { value } }) => handleChangeRole(user, value)}
/>
</div>
{/key}
{/each}
</div>
</div>
{/if} {/if}
<div class="text-left"> {#if users.length + Object.keys(selectedUsers).length === 0}
<p class="p-5 text-sm">
Looks like you have shared this album with all users or you don't have any user to share with.
</p>
{/if}
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
<p class="text-xs font-medium">SUGGESTIONS</p>
<div class="my-2">
{#each users as user}
{#if !Object.keys(selectedUsers).includes(user.id)}
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
<button on:click={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4">
<UserAvatar {user} size="md" />
<div class="text-left flex-grow">
<p class="text-immich-fg dark:text-immich-dark-fg"> <p class="text-immich-fg dark:text-immich-dark-fg">
{user.name} {user.name}
</p> </p>
@ -102,12 +133,10 @@
</p> </p>
</div> </div>
</button> </button>
</div>
{/if}
{/each} {/each}
</div> </div>
{:else}
<p class="p-5 text-sm">
Looks like you have shared this album with all users or you don't have any user to share with.
</p>
{/if} {/if}
</div> </div>
@ -117,8 +146,12 @@
size="sm" size="sm"
fullwidth fullwidth
rounded="full" rounded="full"
disabled={selectedUsers.length === 0} disabled={Object.keys(selectedUsers).length === 0}
on:click={() => dispatch('select', selectedUsers)}>Add</Button on:click={() =>
dispatch(
'select',
Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
)}>Add</Button
> >
</div> </div>
{/if} {/if}

View file

@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import AlbumOptions from '$lib/components/album-page/album-options.svelte'; import AlbumOptions from '$lib/components/album-page/album-options.svelte';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
import AlbumTitle from '$lib/components/album-page/album-title.svelte';
import ShareInfoModal from '$lib/components/album-page/share-info-modal.svelte'; import ShareInfoModal from '$lib/components/album-page/share-info-modal.svelte';
import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte'; import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte';
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
@ -39,12 +42,16 @@
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { downloadAlbum } from '$lib/utils/asset-utils'; import { downloadAlbum } from '$lib/utils/asset-utils';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu'; import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
import { import {
AlbumUserRole,
AssetOrder,
ReactionLevel, ReactionLevel,
ReactionType, ReactionType,
addAssetsToAlbum, addAssetsToAlbum,
@ -57,29 +64,23 @@
getAlbumInfo, getAlbumInfo,
updateAlbumInfo, updateAlbumInfo,
type ActivityResponseDto, type ActivityResponseDto,
type UserResponseDto, type AlbumUserAddDto,
AssetOrder,
} from '@immich/sdk'; } from '@immich/sdk';
import { import {
mdiArrowLeft, mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline, mdiDeleteOutline,
mdiDotsVertical, mdiDotsVertical,
mdiFolderDownloadOutline, mdiFolderDownloadOutline,
mdiLink,
mdiPlus,
mdiShareVariantOutline,
mdiPresentationPlay,
mdiCogOutline,
mdiImageOutline, mdiImageOutline,
mdiImagePlusOutline, mdiImagePlusOutline,
mdiLink,
mdiPlus,
mdiPresentationPlay,
mdiShareVariantOutline,
} from '@mdi/js'; } from '@mdi/js';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import AlbumTitle from '$lib/components/album-page/album-title.svelte';
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
export let data: PageData; export let data: PageData;
@ -137,6 +138,9 @@
$: showActivityStatus = $: showActivityStatus =
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0); album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
$: isEditor = album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor;
$: albumHasViewers = album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer);
afterNavigate(({ from }) => { afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname; let url: string | undefined = from?.url?.pathname;
@ -312,12 +316,12 @@
viewMode = ViewMode.VIEW; viewMode = ViewMode.VIEW;
}; };
const handleAddUsers = async (users: UserResponseDto[]) => { const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
try { try {
album = await addUsersToAlbum({ album = await addUsersToAlbum({
id: album.id, id: album.id,
addUsersDto: { addUsersDto: {
sharedUserIds: [...users].map(({ id }) => id), albumUsers,
}, },
}); });
@ -335,9 +339,9 @@
try { try {
await refreshAlbum(); await refreshAlbum();
viewMode = album.sharedUsers.length > 1 ? ViewMode.SELECT_USERS : ViewMode.VIEW; viewMode = album.sharedUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW;
} catch (error) { } catch (error) {
handleError(error, 'Error deleting share users'); handleError(error, 'Error deleting shared user');
} }
}; };
@ -433,11 +437,13 @@
{#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS} {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}> <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
<svelte:fragment slot="trailing"> <svelte:fragment slot="trailing">
{#if isEditor}
<CircleIconButton <CircleIconButton
title="Add photos" title="Add photos"
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)} on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
icon={mdiImagePlusOutline} icon={mdiImagePlusOutline}
/> />
{/if}
{#if isOwned} {#if isOwned}
<CircleIconButton <CircleIconButton
@ -578,13 +584,25 @@
<UserAvatar user={album.owner} size="md" /> <UserAvatar user={album.owner} size="md" />
</button> </button>
<!-- users --> <!-- users with write access (collaborators) -->
{#each album.sharedUsers as user (user.id)} {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}> <button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar {user} size="md" /> <UserAvatar {user} size="md" />
</button> </button>
{/each} {/each}
<!-- display ellipsis if there are readonly users too -->
{#if albumHasViewers}
<CircleIconButton
title="View all users"
backgroundColor="#d3d3d3"
forceDark
size="20"
icon={mdiDotsVertical}
on:click={() => (viewMode = ViewMode.VIEW_USERS)}
/>
{/if}
{#if isOwned} {#if isOwned}
<CircleIconButton <CircleIconButton
backgroundColor="#d3d3d3" backgroundColor="#d3d3d3"
@ -678,6 +696,7 @@
onClose={() => (viewMode = ViewMode.VIEW)} onClose={() => (viewMode = ViewMode.VIEW)}
{album} {album}
on:remove={({ detail: userId }) => handleRemoveUser(userId)} on:remove={({ detail: userId }) => handleRemoveUser(userId)}
on:refreshAlbum={refreshAlbum}
/> />
{/if} {/if}

View file

@ -16,6 +16,7 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
owner: userFactory.build(), owner: userFactory.build(),
shared: false, shared: false,
sharedUsers: [], sharedUsers: [],
albumUsers: [],
hasSharedLink: false, hasSharedLink: false,
isActivityEnabled: true, isActivityEnabled: true,
order: AssetOrder.Desc, order: AssetOrder.Desc,