diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index d9ac1eddbe..3e4f971cfd 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -27,7 +27,7 @@ describe('/library', () => { await utils.resetDatabase(); admin = await utils.adminSetup(); user = await utils.userSetup(admin.accessToken, userDto.user1); - library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External }); websocket = await utils.connectWebsocket(admin.accessToken); }); @@ -82,7 +82,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${user.accessToken}`) - .send({ type: LibraryType.External }); + .send({ ownerId: admin.userId, type: LibraryType.External }); expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); @@ -92,7 +92,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.External }); + .send({ ownerId: admin.userId, type: LibraryType.External }); expect(status).toBe(201); expect(body).toEqual( @@ -113,6 +113,7 @@ describe('/library', () => { .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ + ownerId: admin.userId, type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path/to/import'], @@ -133,6 +134,7 @@ describe('/library', () => { .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ + ownerId: admin.userId, type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path', '/path'], @@ -148,6 +150,7 @@ describe('/library', () => { .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ + ownerId: admin.userId, type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path/to/import'], @@ -162,7 +165,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload }); + .send({ ownerId: admin.userId, type: LibraryType.Upload }); expect(status).toBe(201); expect(body).toEqual( @@ -182,7 +185,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload, name: 'My Awesome Library' }); + .send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' }); expect(status).toBe(201); expect(body).toEqual( @@ -196,7 +199,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] }); + .send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths')); @@ -206,7 +209,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); + .send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns')); @@ -330,7 +333,10 @@ describe('/library', () => { }); it('should get library by id', async () => { - const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + type: LibraryType.External, + }); const { status, body } = await request(app) .get(`/library/${library.id}`) @@ -386,7 +392,10 @@ describe('/library', () => { }); it('should delete an external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + type: LibraryType.External, + }); const { status, body } = await request(app) .delete(`/library/${library.id}`) @@ -407,6 +416,7 @@ describe('/library', () => { it('should delete an external library with assets', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -455,6 +465,7 @@ describe('/library', () => { it('should not scan an upload library', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.Upload, }); @@ -468,6 +479,7 @@ describe('/library', () => { it('should scan external library', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); @@ -483,6 +495,7 @@ describe('/library', () => { it('should scan external library with exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], exclusionPatterns: ['**/directoryA'], @@ -499,6 +512,7 @@ describe('/library', () => { it('should scan multiple import paths', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); @@ -515,6 +529,7 @@ describe('/library', () => { it('should pick up new files', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); diff --git a/mobile/openapi/doc/CreateLibraryDto.md b/mobile/openapi/doc/CreateLibraryDto.md index e0caf1c8a5..94e96493ec 100644 Binary files a/mobile/openapi/doc/CreateLibraryDto.md and b/mobile/openapi/doc/CreateLibraryDto.md differ diff --git a/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md b/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md index 4601d8d2f2..1ebcb04efd 100644 Binary files a/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md and b/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md differ diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index ef656ea2a3..24cc045300 100644 Binary files a/mobile/openapi/lib/model/create_library_dto.dart and b/mobile/openapi/lib/model/create_library_dto.dart differ diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 1297c824c2..142055f2cd 100644 Binary files a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart and b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 15ada078cb..82562100a1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7646,6 +7646,7 @@ } }, "required": [ + "ownerId", "type" ], "type": "object" @@ -10689,7 +10690,8 @@ } }, "required": [ - "importPath" + "importPath", + "isValid" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6a660f4e1b..6b50642520 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -466,7 +466,7 @@ export type CreateLibraryDto = { isVisible?: boolean; isWatched?: boolean; name?: string; - ownerId?: string; + ownerId: string; "type": LibraryType; }; export type UpdateLibraryDto = { @@ -491,7 +491,7 @@ export type ValidateLibraryDto = { }; export type ValidateLibraryImportPathResponseDto = { importPath: string; - isValid?: boolean; + isValid: boolean; message?: string; }; export type ValidateLibraryResponseDto = { diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts index 4757876898..5f05d736bc 100644 --- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts +++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts @@ -46,6 +46,7 @@ describe(`Library watcher (e2e)`, () => { describe('Single import path', () => { beforeEach(async () => { await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -133,6 +134,7 @@ describe(`Library watcher (e2e)`, () => { await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [ `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, @@ -190,6 +192,7 @@ describe(`Library watcher (e2e)`, () => { beforeEach(async () => { library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [ `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 4ebb00c4df..a4ee4977a3 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -1,6 +1,6 @@ -import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; +import { LoginResponseDto } from '@app/domain'; import { LibraryController } from '@app/immich'; -import { AssetType, LibraryType } from '@app/infra/entities'; +import { LibraryType } from '@app/infra/entities'; import { errorStub, uuidStub } from '@test/fixtures'; import * as fs from 'node:fs'; import request from 'supertest'; @@ -41,6 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => { }); const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -72,6 +73,7 @@ describe(`${LibraryController.name} (e2e)`, () => { it('should scan new files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -107,6 +109,7 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('with refreshModifiedFiles=true', () => { it('should reimport modified files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -153,6 +156,7 @@ describe(`${LibraryController.name} (e2e)`, () => { it('should not reimport unmodified files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -192,6 +196,7 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('with refreshAllFiles=true', () => { it('should reimport all files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -251,6 +256,7 @@ describe(`${LibraryController.name} (e2e)`, () => { }); const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -277,6 +283,7 @@ describe(`${LibraryController.name} (e2e)`, () => { it('should not remove online files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], }); diff --git a/server/package.json b/server/package.json index c575571d07..d60f67a1f8 100644 --- a/server/package.json +++ b/server/package.json @@ -153,7 +153,7 @@ "coverageDirectory": "./coverage", "coverageThreshold": { "./src/domain/": { - "branches": 79, + "branches": 75, "functions": 80, "lines": 90, "statements": 90 diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 7063cb49a2..40b01de1da 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -33,12 +33,6 @@ export enum Permission { TIMELINE_READ = 'timeline.read', TIMELINE_DOWNLOAD = 'timeline.download', - LIBRARY_CREATE = 'library.create', - LIBRARY_READ = 'library.read', - LIBRARY_UPDATE = 'library.update', - LIBRARY_DELETE = 'library.delete', - LIBRARY_DOWNLOAD = 'library.download', - PERSON_READ = 'person.read', PERSON_WRITE = 'person.write', PERSON_MERGE = 'person.merge', @@ -261,29 +255,6 @@ export class AccessCore { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } - case Permission.LIBRARY_READ: { - if (auth.user.isAdmin) { - return new Set(ids); - } - const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids); - const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.LIBRARY_UPDATE: { - if (auth.user.isAdmin) { - return new Set(ids); - } - return await this.repository.library.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.LIBRARY_DELETE: { - if (auth.user.isAdmin) { - return new Set(ids); - } - return await this.repository.library.checkOwnerAccess(auth.user.id, ids); - } - case Permission.PERSON_READ: { return await this.repository.person.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts index 5e4bb4ec6b..fcce02f878 100644 --- a/server/src/domain/library/library.dto.ts +++ b/server/src/domain/library/library.dto.ts @@ -8,8 +8,8 @@ export class CreateLibraryDto { @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) type!: LibraryType; - @ValidateUUID({ optional: true }) - ownerId?: string; + @ValidateUUID() + ownerId!: string; @IsString() @Optional() diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 57bdf7373d..3b5258b975 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -706,7 +706,7 @@ describe(LibraryService.name, () => { libraryMock.getUploadLibraryCount.mockResolvedValue(2); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); + await sut.delete(libraryStub.externalLibrary1.id); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, @@ -721,9 +721,7 @@ describe(LibraryService.name, () => { libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); @@ -735,7 +733,7 @@ describe(LibraryService.name, () => { libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); + await sut.delete(libraryStub.externalLibrary1.id); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, @@ -757,26 +755,16 @@ describe(LibraryService.name, () => { storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.init(); - await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id); + await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); expect(mockClose).toHaveBeenCalled(); }); }); - describe('getCount', () => { - it('should call the repository', async () => { - libraryMock.getCountForUser.mockResolvedValue(17); - - await expect(sut.getCount(authStub.admin)).resolves.toBe(17); - - expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.user.id); - }); - }); - describe('get', () => { it('should return a library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual( + await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual( expect.objectContaining({ id: libraryStub.uploadLibrary1.id, name: libraryStub.uploadLibrary1.name, @@ -789,15 +777,16 @@ describe(LibraryService.name, () => { it('should throw an error when a library is not found', async () => { libraryMock.get.mockResolvedValue(null); - await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); - await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({ + await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({ photos: 10, videos: 0, total: 10, @@ -812,11 +801,7 @@ describe(LibraryService.name, () => { describe('external library', () => { it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); - await expect( - sut.create(authStub.admin, { - type: LibraryType.EXTERNAL, - }), - ).resolves.toEqual( + await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, type: LibraryType.EXTERNAL, @@ -845,10 +830,7 @@ describe(LibraryService.name, () => { it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { - type: LibraryType.EXTERNAL, - name: 'My Awesome Library', - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -878,10 +860,7 @@ describe(LibraryService.name, () => { it('should create invisible', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { - type: LibraryType.EXTERNAL, - isVisible: false, - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -911,7 +890,8 @@ describe(LibraryService.name, () => { it('should create with import paths', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, importPaths: ['/data/images', '/data/videos'], }), @@ -948,7 +928,8 @@ describe(LibraryService.name, () => { libraryMock.getAll.mockResolvedValue([]); await sut.init(); - await sut.create(authStub.admin, { + await sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, }); @@ -963,7 +944,8 @@ describe(LibraryService.name, () => { it('should create with exclusion patterns', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, exclusionPatterns: ['*.tmp', '*.bak'], }), @@ -997,11 +979,7 @@ describe(LibraryService.name, () => { describe('upload library', () => { it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); - await expect( - sut.create(authStub.admin, { - type: LibraryType.UPLOAD, - }), - ).resolves.toEqual( + await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual( expect.objectContaining({ id: libraryStub.uploadLibrary1.id, type: LibraryType.UPLOAD, @@ -1030,10 +1008,7 @@ describe(LibraryService.name, () => { it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); await expect( - sut.create(authStub.admin, { - type: LibraryType.UPLOAD, - name: 'My Awesome Library', - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.uploadLibrary1.id, @@ -1062,7 +1037,8 @@ describe(LibraryService.name, () => { it('should not create with import paths', async () => { await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, importPaths: ['/data/images', '/data/videos'], }), @@ -1073,7 +1049,8 @@ describe(LibraryService.name, () => { it('should not create with exclusion patterns', async () => { await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, exclusionPatterns: ['*.tmp', '*.bak'], }), @@ -1084,10 +1061,7 @@ describe(LibraryService.name, () => { it('should not create watched', async () => { await expect( - sut.create(authStub.admin, { - type: LibraryType.UPLOAD, - isWatched: true, - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }), ).rejects.toBeInstanceOf(BadRequestException); expect(storageMock.watch).not.toHaveBeenCalled(); @@ -1117,14 +1091,9 @@ describe(LibraryService.name, () => { it('should update library', async () => { libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual( - mapLibrary(libraryStub.uploadLibrary1), - ); - expect(libraryMock.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: authStub.admin.user.id, - }), - ); + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1)); + expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); }); it('should re-watch library when updating import paths', async () => { @@ -1137,15 +1106,11 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - await expect( - sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }), - ).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1)); - - expect(libraryMock.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: authStub.admin.user.id, - }), + await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual( + mapLibrary(libraryStub.externalLibraryWithImportPaths1), ); + + expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); expect(storageMock.watch).toHaveBeenCalledWith( libraryStub.externalLibraryWithImportPaths1.importPaths, expect.anything(), @@ -1158,15 +1123,11 @@ describe(LibraryService.name, () => { configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual( + await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual( mapLibrary(libraryStub.externalLibraryWithImportPaths1), ); - expect(libraryMock.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: authStub.admin.user.id, - }), - ); + expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); expect(storageMock.watch).toHaveBeenCalledWith( expect.arrayContaining([expect.any(String)]), expect.anything(), @@ -1411,7 +1372,7 @@ describe(LibraryService.name, () => { it('should queue a library scan of external library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id, {}); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1430,9 +1391,7 @@ describe(LibraryService.name, () => { it('should not queue a library scan of upload library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException); expect(jobMock.queue).not.toBeCalled(); }); @@ -1440,7 +1399,7 @@ describe(LibraryService.name, () => { it('should queue a library scan of all modified assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); + await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1459,7 +1418,7 @@ describe(LibraryService.name, () => { it('should queue a forced library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true }); + await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1478,7 +1437,7 @@ describe(LibraryService.name, () => { describe('queueEmptyTrash', () => { it('should queue the trash job', async () => { - await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id); + await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1566,17 +1525,15 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: true, + message: undefined, + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: true, - message: undefined, - }, - ]); }); it('should detect when path does not exist', async () => { @@ -1585,17 +1542,15 @@ describe(LibraryService.name, () => { throw error; }); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: false, + message: 'Path does not exist (ENOENT)', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: false, - message: 'Path does not exist (ENOENT)', - }, - ]); }); it('should detect when path is not a directory', async () => { @@ -1603,17 +1558,15 @@ describe(LibraryService.name, () => { isDirectory: () => false, } as Stats); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/file'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/file', + isValid: false, + message: 'Not a directory', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/file', - isValid: false, - message: 'Not a directory', - }, - ]); }); it('should return an unknown exception from stat', async () => { @@ -1621,17 +1574,15 @@ describe(LibraryService.name, () => { throw new Error('Unknown error'); }); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: false, + message: 'Error: Unknown error', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: false, - message: 'Error: Unknown error', - }, - ]); }); it('should detect when access rights are missing', async () => { @@ -1641,17 +1592,15 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(false); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: false, + message: 'Lacking read permission for folder', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: false, - message: 'Lacking read permission for folder', - }, - ]); }); it('should detect when import path is in immich media folder', async () => { @@ -1659,26 +1608,26 @@ describe(LibraryService.name, () => { const validImport = libraryStub.hasImmichPaths.importPaths[1]; when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true); - const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, { - importPaths: libraryStub.hasImmichPaths.importPaths, + await expect( + sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }), + ).resolves.toEqual({ + importPaths: [ + { + importPath: libraryStub.hasImmichPaths.importPaths[0], + isValid: false, + message: 'Cannot use media upload folder for external libraries', + }, + { + importPath: validImport, + isValid: true, + }, + { + importPath: libraryStub.hasImmichPaths.importPaths[2], + isValid: false, + message: 'Cannot use media upload folder for external libraries', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: libraryStub.hasImmichPaths.importPaths[0], - isValid: false, - message: 'Cannot use media upload folder for external libraries', - }, - { - importPath: validImport, - isValid: true, - }, - { - importPath: libraryStub.hasImmichPaths.importPaths[2], - isValid: false, - message: 'Cannot use media upload folder for external libraries', - }, - ]); }); }); }); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 12d135fa30..000acac29d 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -8,8 +8,7 @@ import { EventEmitter } from 'node:events'; import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; -import { AccessCore, Permission } from '../access'; -import { AuthDto } from '../auth'; +import { AccessCore } from '../access'; import { mimeTypes } from '../domain.constant'; import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; @@ -226,24 +225,17 @@ export class LibraryService extends EventEmitter { } } - async getStatistics(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); - + async getStatistics(id: string): Promise { + await this.findOrFail(id); return this.repository.getStatistics(id); } - async getCount(auth: AuthDto): Promise { - return this.repository.getCountForUser(auth.user.id); - } - - async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); - + async get(id: string): Promise { const library = await this.findOrFail(id); return mapLibrary(library); } - async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise { + async getAll(dto: SearchLibraryDto): Promise { const libraries = await this.repository.getAll(false, dto.type); return libraries.map((library) => mapLibrary(library)); } @@ -257,7 +249,7 @@ export class LibraryService extends EventEmitter { return JobStatus.SUCCESS; } - async create(auth: AuthDto, dto: CreateLibraryDto): Promise { + async create(dto: CreateLibraryDto): Promise { switch (dto.type) { case LibraryType.EXTERNAL: { if (!dto.name) { @@ -282,14 +274,8 @@ export class LibraryService extends EventEmitter { } } - let ownerId = auth.user.id; - - if (dto.ownerId) { - ownerId = dto.ownerId; - } - const library = await this.repository.create({ - ownerId, + ownerId: dto.ownerId, name: dto.name, type: dto.type, importPaths: dto.importPaths ?? [], @@ -297,7 +283,7 @@ export class LibraryService extends EventEmitter { isVisible: dto.isVisible ?? true, }); - this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`); + this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`); if (dto.type === LibraryType.EXTERNAL) { await this.watch(library.id); @@ -364,29 +350,19 @@ export class LibraryService extends EventEmitter { return validation; } - public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - - const response = new ValidateLibraryResponseDto(); - - if (dto.importPaths) { - response.importPaths = await Promise.all( - dto.importPaths.map(async (importPath) => { - return await this.validateImportPath(importPath); - }), - ); - } - - return response; + async validate(id: string, dto: ValidateLibraryDto): Promise { + const importPaths = await Promise.all( + (dto.importPaths || []).map((importPath) => this.validateImportPath(importPath)), + ); + return { importPaths }; } - async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - + async update(id: string, dto: UpdateLibraryDto): Promise { + await this.findOrFail(id); const library = await this.repository.update({ id, ...dto }); if (dto.importPaths) { - const validation = await this.validate(auth, id, { importPaths: dto.importPaths }); + const validation = await this.validate(id, { importPaths: dto.importPaths }); if (validation.importPaths) { for (const path of validation.importPaths) { if (!path.isValid) { @@ -404,11 +380,9 @@ export class LibraryService extends EventEmitter { return mapLibrary(library); } - async delete(auth: AuthDto, id: string) { - await this.access.requirePermission(auth, Permission.LIBRARY_DELETE, id); - + async delete(id: string) { const library = await this.findOrFail(id); - const uploadCount = await this.repository.getUploadLibraryCount(auth.user.id); + const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId); if (library.type === LibraryType.UPLOAD && uploadCount <= 1) { throw new BadRequestException('Cannot delete the last upload library'); } @@ -565,11 +539,9 @@ export class LibraryService extends EventEmitter { return JobStatus.SUCCESS; } - async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) { - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - - const library = await this.repository.get(id); - if (!library || library.type !== LibraryType.EXTERNAL) { + async queueScan(id: string, dto: ScanLibraryDto) { + const library = await this.findOrFail(id); + if (library.type !== LibraryType.EXTERNAL) { throw new BadRequestException('Can only refresh external libraries'); } @@ -583,16 +555,9 @@ export class LibraryService extends EventEmitter { }); } - async queueRemoveOffline(auth: AuthDto, id: string) { + async queueRemoveOffline(id: string) { this.logger.verbose(`Removing offline files from library: ${id}`); - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - - await this.jobRepository.queue({ - name: JobName.LIBRARY_REMOVE_OFFLINE, - data: { - id, - }, - }); + await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); } async handleQueueAllScan(job: IBaseJob): Promise { diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 6aa70a2123..7924a29dd3 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -26,7 +26,6 @@ export interface IAccessRepository { library: { checkOwnerAccess(userId: string, libraryIds: Set): Promise>; - checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; timeline: { diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts index 801dc173d9..2b509645c3 100644 --- a/server/src/immich/controllers/library.controller.ts +++ b/server/src/immich/controllers/library.controller.ts @@ -1,5 +1,4 @@ import { - AuthDto, CreateLibraryDto as CreateDto, LibraryService, LibraryStatsResponseDto, @@ -12,7 +11,7 @@ import { } from '@app/domain'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AdminRoute, Auth, Authenticated } from '../app.guard'; +import { AdminRoute, Authenticated } from '../app.guard'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Library') @@ -23,55 +22,52 @@ export class LibraryController { constructor(private service: LibraryService) {} @Get() - getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise { - return this.service.getAll(auth, dto); + getAllLibraries(@Query() dto: SearchLibraryDto): Promise { + return this.service.getAll(dto); } @Post() - createLibrary(@Auth() auth: AuthDto, @Body() dto: CreateDto): Promise { - return this.service.create(auth, dto); + createLibrary(@Body() dto: CreateDto): Promise { + return this.service.create(dto); } @Put(':id') - updateLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise { - return this.service.update(auth, id, dto); + updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise { + return this.service.update(id, dto); } @Get(':id') - getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.get(auth, id); + getLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); } @Post(':id/validate') @HttpCode(200) - validate( - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - @Body() dto: ValidateLibraryDto, - ): Promise { - return this.service.validate(auth, id, dto); + // TODO: change endpoint to validate current settings instead + validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise { + return this.service.validate(id, dto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.delete(auth, id); + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); } @Get(':id/statistics') - getLibraryStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getStatistics(auth, id); + getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { + return this.service.getStatistics(id); } @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(auth, id, dto); + scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { + return this.service.queueScan(id, dto); } @Post(':id/removeOffline') @HttpCode(HttpStatus.NO_CONTENT) - removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(auth, id); + removeOfflineFiles(@Param() { id }: UUIDParamDto) { + return this.service.queueRemoveOffline(id); } } diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 25691846d3..ad650bf0e6 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -307,10 +307,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } class LibraryAccess implements ILibraryAccess { - constructor( - private libraryRepository: Repository, - private partnerRepository: Repository, - ) {} + constructor(private libraryRepository: Repository) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) @@ -329,22 +326,6 @@ class LibraryAccess implements ILibraryAccess { }) .then((libraries) => new Set(libraries.map((library) => library.id))); } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) - @ChunkedSet({ paramIndex: 1 }) - async checkPartnerAccess(userId: string, partnerIds: Set): Promise> { - if (partnerIds.size === 0) { - return new Set(); - } - - return this.partnerRepository - .createQueryBuilder('partner') - .select('partner.sharedById') - .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] }) - .andWhere('partner.sharedWithId = :userId', { userId }) - .getMany() - .then((partners) => new Set(partners.map((partner) => partner.sharedById))); - } } class TimelineAccess implements ITimelineAccess { @@ -457,7 +438,7 @@ export class AccessRepository implements IAccessRepository { this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.authDevice = new AuthDeviceAccess(tokenRepository); - this.library = new LibraryAccess(libraryRepository, partnerRepository); + this.library = new LibraryAccess(libraryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); this.timeline = new TimelineAccess(partnerRepository); diff --git a/server/src/infra/sql/access.repository.sql b/server/src/infra/sql/access.repository.sql index 638be9f90b..a0c4e19275 100644 --- a/server/src/infra/sql/access.repository.sql +++ b/server/src/infra/sql/access.repository.sql @@ -196,16 +196,6 @@ WHERE ) AND ("LibraryEntity"."deletedAt" IS NULL) --- AccessRepository.library.checkPartnerAccess -SELECT - "partner"."sharedById" AS "partner_sharedById", - "partner"."sharedWithId" AS "partner_sharedWithId" -FROM - "partners" "partner" -WHERE - "partner"."sharedById" IN ($1) - AND "partner"."sharedWithId" = $2 - -- AccessRepository.person.checkOwnerAccess SELECT "PersonEntity"."id" AS "PersonEntity_id" diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a1f09aa75c..e10dd7d9a7 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -42,7 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => library: { checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), - checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), }, timeline: { diff --git a/web/src/lib/components/forms/library-user-picker-form.svelte b/web/src/lib/components/forms/library-user-picker-form.svelte index cc1d31b884..294cf70766 100644 --- a/web/src/lib/components/forms/library-user-picker-form.svelte +++ b/web/src/lib/components/forms/library-user-picker-form.svelte @@ -20,7 +20,7 @@ const dispatch = createEventDispatcher<{ cancel: void; - submit: { ownerId: string | null }; + submit: { ownerId: string }; delete: void; }>(); diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index e6f4332ee7..1efc7f778f 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -28,7 +28,6 @@ removeOfflineFiles, scanLibrary, updateLibrary, - type CreateLibraryDto, type LibraryResponseDto, type LibraryStatsResponseDto, type UserResponseDto, @@ -117,14 +116,9 @@ } } - const handleCreate = async (ownerId: string | null) => { + const handleCreate = async (ownerId: string) => { try { - let createLibraryDto: CreateLibraryDto = { type: LibraryType.External }; - if (ownerId) { - createLibraryDto = { ...createLibraryDto, ownerId }; - } - - const createdLibrary = await createLibrary({ createLibraryDto }); + const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } }); notificationController.show({ message: `Created library: ${createdLibrary.name}`,