From 088d5addf23dab44cb82c76b83d7a9b9f1d89f93 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 31 Oct 2023 11:01:32 -0400 Subject: [PATCH 01/26] refactor(server): user core (#4733) --- server/src/domain/album/album.service.spec.ts | 4 +- server/src/domain/album/album.service.ts | 4 +- server/src/domain/library/library.service.ts | 2 +- .../domain/repositories/user.repository.ts | 6 +- .../storage-template.service.ts | 2 +- server/src/domain/user/user.core.ts | 29 -- server/src/domain/user/user.service.spec.ts | 315 ++++++------------ server/src/domain/user/user.service.ts | 69 ++-- .../src/infra/repositories/user.repository.ts | 33 +- 9 files changed, 160 insertions(+), 304 deletions(-) diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 25b616ad6..a93cb0ad1 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -174,7 +174,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: '123', }); - expect(userMock.get).toHaveBeenCalledWith('user-id'); + expect(userMock.get).toHaveBeenCalledWith('user-id', {}); }); it('should require valid userIds', async () => { @@ -185,7 +185,7 @@ describe(AlbumService.name, () => { sharedWithUserIds: ['user-3'], }), ).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith('user-3'); + expect(userMock.get).toHaveBeenCalledWith('user-3', {}); expect(albumMock.create).not.toHaveBeenCalled(); }); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index ecff9f49d..b8e789943 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -95,7 +95,7 @@ export class AlbumService { async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise { for (const userId of dto.sharedWithUserIds || []) { - const exists = await this.userRepository.get(userId); + const exists = await this.userRepository.get(userId, {}); if (!exists) { throw new BadRequestException('User not found'); } @@ -238,7 +238,7 @@ export class AlbumService { throw new BadRequestException('User already added'); } - const user = await this.userRepository.get(userId); + const user = await this.userRepository.get(userId, {}); if (!user) { throw new BadRequestException('User not found'); } diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index d226b22c7..4943fc200 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -343,7 +343,7 @@ export class LibraryService { return false; } - const user = await this.userRepository.get(library.ownerId); + const user = await this.userRepository.get(library.ownerId, {}); if (!user?.externalPath) { this.logger.warn('User has no external path set, cannot refresh library'); return false; diff --git a/server/src/domain/repositories/user.repository.ts b/server/src/domain/repositories/user.repository.ts index 3e546c05e..c4647d571 100644 --- a/server/src/domain/repositories/user.repository.ts +++ b/server/src/domain/repositories/user.repository.ts @@ -13,10 +13,14 @@ export interface UserStatsQueryResponse { usage: number; } +export interface UserFindOptions { + withDeleted?: boolean; +} + export const IUserRepository = 'IUserRepository'; export interface IUserRepository { - get(id: string, withDeleted?: boolean): Promise; + get(id: string, options: UserFindOptions): Promise; getAdmin(): Promise; hasAdmin(): Promise; getByEmail(email: string, withPassword?: boolean): Promise; diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index b14d21bcf..8e8bd81ea 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -75,7 +75,7 @@ export class StorageTemplateService { async handleMigrationSingle({ id }: IEntityJob) { const [asset] = await this.assetRepository.getByIds([id]); - const user = await this.userRepository.get(asset.ownerId); + const user = await this.userRepository.get(asset.ownerId, {}); const storageLabel = user?.storageLabel || null; const filename = asset.originalFileName || asset.id; await this.moveAsset(asset, { storageLabel, filename }); diff --git a/server/src/domain/user/user.core.ts b/server/src/domain/user/user.core.ts index a26247781..2d1d46838 100644 --- a/server/src/domain/user/user.core.ts +++ b/server/src/domain/user/user.core.ts @@ -122,33 +122,4 @@ export class UserCore { throw new InternalServerErrorException('Failed to register new user'); } } - - async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise { - if (!authUser.isAdmin) { - throw new ForbiddenException('Unauthorized'); - } - try { - return this.userRepository.restore(userToRestore); - } catch (e) { - Logger.error(e, 'Failed to restore deleted user'); - throw new InternalServerErrorException('Failed to restore deleted user'); - } - } - - async deleteUser(authUser: AuthUserDto, userToDelete: UserEntity): Promise { - if (!authUser.isAdmin) { - throw new ForbiddenException('Unauthorized'); - } - - if (userToDelete.isAdmin) { - throw new ForbiddenException('Cannot delete admin user'); - } - - try { - return this.userRepository.delete(userToDelete); - } catch (e) { - Logger.error(e, 'Failed to delete user'); - throw new InternalServerErrorException('Failed to delete user'); - } - } } diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index 180337dff..1f9918fec 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -6,6 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { + authStub, newAlbumRepositoryMock, newAssetRepositoryMock, newCryptoRepositoryMock, @@ -17,7 +18,6 @@ import { } from '@test'; import { when } from 'jest-when'; import { Readable } from 'stream'; -import { AuthUserDto } from '../auth'; import { JobName } from '../job'; import { IAlbumRepository, @@ -29,7 +29,7 @@ import { IUserRepository, } from '../repositories'; import { UpdateUserDto } from './dto/update-user.dto'; -import { UserResponseDto, mapUser } from './response-dto'; +import { mapUser } from './response-dto'; import { UserService } from './user.service'; const makeDeletedAt = (daysAgo: number) => { @@ -38,95 +38,6 @@ const makeDeletedAt = (daysAgo: number) => { return deletedAt; }; -const adminUserAuth: AuthUserDto = Object.freeze({ - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, -}); - -const immichUserAuth: AuthUserDto = Object.freeze({ - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, -}); - -const adminUser: UserEntity = Object.freeze({ - id: adminUserAuth.id, - email: 'admin@test.com', - password: 'admin_password', - firstName: 'admin_first_name', - lastName: 'admin_last_name', - isAdmin: true, - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - storageLabel: 'admin', - externalPath: null, - memoriesEnabled: true, -}); - -const immichUser: UserEntity = Object.freeze({ - id: immichUserAuth.id, - email: 'immich@test.com', - password: 'immich_password', - firstName: 'immich_first_name', - lastName: 'immich_last_name', - isAdmin: false, - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - storageLabel: null, - externalPath: null, - memoriesEnabled: true, -}); - -const updatedImmichUser = Object.freeze({ - id: immichUserAuth.id, - email: 'immich@test.com', - password: 'immich_password', - firstName: 'updated_immich_first_name', - lastName: 'updated_immich_last_name', - isAdmin: false, - oauthId: '', - shouldChangePassword: true, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - storageLabel: null, - externalPath: null, - memoriesEnabled: true, -}); - -const adminUserResponse = Object.freeze({ - id: adminUserAuth.id, - email: 'admin@test.com', - firstName: 'admin_first_name', - lastName: 'admin_last_name', - isAdmin: true, - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - storageLabel: 'admin', - externalPath: null, - memoriesEnabled: true, -}); - describe(UserService.name, () => { let sut: UserService; let userMock: jest.Mocked; @@ -149,119 +60,92 @@ describe(UserService.name, () => { sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock); - when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); - when(userMock.get).calledWith(immichUser.id).mockResolvedValue(immichUser); + when(userMock.get).calledWith(authStub.admin.id, {}).mockResolvedValue(userStub.admin); + when(userMock.get).calledWith(authStub.admin.id, { withDeleted: true }).mockResolvedValue(userStub.admin); + when(userMock.get).calledWith(authStub.user1.id, {}).mockResolvedValue(userStub.user1); + when(userMock.get).calledWith(authStub.user1.id, { withDeleted: true }).mockResolvedValue(userStub.user1); }); describe('getAll', () => { it('should get all users', async () => { - userMock.getList.mockResolvedValue([adminUser]); - - const response = await sut.getAll(adminUserAuth, false); - - expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); - expect(response).toEqual([ - { - id: adminUserAuth.id, - email: 'admin@test.com', - firstName: 'admin_first_name', - lastName: 'admin_last_name', - isAdmin: true, - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - storageLabel: 'admin', - externalPath: null, - memoriesEnabled: true, - }, + userMock.getList.mockResolvedValue([userStub.admin]); + await expect(sut.getAll(authStub.admin, false)).resolves.toEqual([ + expect.objectContaining({ + id: authStub.admin.id, + email: authStub.admin.email, + }), ]); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); }); }); describe('get', () => { it('should get a user by id', async () => { - userMock.get.mockResolvedValue(adminUser); - - const response = await sut.get(adminUser.id); - - expect(userMock.get).toHaveBeenCalledWith(adminUser.id, false); - expect(response).toEqual(adminUserResponse); + userMock.get.mockResolvedValue(userStub.admin); + await sut.get(authStub.admin.id); + expect(userMock.get).toHaveBeenCalledWith(authStub.admin.id, { withDeleted: false }); }); it('should throw an error if a user is not found', async () => { userMock.get.mockResolvedValue(null); - - await expect(sut.get(adminUser.id)).rejects.toBeInstanceOf(NotFoundException); - - expect(userMock.get).toHaveBeenCalledWith(adminUser.id, false); + await expect(sut.get(authStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); + expect(userMock.get).toHaveBeenCalledWith(authStub.admin.id, { withDeleted: false }); }); }); describe('getMe', () => { it("should get the auth user's info", async () => { - userMock.get.mockResolvedValue(adminUser); - - const response = await sut.getMe(adminUser); - - expect(userMock.get).toHaveBeenCalledWith(adminUser.id); - expect(response).toEqual(adminUserResponse); + userMock.get.mockResolvedValue(userStub.admin); + await sut.getMe(authStub.admin); + expect(userMock.get).toHaveBeenCalledWith(authStub.admin.id, {}); }); it('should throw an error if a user is not found', async () => { userMock.get.mockResolvedValue(null); - - await expect(sut.getMe(adminUser)).rejects.toBeInstanceOf(BadRequestException); - - expect(userMock.get).toHaveBeenCalledWith(adminUser.id); + await expect(sut.getMe(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); + expect(userMock.get).toHaveBeenCalledWith(authStub.admin.id, {}); }); }); describe('update', () => { it('should update user', async () => { const update: UpdateUserDto = { - id: immichUser.id, + id: userStub.user1.id, shouldChangePassword: true, email: 'immich@test.com', storageLabel: 'storage_label', }; userMock.getByEmail.mockResolvedValue(null); userMock.getByStorageLabel.mockResolvedValue(null); - userMock.update.mockResolvedValue({ ...updatedImmichUser, isAdmin: true, storageLabel: 'storage_label' }); + userMock.update.mockResolvedValue(userStub.user1); + + await sut.update({ ...authStub.user1, isAdmin: true }, update); - const updatedUser = await sut.update({ ...immichUserAuth, isAdmin: true }, update); - expect(updatedUser.shouldChangePassword).toEqual(true); expect(userMock.getByEmail).toHaveBeenCalledWith(update.email); expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel); }); it('should not set an empty string for storage label', async () => { - userMock.update.mockResolvedValue(updatedImmichUser); - - await sut.update(adminUserAuth, { id: immichUser.id, storageLabel: '' }); - - expect(userMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id, storageLabel: null }); + userMock.update.mockResolvedValue(userStub.user1); + await sut.update(userStub.admin, { id: userStub.user1.id, storageLabel: '' }); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: userStub.user1.id, storageLabel: null }); }); it('should omit a storage label set by non-admin users', async () => { - userMock.update.mockResolvedValue(updatedImmichUser); - - await sut.update(immichUserAuth, { id: immichUser.id, storageLabel: 'admin' }); - - expect(userMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id }); + userMock.update.mockResolvedValue(userStub.user1); + await sut.update(userStub.user1, { id: userStub.user1.id, storageLabel: 'admin' }); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: userStub.user1.id }); }); it('user can only update its information', async () => { when(userMock.get) - .calledWith('not_immich_auth_user_id') + .calledWith('not_immich_auth_user_id', {}) .mockResolvedValueOnce({ - ...immichUser, + ...userStub.user1, id: 'not_immich_auth_user_id', }); - const result = sut.update(immichUserAuth, { + const result = sut.update(userStub.user1, { id: 'not_immich_auth_user_id', password: 'I take over your account now', }); @@ -269,107 +153,104 @@ describe(UserService.name, () => { }); it('should let a user change their email', async () => { - const dto = { id: immichUser.id, email: 'updated@test.com' }; + const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - userMock.get.mockResolvedValue(immichUser); - userMock.update.mockResolvedValue(immichUser); + userMock.get.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); - await sut.update(immichUser, dto); + await sut.update(userStub.user1, dto); - expect(userMock.update).toHaveBeenCalledWith(immichUser.id, { + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: 'user-id', email: 'updated@test.com', }); }); it('should not let a user change their email to one already in use', async () => { - const dto = { id: immichUser.id, email: 'updated@test.com' }; + const dto = { id: userStub.user1.id, email: 'updated@test.com' }; - userMock.get.mockResolvedValue(immichUser); - userMock.getByEmail.mockResolvedValue(adminUser); + userMock.get.mockResolvedValue(userStub.user1); + userMock.getByEmail.mockResolvedValue(userStub.admin); - await expect(sut.update(immichUser, dto)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(userStub.user1, dto)).rejects.toBeInstanceOf(BadRequestException); expect(userMock.update).not.toHaveBeenCalled(); }); it('should not let the admin change the storage label to one already in use', async () => { - const dto = { id: immichUser.id, storageLabel: 'admin' }; + const dto = { id: userStub.user1.id, storageLabel: 'admin' }; - userMock.get.mockResolvedValue(immichUser); - userMock.getByStorageLabel.mockResolvedValue(adminUser); + userMock.get.mockResolvedValue(userStub.user1); + userMock.getByStorageLabel.mockResolvedValue(userStub.admin); - await expect(sut.update(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(userStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException); expect(userMock.update).not.toHaveBeenCalled(); }); it('admin can update any user information', async () => { const update: UpdateUserDto = { - id: immichUser.id, + id: userStub.user1.id, shouldChangePassword: true, }; - when(userMock.update).calledWith(immichUser.id, update).mockResolvedValueOnce(updatedImmichUser); - - const result = await sut.update(adminUserAuth, update); - - expect(result).toBeDefined(); - expect(result.id).toEqual(updatedImmichUser.id); - expect(result.shouldChangePassword).toEqual(updatedImmichUser.shouldChangePassword); + when(userMock.update).calledWith(userStub.user1.id, update).mockResolvedValueOnce(userStub.user1); + await sut.update(userStub.admin, update); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + id: 'user-id', + shouldChangePassword: true, + }); }); it('update user information should throw error if user not found', async () => { - when(userMock.get).calledWith(immichUser.id).mockResolvedValueOnce(null); + when(userMock.get).calledWith(userStub.user1.id, {}).mockResolvedValueOnce(null); - const result = sut.update(adminUser, { - id: immichUser.id, + const result = sut.update(userStub.admin, { + id: userStub.user1.id, shouldChangePassword: true, }); - await expect(result).rejects.toBeInstanceOf(NotFoundException); + await expect(result).rejects.toBeInstanceOf(BadRequestException); }); it('should let the admin update himself', async () => { - const dto = { id: adminUser.id, shouldChangePassword: true, isAdmin: true }; + const dto = { id: userStub.admin.id, shouldChangePassword: true, isAdmin: true }; - when(userMock.update).calledWith(adminUser.id, dto).mockResolvedValueOnce(adminUser); + when(userMock.update).calledWith(userStub.admin.id, dto).mockResolvedValueOnce(userStub.admin); - await sut.update(adminUser, dto); + await sut.update(userStub.admin, dto); - expect(userMock.update).toHaveBeenCalledWith(adminUser.id, dto); + expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, dto); }); it('should not let the another user become an admin', async () => { - const dto = { id: immichUser.id, shouldChangePassword: true, isAdmin: true }; + const dto = { id: userStub.user1.id, shouldChangePassword: true, isAdmin: true }; - when(userMock.get).calledWith(immichUser.id).mockResolvedValueOnce(immichUser); + when(userMock.get).calledWith(userStub.user1.id, {}).mockResolvedValueOnce(userStub.user1); - await expect(sut.update(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.update(userStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException); }); }); describe('restore', () => { it('should throw error if user could not be found', async () => { - userMock.get.mockResolvedValue(null); - - await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException); + when(userMock.get).calledWith(userStub.admin.id, { withDeleted: true }).mockResolvedValue(null); + await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); expect(userMock.restore).not.toHaveBeenCalled(); }); it('should require an admin', async () => { - when(userMock.get).calledWith(adminUser.id, true).mockResolvedValue(adminUser); - await expect(sut.restore(immichUserAuth, adminUser.id)).rejects.toBeInstanceOf(ForbiddenException); - expect(userMock.get).toHaveBeenCalledWith(adminUser.id, true); + when(userMock.get).calledWith(userStub.admin.id, { withDeleted: true }).mockResolvedValue(userStub.admin); + await expect(sut.restore(authStub.user1, userStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException); }); it('should restore an user', async () => { - userMock.get.mockResolvedValue(immichUser); - userMock.restore.mockResolvedValue(immichUser); + userMock.get.mockResolvedValue(userStub.user1); + userMock.restore.mockResolvedValue(userStub.user1); - await expect(sut.restore(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser)); - expect(userMock.get).toHaveBeenCalledWith(immichUser.id, true); - expect(userMock.restore).toHaveBeenCalledWith(immichUser); + await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); + expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: true }); + expect(userMock.restore).toHaveBeenCalledWith(userStub.user1); }); }); @@ -377,27 +258,27 @@ describe(UserService.name, () => { it('should throw error if user could not be found', async () => { userMock.get.mockResolvedValue(null); - await expect(sut.delete(immichUserAuth, adminUser.id)).rejects.toThrowError(BadRequestException); + await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); expect(userMock.delete).not.toHaveBeenCalled(); }); it('cannot delete admin user', async () => { - await expect(sut.delete(adminUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException); }); it('should require the auth user be an admin', async () => { - await expect(sut.delete(immichUserAuth, adminUserAuth.id)).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.delete(authStub.user1, authStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException); expect(userMock.delete).not.toHaveBeenCalled(); }); it('should delete user', async () => { - userMock.get.mockResolvedValue(immichUser); - userMock.delete.mockResolvedValue(immichUser); + userMock.get.mockResolvedValue(userStub.user1); + userMock.delete.mockResolvedValue(userStub.user1); - await expect(sut.delete(adminUserAuth, immichUser.id)).resolves.toEqual(mapUser(immichUser)); - expect(userMock.get).toHaveBeenCalledWith(immichUser.id); - expect(userMock.delete).toHaveBeenCalledWith(immichUser); + await expect(sut.delete(userStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); + expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, {}); + expect(userMock.delete).toHaveBeenCalledWith(userStub.user1); }); }); @@ -443,18 +324,18 @@ describe(UserService.name, () => { describe('createProfileImage', () => { it('should throw an error if the user does not exist', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - userMock.update.mockResolvedValue({ ...adminUser, profileImagePath: file.path }); + userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); - await sut.createProfileImage(adminUserAuth, file); + await sut.createProfileImage(userStub.admin, file); - expect(userMock.update).toHaveBeenCalledWith(adminUserAuth.id, { profileImagePath: file.path }); + expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { profileImagePath: file.path }); }); it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; userMock.update.mockRejectedValue(new InternalServerErrorException('mocked error')); - await expect(sut.createProfileImage(adminUserAuth, file)).rejects.toThrowError(InternalServerErrorException); + await expect(sut.createProfileImage(userStub.admin, file)).rejects.toThrowError(InternalServerErrorException); }); }); @@ -462,17 +343,17 @@ describe(UserService.name, () => { it('should throw an error if the user does not exist', async () => { userMock.get.mockResolvedValue(null); - await expect(sut.getProfileImage(adminUserAuth.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); - expect(userMock.get).toHaveBeenCalledWith(adminUserAuth.id); + expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should throw an error if the user does not have a picture', async () => { - userMock.get.mockResolvedValue(adminUser); + userMock.get.mockResolvedValue(userStub.admin); - await expect(sut.getProfileImage(adminUserAuth.id)).rejects.toBeInstanceOf(NotFoundException); + await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); - expect(userMock.get).toHaveBeenCalledWith(adminUserAuth.id); + expect(userMock.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); it('should return the profile picture', async () => { @@ -483,7 +364,7 @@ describe(UserService.name, () => { await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual({ stream }); - expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id); + expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/profile.jpg', 'image/jpeg'); }); }); @@ -499,7 +380,7 @@ describe(UserService.name, () => { }); it('should default to a random password', async () => { - userMock.getAdmin.mockResolvedValue(adminUser); + userMock.getAdmin.mockResolvedValue(userStub.admin); const ask = jest.fn().mockResolvedValue(undefined); const response = await sut.resetAdminPassword(ask); @@ -508,12 +389,12 @@ describe(UserService.name, () => { expect(response.provided).toBe(false); expect(ask).toHaveBeenCalled(); - expect(id).toEqual(adminUser.id); + expect(id).toEqual(userStub.admin.id); expect(update.password).toBeDefined(); }); it('should use the supplied password', async () => { - userMock.getAdmin.mockResolvedValue(adminUser); + userMock.getAdmin.mockResolvedValue(userStub.admin); const ask = jest.fn().mockResolvedValue('new-password'); const response = await sut.resetAdminPassword(ask); @@ -522,7 +403,7 @@ describe(UserService.name, () => { expect(response.provided).toBe(true); expect(ask).toHaveBeenCalled(); - expect(id).toEqual(adminUser.id); + expect(id).toEqual(userStub.admin.id); expect(update.password).toBeDefined(); }); }); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 3f36f2bec..a155d401d 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -1,5 +1,5 @@ import { UserEntity } from '@app/infra/entities'; -import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { AuthUserDto } from '../auth'; import { IEntityJob, JobName } from '../job'; @@ -12,6 +12,7 @@ import { IStorageRepository, IUserRepository, ImmichReadStream, + UserFindOptions, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { CreateUserDto, UpdateUserDto } from './dto'; @@ -41,7 +42,7 @@ export class UserService { } async get(userId: string): Promise { - const user = await this.userRepository.get(userId, false); + const user = await this.userRepository.get(userId, { withDeleted: false }); if (!user) { throw new NotFoundException('User not found'); } @@ -49,47 +50,43 @@ export class UserService { return mapUser(user); } - async getMe(authUser: AuthUserDto): Promise { - const user = await this.userRepository.get(authUser.id); - if (!user) { - throw new BadRequestException('User not found'); - } - return mapUser(user); + getMe(authUser: AuthUserDto): Promise { + return this.findOrFail(authUser.id, {}).then(mapUser); } - async create(createUserDto: CreateUserDto): Promise { - const createdUser = await this.userCore.createUser(createUserDto); - return mapUser(createdUser); + create(createUserDto: CreateUserDto): Promise { + return this.userCore.createUser(createUserDto).then(mapUser); } async update(authUser: AuthUserDto, dto: UpdateUserDto): Promise { - const user = await this.userRepository.get(dto.id); - if (!user) { - throw new NotFoundException('User not found'); - } - - const updatedUser = await this.userCore.updateUser(authUser, dto.id, dto); - return mapUser(updatedUser); + await this.findOrFail(dto.id, {}); + return this.userCore.updateUser(authUser, dto.id, dto).then(mapUser); } - async delete(authUser: AuthUserDto, userId: string): Promise { - const user = await this.userRepository.get(userId); - if (!user) { - throw new BadRequestException('User not found'); + async delete(authUser: AuthUserDto, id: string): Promise { + if (!authUser.isAdmin) { + throw new ForbiddenException('Unauthorized'); } - await this.albumRepository.softDeleteAll(userId); - const deletedUser = await this.userCore.deleteUser(authUser, user); - return mapUser(deletedUser); + + const user = await this.findOrFail(id, {}); + if (user.isAdmin) { + throw new ForbiddenException('Cannot delete admin user'); + } + + await this.albumRepository.softDeleteAll(id); + + return this.userRepository.delete(user).then(mapUser); } - async restore(authUser: AuthUserDto, userId: string): Promise { - const user = await this.userRepository.get(userId, true); - if (!user) { - throw new BadRequestException('User not found'); + async restore(authUser: AuthUserDto, id: string): Promise { + if (!authUser.isAdmin) { + throw new ForbiddenException('Unauthorized'); } - const updatedUser = await this.userCore.restoreUser(authUser, user); - await this.albumRepository.restoreAll(userId); - return mapUser(updatedUser); + + let user = await this.findOrFail(id, { withDeleted: true }); + user = await this.userRepository.restore(user); + await this.albumRepository.restoreAll(id); + return mapUser(user); } async createProfileImage( @@ -101,7 +98,7 @@ export class UserService { } async getProfileImage(id: string): Promise { - const user = await this.findOrFail(id); + const user = await this.findOrFail(id, {}); if (!user.profileImagePath) { throw new NotFoundException('User does not have a profile image'); } @@ -134,7 +131,7 @@ export class UserService { } async handleUserDelete({ id }: IEntityJob) { - const user = await this.userRepository.get(id, true); + const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return false; } @@ -181,8 +178,8 @@ export class UserService { return msSinceDelete >= msDeleteWait; } - private async findOrFail(id: string) { - const user = await this.userRepository.get(id); + private async findOrFail(id: string, options: UserFindOptions) { + const user = await this.userRepository.get(id, options); if (!user) { throw new BadRequestException('User not found'); } diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts index 559f16aa2..9809ec75d 100644 --- a/server/src/infra/repositories/user.repository.ts +++ b/server/src/infra/repositories/user.repository.ts @@ -1,5 +1,5 @@ -import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain'; -import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { IUserRepository, UserFindOptions, UserListFilter, UserStatsQueryResponse } from '@app/domain'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Not, Repository } from 'typeorm'; import { UserEntity } from '../entities'; @@ -8,8 +8,12 @@ import { UserEntity } from '../entities'; export class UserRepository implements IUserRepository { constructor(@InjectRepository(UserEntity) private userRepository: Repository) {} - async get(userId: string, withDeleted?: boolean): Promise { - return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted }); + async get(userId: string, options: UserFindOptions): Promise { + options = options || {}; + return this.userRepository.findOne({ + where: { id: userId }, + withDeleted: options.withDeleted, + }); } async getAdmin(): Promise { @@ -51,19 +55,13 @@ export class UserRepository implements IUserRepository { }); } - async create(user: Partial): Promise { - return this.userRepository.save(user); + create(user: Partial): Promise { + return this.save(user); } - async update(id: string, user: Partial): Promise { - user.id = id; - - await this.userRepository.save(user); - const updatedUser = await this.get(id); - if (!updatedUser) { - throw new InternalServerErrorException('Cannot reload user after update'); - } - return updatedUser; + // TODO change to (user: Partial) + update(id: string, user: Partial): Promise { + return this.save({ ...user, id }); } async delete(user: UserEntity, hard?: boolean): Promise { @@ -101,4 +99,9 @@ export class UserRepository implements IUserRepository { return stats; } + + private async save(user: Partial) { + const { id } = await this.userRepository.save(user); + return this.userRepository.findOneByOrFail({ id }); + } } From cd375a976e14c059225f2e8c0d69cac6182a5fff Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 31 Oct 2023 21:19:12 +0100 Subject: [PATCH 02/26] feat(server): custom library scanning interval (#4390) * add automatic library scan config options * add validation * open api * use CronJob instead of cron-validator * fix tests * catch potential error of the library scan initialization * better description for input field * move library scan job initialization to server app service * fix tests * add comments to all parameters of cronjob contructor * make scan a child of a more general library object * open api * chore: cleanup * move cronjob handling to job repoistory * web: select for common cron expressions * fix open api * fix tests * put scanning settings in nested accordion * fix system config validation * refactor, tests --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 38 +++++ mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | 2 + mobile/openapi/doc/SystemConfigDto.md | 1 + mobile/openapi/doc/SystemConfigLibraryDto.md | 15 ++ .../openapi/doc/SystemConfigLibraryScanDto.md | 16 ++ mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../openapi/lib/model/system_config_dto.dart | 10 +- .../lib/model/system_config_library_dto.dart | 98 ++++++++++++ .../model/system_config_library_scan_dto.dart | 106 +++++++++++++ .../openapi/test/system_config_dto_test.dart | 5 + .../test/system_config_library_dto_test.dart | 27 ++++ .../system_config_library_scan_dto_test.dart | 32 ++++ server/immich-openapi-specs.json | 32 +++- server/package-lock.json | 110 +------------ server/src/domain/domain.util.ts | 11 ++ server/src/domain/job/job.service.spec.ts | 1 - server/src/domain/job/job.service.ts | 1 - .../domain/library/library.service.spec.ts | 42 ++++- server/src/domain/library/library.service.ts | 26 +++- .../src/domain/repositories/job.repository.ts | 3 + server/src/domain/system-config/dto/index.ts | 1 + .../dto/system-config-library.dto.ts | 40 +++++ .../system-config/dto/system-config.dto.ts | 6 + .../system-config/system-config.core.ts | 7 + .../system-config.service.spec.ts | 6 + server/src/immich/app.service.ts | 4 +- .../infra/entities/system-config.entity.ts | 9 ++ .../src/infra/repositories/job.repository.ts | 44 +++++- .../test/repositories/job.repository.mock.ts | 3 + server/test/test-utils.ts | 4 + web/src/api/open-api/api.ts | 38 +++++ .../library-settings/library-settings.svelte | 145 ++++++++++++++++++ .../routes/admin/system-settings/+page.svelte | 5 + 35 files changed, 786 insertions(+), 114 deletions(-) create mode 100644 mobile/openapi/doc/SystemConfigLibraryDto.md create mode 100644 mobile/openapi/doc/SystemConfigLibraryScanDto.md create mode 100644 mobile/openapi/lib/model/system_config_library_dto.dart create mode 100644 mobile/openapi/lib/model/system_config_library_scan_dto.dart create mode 100644 mobile/openapi/test/system_config_library_dto_test.dart create mode 100644 mobile/openapi/test/system_config_library_scan_dto_test.dart create mode 100644 server/src/domain/system-config/dto/system-config-library.dto.ts create mode 100644 web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index e79388602..97dc8523c 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -3283,6 +3283,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'job': SystemConfigJobDto; + /** + * + * @type {SystemConfigLibraryDto} + * @memberof SystemConfigDto + */ + 'library': SystemConfigLibraryDto; /** * * @type {SystemConfigMachineLearningDto} @@ -3534,6 +3540,38 @@ export interface SystemConfigJobDto { */ 'videoConversion': JobSettingsDto; } +/** + * + * @export + * @interface SystemConfigLibraryDto + */ +export interface SystemConfigLibraryDto { + /** + * + * @type {SystemConfigLibraryScanDto} + * @memberof SystemConfigLibraryDto + */ + 'scan': SystemConfigLibraryScanDto; +} +/** + * + * @export + * @interface SystemConfigLibraryScanDto + */ +export interface SystemConfigLibraryScanDto { + /** + * + * @type {string} + * @memberof SystemConfigLibraryScanDto + */ + 'cronExpression': string; + /** + * + * @type {boolean} + * @memberof SystemConfigLibraryScanDto + */ + 'enabled': boolean; +} /** * * @export diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 6350677f1..c73dcfd06 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -128,6 +128,8 @@ doc/SystemConfigApi.md doc/SystemConfigDto.md doc/SystemConfigFFmpegDto.md doc/SystemConfigJobDto.md +doc/SystemConfigLibraryDto.md +doc/SystemConfigLibraryScanDto.md doc/SystemConfigMachineLearningDto.md doc/SystemConfigMapDto.md doc/SystemConfigNewVersionCheckDto.md @@ -296,6 +298,8 @@ lib/model/smart_info_response_dto.dart lib/model/system_config_dto.dart lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_job_dto.dart +lib/model/system_config_library_dto.dart +lib/model/system_config_library_scan_dto.dart lib/model/system_config_machine_learning_dto.dart lib/model/system_config_map_dto.dart lib/model/system_config_new_version_check_dto.dart @@ -451,6 +455,8 @@ test/system_config_api_test.dart test/system_config_dto_test.dart test/system_config_f_fmpeg_dto_test.dart test/system_config_job_dto_test.dart +test/system_config_library_dto_test.dart +test/system_config_library_scan_dto_test.dart test/system_config_machine_learning_dto_test.dart test/system_config_map_dto_test.dart test/system_config_new_version_check_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6ec603962..9e5462b08 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -311,6 +311,8 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) + - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) + - [SystemConfigLibraryScanDto](doc//SystemConfigLibraryScanDto.md) - [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md) - [SystemConfigMapDto](doc//SystemConfigMapDto.md) - [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md) diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index 98a626640..73c5b70dc 100644 --- a/mobile/openapi/doc/SystemConfigDto.md +++ b/mobile/openapi/doc/SystemConfigDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | | **job** | [**SystemConfigJobDto**](SystemConfigJobDto.md) | | +**library_** | [**SystemConfigLibraryDto**](SystemConfigLibraryDto.md) | | **machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | | **map** | [**SystemConfigMapDto**](SystemConfigMapDto.md) | | **newVersionCheck** | [**SystemConfigNewVersionCheckDto**](SystemConfigNewVersionCheckDto.md) | | diff --git a/mobile/openapi/doc/SystemConfigLibraryDto.md b/mobile/openapi/doc/SystemConfigLibraryDto.md new file mode 100644 index 000000000..22c8ddf34 --- /dev/null +++ b/mobile/openapi/doc/SystemConfigLibraryDto.md @@ -0,0 +1,15 @@ +# openapi.model.SystemConfigLibraryDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**scan** | [**SystemConfigLibraryScanDto**](SystemConfigLibraryScanDto.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SystemConfigLibraryScanDto.md b/mobile/openapi/doc/SystemConfigLibraryScanDto.md new file mode 100644 index 000000000..d77bb03ce --- /dev/null +++ b/mobile/openapi/doc/SystemConfigLibraryScanDto.md @@ -0,0 +1,16 @@ +# openapi.model.SystemConfigLibraryScanDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**cronExpression** | **String** | | +**enabled** | **bool** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7a621b7f4..d72aafe58 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -156,6 +156,8 @@ part 'model/smart_info_response_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_job_dto.dart'; +part 'model/system_config_library_dto.dart'; +part 'model/system_config_library_scan_dto.dart'; part 'model/system_config_machine_learning_dto.dart'; part 'model/system_config_map_dto.dart'; part 'model/system_config_new_version_check_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index fa16b0d60..a61a6b4a9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -403,6 +403,10 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigJobDto': return SystemConfigJobDto.fromJson(value); + case 'SystemConfigLibraryDto': + return SystemConfigLibraryDto.fromJson(value); + case 'SystemConfigLibraryScanDto': + return SystemConfigLibraryScanDto.fromJson(value); case 'SystemConfigMachineLearningDto': return SystemConfigMachineLearningDto.fromJson(value); case 'SystemConfigMapDto': diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 89c7e5f7d..c8407c2ce 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -15,6 +15,7 @@ class SystemConfigDto { SystemConfigDto({ required this.ffmpeg, required this.job, + required this.library_, required this.machineLearning, required this.map, required this.newVersionCheck, @@ -31,6 +32,8 @@ class SystemConfigDto { SystemConfigJobDto job; + SystemConfigLibraryDto library_; + SystemConfigMachineLearningDto machineLearning; SystemConfigMapDto map; @@ -55,6 +58,7 @@ class SystemConfigDto { bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && other.ffmpeg == ffmpeg && other.job == job && + other.library_ == library_ && other.machineLearning == machineLearning && other.map == map && other.newVersionCheck == newVersionCheck && @@ -71,6 +75,7 @@ class SystemConfigDto { // ignore: unnecessary_parenthesis (ffmpeg.hashCode) + (job.hashCode) + + (library_.hashCode) + (machineLearning.hashCode) + (map.hashCode) + (newVersionCheck.hashCode) + @@ -83,12 +88,13 @@ class SystemConfigDto { (trash.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]'; Map toJson() { final json = {}; json[r'ffmpeg'] = this.ffmpeg; json[r'job'] = this.job; + json[r'library'] = this.library_; json[r'machineLearning'] = this.machineLearning; json[r'map'] = this.map; json[r'newVersionCheck'] = this.newVersionCheck; @@ -112,6 +118,7 @@ class SystemConfigDto { return SystemConfigDto( ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!, job: SystemConfigJobDto.fromJson(json[r'job'])!, + library_: SystemConfigLibraryDto.fromJson(json[r'library'])!, machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!, map: SystemConfigMapDto.fromJson(json[r'map'])!, newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!, @@ -171,6 +178,7 @@ class SystemConfigDto { static const requiredKeys = { 'ffmpeg', 'job', + 'library', 'machineLearning', 'map', 'newVersionCheck', diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart new file mode 100644 index 000000000..0dccb0a32 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigLibraryDto { + /// Returns a new [SystemConfigLibraryDto] instance. + SystemConfigLibraryDto({ + required this.scan, + }); + + SystemConfigLibraryScanDto scan; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigLibraryDto && + other.scan == scan; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (scan.hashCode); + + @override + String toString() => 'SystemConfigLibraryDto[scan=$scan]'; + + Map toJson() { + final json = {}; + json[r'scan'] = this.scan; + return json; + } + + /// Returns a new [SystemConfigLibraryDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigLibraryDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigLibraryDto( + scan: SystemConfigLibraryScanDto.fromJson(json[r'scan'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigLibraryDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigLibraryDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigLibraryDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigLibraryDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'scan', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_library_scan_dto.dart b/mobile/openapi/lib/model/system_config_library_scan_dto.dart new file mode 100644 index 000000000..1de6e4d14 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigLibraryScanDto { + /// Returns a new [SystemConfigLibraryScanDto] instance. + SystemConfigLibraryScanDto({ + required this.cronExpression, + required this.enabled, + }); + + String cronExpression; + + bool enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigLibraryScanDto && + other.cronExpression == cronExpression && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (cronExpression.hashCode) + + (enabled.hashCode); + + @override + String toString() => 'SystemConfigLibraryScanDto[cronExpression=$cronExpression, enabled=$enabled]'; + + Map toJson() { + final json = {}; + json[r'cronExpression'] = this.cronExpression; + json[r'enabled'] = this.enabled; + return json; + } + + /// Returns a new [SystemConfigLibraryScanDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigLibraryScanDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigLibraryScanDto( + cronExpression: mapValueOfType(json, r'cronExpression')!, + enabled: mapValueOfType(json, r'enabled')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigLibraryScanDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigLibraryScanDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigLibraryScanDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigLibraryScanDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'cronExpression', + 'enabled', + }; +} + diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 75e604539..c8b5c0d9c 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // SystemConfigLibraryDto library_ + test('to test the property `library_`', () async { + // TODO + }); + // SystemConfigMachineLearningDto machineLearning test('to test the property `machineLearning`', () async { // TODO diff --git a/mobile/openapi/test/system_config_library_dto_test.dart b/mobile/openapi/test/system_config_library_dto_test.dart new file mode 100644 index 000000000..f7051c82e --- /dev/null +++ b/mobile/openapi/test/system_config_library_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SystemConfigLibraryDto +void main() { + // final instance = SystemConfigLibraryDto(); + + group('test SystemConfigLibraryDto', () { + // SystemConfigLibraryScanDto scan + test('to test the property `scan`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/system_config_library_scan_dto_test.dart b/mobile/openapi/test/system_config_library_scan_dto_test.dart new file mode 100644 index 000000000..574013e75 --- /dev/null +++ b/mobile/openapi/test/system_config_library_scan_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SystemConfigLibraryScanDto +void main() { + // final instance = SystemConfigLibraryScanDto(); + + group('test SystemConfigLibraryScanDto', () { + // String cronExpression + test('to test the property `cronExpression`', () async { + // TODO + }); + + // bool enabled + test('to test the property `enabled`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 49567f7f6..6f8d639e9 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -8061,6 +8061,9 @@ "job": { "$ref": "#/components/schemas/SystemConfigJobDto" }, + "library": { + "$ref": "#/components/schemas/SystemConfigLibraryDto" + }, "machineLearning": { "$ref": "#/components/schemas/SystemConfigMachineLearningDto" }, @@ -8104,7 +8107,8 @@ "job", "thumbnail", "trash", - "theme" + "theme", + "library" ], "type": "object" }, @@ -8238,6 +8242,32 @@ ], "type": "object" }, + "SystemConfigLibraryDto": { + "properties": { + "scan": { + "$ref": "#/components/schemas/SystemConfigLibraryScanDto" + } + }, + "required": [ + "scan" + ], + "type": "object" + }, + "SystemConfigLibraryScanDto": { + "properties": { + "cronExpression": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "cronExpression" + ], + "type": "object" + }, "SystemConfigMachineLearningDto": { "properties": { "classification": { diff --git a/server/package-lock.json b/server/package-lock.json index 0842da092..7cebecaf8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1683,66 +1683,6 @@ "darwin" ] }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz", - "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz", - "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz", - "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz", - "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz", - "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@nestjs/bull-shared": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.0.1.tgz", @@ -6118,15 +6058,6 @@ "exiftool-vendored.pl": "12.67.0" } }, - "node_modules/exiftool-vendored.exe": { - "version": "12.67.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.67.0.tgz", - "integrity": "sha512-wzgMDoL/VWH34l38g22cVUn43mVFtTSVj0HRjfjR46+4fGwpSvSueeYbwLCZ5NvBAVINCS5Rz9Rl2DVmqoIjsw==", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/exiftool-vendored.pl": { "version": "12.67.0", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.67.0.tgz", @@ -14300,36 +14231,6 @@ "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==", "optional": true }, - "@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz", - "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==", - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz", - "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==", - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz", - "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==", - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz", - "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==", - "optional": true - }, - "@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz", - "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==", - "optional": true - }, "@nestjs/bull-shared": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.0.1.tgz", @@ -16944,6 +16845,11 @@ "luxon": "^3.2.1" } }, + "cron-validator": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz", + "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==" + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -17608,12 +17514,6 @@ } } }, - "exiftool-vendored.exe": { - "version": "12.67.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.67.0.tgz", - "integrity": "sha512-wzgMDoL/VWH34l38g22cVUn43mVFtTSVj0HRjfjR46+4fGwpSvSueeYbwLCZ5NvBAVINCS5Rz9Rl2DVmqoIjsw==", - "optional": true - }, "exiftool-vendored.pl": { "version": "12.67.0", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.67.0.tgz", diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 9b7ee7521..04ec4f430 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -1,6 +1,7 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateIf, ValidationOptions } from 'class-validator'; +import { CronJob } from 'cron'; import { basename, extname } from 'node:path'; import sanitize from 'sanitize-filename'; @@ -18,6 +19,16 @@ export function ValidateUUID({ optional, each }: Options = { optional: false, ea ); } +export function validateCronExpression(expression: string) { + try { + new CronJob(expression, () => {}); + } catch (error) { + return false; + } + + return true; +} + interface IValue { value?: string; } diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index dac22a3ec..fa909d1ae 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -61,7 +61,6 @@ describe(JobService.name, () => { [{ name: JobName.PERSON_CLEANUP }], [{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }], [{ name: JobName.CLEAN_OLD_AUDIT_LOGS }], - [{ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }], ]); }); }); diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 7b65467af..7ebffcc69 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -153,7 +153,6 @@ export class JobService { await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); await this.jobRepository.queue({ name: JobName.CLEAN_OLD_AUDIT_LOGS }); - await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }); } /** diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index b13675a35..3d7d68736 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetType, LibraryType, UserEntity } from '@app/infra/entities'; +import { AssetType, LibraryType, SystemConfig, SystemConfigKey, UserEntity } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { @@ -12,6 +12,7 @@ import { newJobRepositoryMock, newLibraryRepositoryMock, newStorageRepositoryMock, + newSystemConfigRepositoryMock, newUserRepositoryMock, userStub, } from '@test'; @@ -23,8 +24,10 @@ import { IJobRepository, ILibraryRepository, IStorageRepository, + ISystemConfigRepository, IUserRepository, } from '../repositories'; +import { SystemConfigCore } from '../system-config/system-config.core'; import { LibraryService } from './library.service'; describe(LibraryService.name, () => { @@ -32,6 +35,7 @@ describe(LibraryService.name, () => { let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; + let configMock: jest.Mocked; let cryptoMock: jest.Mocked; let userMock: jest.Mocked; let jobMock: jest.Mocked; @@ -40,6 +44,7 @@ describe(LibraryService.name, () => { beforeEach(() => { accessMock = newAccessRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); libraryMock = newLibraryRepositoryMock(); userMock = newUserRepositoryMock(); assetMock = newAssetRepositoryMock(); @@ -55,13 +60,46 @@ describe(LibraryService.name, () => { accessMock.library.hasOwnerAccess.mockResolvedValue(true); - sut = new LibraryService(accessMock, assetMock, cryptoMock, jobMock, libraryMock, storageMock, userMock); + sut = new LibraryService( + accessMock, + assetMock, + configMock, + cryptoMock, + jobMock, + libraryMock, + storageMock, + userMock, + ); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('init', () => { + it('should init cron job and subscribe to config changes', async () => { + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.LIBRARY_SCAN_ENABLED, value: true }, + { key: SystemConfigKey.LIBRARY_SCAN_CRON_EXPRESSION, value: '0 0 * * *' }, + ]); + + await sut.init(); + expect(configMock.load).toHaveBeenCalled(); + expect(jobMock.addCronJob).toHaveBeenCalled(); + + SystemConfigCore.create(newSystemConfigRepositoryMock(false)).config$.next({ + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + }, + } as SystemConfig); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); + }); + }); + describe('handleQueueAssetRefresh', () => { it("should not queue assets outside of user's external path", async () => { const mockLibraryJob: ILibraryRefreshJob = { diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 4943fc200..6bec17c6b 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -7,7 +7,7 @@ import { basename, parse } from 'path'; import { AccessCore, Permission } from '../access'; import { AuthUserDto } from '../auth'; import { mimeTypes } from '../domain.constant'; -import { usePagination } from '../domain.util'; +import { usePagination, validateCronExpression } from '../domain.util'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { @@ -17,9 +17,11 @@ import { IJobRepository, ILibraryRepository, IStorageRepository, + ISystemConfigRepository, IUserRepository, WithProperty, } from '../repositories'; +import { SystemConfigCore } from '../system-config'; import { CreateLibraryDto, LibraryResponseDto, @@ -33,10 +35,12 @@ import { export class LibraryService { readonly logger = new Logger(LibraryService.name); private access: AccessCore; + private configCore: SystemConfigCore; constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) private repository: ILibraryRepository, @@ -44,6 +48,26 @@ export class LibraryService { @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.access = AccessCore.create(accessRepository); + this.configCore = SystemConfigCore.create(configRepository); + this.configCore.addValidator((config) => { + if (!validateCronExpression(config.library.scan.cronExpression)) { + throw new Error(`Invalid cron expression ${config.library.scan.cronExpression}`); + } + }); + } + + async init() { + const config = await this.configCore.getConfig(); + this.jobRepository.addCronJob( + 'libraryScan', + config.library.scan.cronExpression, + () => this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), + config.library.scan.enabled, + ); + + this.configCore.config$.subscribe((config) => { + this.jobRepository.updateCronJob('libraryScan', config.library.scan.cronExpression, config.library.scan.enabled); + }); } async getStatistics(authUser: AuthUserDto, id: string): Promise { diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts index 3527c9ea6..4b426062f 100644 --- a/server/src/domain/repositories/job.repository.ts +++ b/server/src/domain/repositories/job.repository.ts @@ -111,6 +111,9 @@ export const IJobRepository = 'IJobRepository'; export interface IJobRepository { addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void; + addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void; + updateCronJob(name: string, expression?: string, start?: boolean): void; + deleteCronJob(name: string): void; setConcurrency(queueName: QueueName, concurrency: number): void; queue(item: JobItem): Promise; pause(name: QueueName): Promise; diff --git a/server/src/domain/system-config/dto/index.ts b/server/src/domain/system-config/dto/index.ts index 4a94b4cc8..652e34cc5 100644 --- a/server/src/domain/system-config/dto/index.ts +++ b/server/src/domain/system-config/dto/index.ts @@ -1,4 +1,5 @@ export * from './system-config-ffmpeg.dto'; +export * from './system-config-library.dto'; export * from './system-config-oauth.dto'; export * from './system-config-password-login.dto'; export * from './system-config-storage-template.dto'; diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts new file mode 100644 index 000000000..2280e7093 --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-library.dto.ts @@ -0,0 +1,40 @@ +import { validateCronExpression } from '@app/domain'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsNotEmpty, + IsObject, + IsString, + Validate, + ValidateIf, + ValidateNested, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +const isEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; + +@ValidatorConstraint({ name: 'cronValidator' }) +class CronValidator implements ValidatorConstraintInterface { + validate(expression: string): boolean { + return validateCronExpression(expression); + } +} + +export class SystemConfigLibraryScanDto { + @IsBoolean() + enabled!: boolean; + + @ValidateIf(isEnabled) + @IsNotEmpty() + @Validate(CronValidator, { message: 'Invalid cron expression' }) + @IsString() + cronExpression!: string; +} + +export class SystemConfigLibraryDto { + @Type(() => SystemConfigLibraryScanDto) + @ValidateNested() + @IsObject() + scan!: SystemConfigLibraryScanDto; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index 975f5df89..dbd45855c 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { IsObject, ValidateNested } from 'class-validator'; import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigJobDto } from './system-config-job.dto'; +import { SystemConfigLibraryDto } from './system-config-library.dto'; import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto'; import { SystemConfigMapDto } from './system-config-map.dto'; import { SystemConfigNewVersionCheckDto } from './system-config-new-version-check.dto'; @@ -74,6 +75,11 @@ export class SystemConfigDto implements SystemConfig { @ValidateNested() @IsObject() theme!: SystemConfigThemeDto; + + @Type(() => SystemConfigLibraryDto) + @ValidateNested() + @IsObject() + library!: SystemConfigLibraryDto; } export function mapConfig(config: SystemConfig): SystemConfigDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index df4ef374b..4596370a4 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -13,6 +13,7 @@ import { VideoCodec, } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import * as _ from 'lodash'; @@ -120,6 +121,12 @@ export const defaults = Object.freeze({ theme: { customCss: '', }, + library: { + scan: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT, + }, + }, }); export enum FeatureFlag { diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index c3808a8cd..29ed44e91 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -121,6 +121,12 @@ const updatedConfig = Object.freeze({ theme: { customCss: '', }, + library: { + scan: { + enabled: true, + cronExpression: '0 0 * * *', + }, + }, }); describe(SystemConfigService.name, () => { diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index 110380753..ef9975d8c 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -1,4 +1,4 @@ -import { JobService, ONE_HOUR, SearchService, ServerInfoService, StorageService } from '@app/domain'; +import { JobService, LibraryService, ONE_HOUR, SearchService, ServerInfoService, StorageService } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; @@ -8,6 +8,7 @@ export class AppService { constructor( private jobService: JobService, + private libraryService: LibraryService, private searchService: SearchService, private storageService: StorageService, private serverService: ServerInfoService, @@ -28,6 +29,7 @@ export class AppService { await this.searchService.init(); await this.serverService.handleVersionCheck(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); + await this.libraryService.init(); } async destroy() { diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index de31bad32..b71a44c0a 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -94,6 +94,9 @@ export enum SystemConfigKey { TRASH_DAYS = 'trash.days', THEME_CUSTOM_CSS = 'theme.customCss', + + LIBRARY_SCAN_ENABLED = 'library.scan.enabled', + LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression', } export enum TranscodePolicy { @@ -232,4 +235,10 @@ export interface SystemConfig { theme: { customCss: string; }; + library: { + scan: { + enabled: boolean; + cronExpression: string; + }; + }; } diff --git a/server/src/infra/repositories/job.repository.ts b/server/src/infra/repositories/job.repository.ts index d34ee6819..067ba9bbf 100644 --- a/server/src/infra/repositories/job.repository.ts +++ b/server/src/infra/repositories/job.repository.ts @@ -2,7 +2,9 @@ import { IJobRepository, JobCounts, JobItem, JobName, JOBS_TO_QUEUE, QueueName, import { getQueueToken } from '@nestjs/bullmq'; import { Injectable, Logger } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq'; +import { CronJob, CronTime } from 'cron'; import { bullConfig } from '../infra.config'; @Injectable() @@ -10,7 +12,10 @@ export class JobRepository implements IJobRepository { private workers: Partial> = {}; private logger = new Logger(JobRepository.name); - constructor(private moduleRef: ModuleRef) {} + constructor( + private moduleRef: ModuleRef, + private schedulerReqistry: SchedulerRegistry, + ) {} addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise) { const workerHandler: Processor = async (job: Job) => handler(job as JobItem); @@ -18,6 +23,43 @@ export class JobRepository implements IJobRepository { this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions); } + addCronJob(name: string, expression: string, onTick: () => void, start = true): void { + const job = new CronJob( + expression, + onTick, + // function to run onComplete + undefined, + // whether it should start directly + start, + // timezone + undefined, + // context + undefined, + // runOnInit + undefined, + // utcOffset + undefined, + // prevents memory leaking by automatically stopping when the node process finishes + true, + ); + + this.schedulerReqistry.addCronJob(name, job); + } + + updateCronJob(name: string, expression?: string, start?: boolean): void { + const job = this.schedulerReqistry.getCronJob(name); + if (expression) { + job.setTime(new CronTime(expression)); + } + if (start !== undefined) { + start ? job.start() : job.stop(); + } + } + + deleteCronJob(name: string): void { + this.schedulerReqistry.deleteCronJob(name); + } + setConcurrency(queueName: QueueName, concurrency: number) { const worker = this.workers[queueName]; if (!worker) { diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index 16db4ca69..fe794d1dc 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -3,6 +3,9 @@ import { IJobRepository } from '@app/domain'; export const newJobRepositoryMock = (): jest.Mocked => { return { addHandler: jest.fn(), + addCronJob: jest.fn(), + deleteCronJob: jest.fn(), + updateCronJob: jest.fn(), setConcurrency: jest.fn(), empty: jest.fn(), pause: jest.fn(), diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 6b45c6ee6..4ac0cf0bf 100644 --- a/server/test/test-utils.ts +++ b/server/test/test-utils.ts @@ -49,6 +49,10 @@ export const testApp = { .overrideProvider(IJobRepository) .useValue({ addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler), + addCronJob: jest.fn(), + updateCronJob: jest.fn(), + deleteCronJob: jest.fn(), + validateCronExpression: jest.fn(), queue: (item: JobItem) => jobs && _handler(item), resume: jest.fn(), empty: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e79388602..97dc8523c 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -3283,6 +3283,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'job': SystemConfigJobDto; + /** + * + * @type {SystemConfigLibraryDto} + * @memberof SystemConfigDto + */ + 'library': SystemConfigLibraryDto; /** * * @type {SystemConfigMachineLearningDto} @@ -3534,6 +3540,38 @@ export interface SystemConfigJobDto { */ 'videoConversion': JobSettingsDto; } +/** + * + * @export + * @interface SystemConfigLibraryDto + */ +export interface SystemConfigLibraryDto { + /** + * + * @type {SystemConfigLibraryScanDto} + * @memberof SystemConfigLibraryDto + */ + 'scan': SystemConfigLibraryScanDto; +} +/** + * + * @export + * @interface SystemConfigLibraryScanDto + */ +export interface SystemConfigLibraryScanDto { + /** + * + * @type {string} + * @memberof SystemConfigLibraryScanDto + */ + 'cronExpression': string; + /** + * + * @type {boolean} + * @memberof SystemConfigLibraryScanDto + */ + 'enabled': boolean; +} /** * * @export diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte new file mode 100644 index 000000000..2330507e0 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -0,0 +1,145 @@ + + +
+ {#await getConfigs() then} +
+ +
+
+ + +
+ + +
+ + + +

+ Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru +

+
+
+
+ +
+ +
+
+
+
+ {/await} +
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index a86c91273..8dd4954b0 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -20,6 +20,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import type { PageData } from './$types'; import NewVersionCheckSettings from '$lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte'; + import LibrarySettings from '$lib/components/admin-page/settings/library-settings/library-settings.svelte'; import { mdiAlert, mdiContentCopy, mdiDownload } from '@mdi/js'; export let data: PageData; @@ -69,6 +70,10 @@ + + + + From 197f336b5f7f1ae0b5cd5e6d0e417aa3e9aac31c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 31 Oct 2023 16:37:32 -0400 Subject: [PATCH 03/26] fix(web): no preload repair report (#4749) --- .../components/shared-components/side-bar/admin-side-bar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte index af88e68f2..019063ef8 100644 --- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte @@ -24,7 +24,7 @@ - +
From 68f644671880e600e241b596bd2ac0dd36a07b7c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 31 Oct 2023 23:08:21 -0400 Subject: [PATCH 04/26] fix(cli): ignore web socket when unavailable and skip metadata init (#4748) --- server/src/domain/metadata/metadata.service.spec.ts | 4 ++++ server/src/domain/metadata/metadata.service.ts | 8 +++++++- server/src/infra/repositories/communication.repository.ts | 6 +++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index e6ca7f199..f31605b12 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -67,6 +67,10 @@ describe(MetadataService.name, () => { ); }); + afterEach(async () => { + await sut.teardown(); + }); + it('should be defined', () => { expect(sut).toBeDefined(); }); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 8829f6c6f..45193c2e1 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -4,6 +4,7 @@ import { ExifDateTime, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import { constants } from 'fs/promises'; import { Duration } from 'luxon'; +import { Subscription } from 'rxjs'; import { usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { @@ -67,6 +68,7 @@ export class MetadataService { private storageCore: StorageCore; private configCore: SystemConfigCore; private oldCities?: string; + private subscription: Subscription | null = null; constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @@ -81,10 +83,13 @@ export class MetadataService { ) { this.configCore = SystemConfigCore.create(configRepository); this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); - this.configCore.config$.subscribe(() => this.init()); } async init(deleteCache = false) { + if (!this.subscription) { + this.subscription = this.configCore.config$.subscribe(() => this.init()); + } + const { reverseGeocoding } = await this.configCore.getConfig(); const { citiesFileOverride } = reverseGeocoding; @@ -111,6 +116,7 @@ export class MetadataService { } async teardown() { + this.subscription?.unsubscribe(); await this.repository.teardown(); } diff --git a/server/src/infra/repositories/communication.repository.ts b/server/src/infra/repositories/communication.repository.ts index 908044f40..e1bbc77de 100644 --- a/server/src/infra/repositories/communication.repository.ts +++ b/server/src/infra/repositories/communication.repository.ts @@ -10,7 +10,7 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi constructor(private authService: AuthService) {} - @WebSocketServer() server!: Server; + @WebSocketServer() server?: Server; addEventListener(event: 'connect', callback: Callback) { this.onConnectCallbacks.push(callback); @@ -37,10 +37,10 @@ export class CommunicationRepository implements OnGatewayConnection, OnGatewayDi } send(event: CommunicationEvent, userId: string, data: any) { - this.server.to(userId).emit(event, data); + this.server?.to(userId).emit(event, data); } broadcast(event: CommunicationEvent, data: any) { - this.server.emit(event, data); + this.server?.emit(event, data); } } From ce5966c23df5ada4ed3643ed066649dcf5252612 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Wed, 1 Nov 2023 04:13:34 +0100 Subject: [PATCH 05/26] feat(web,server): activity (#4682) * feat: activity * regenerate api * fix: make asset owner unable to delete comment * fix: merge * fix: tests * feat: use textarea instead of input * fix: do actions only if the album is shared * fix: placeholder opacity * fix(web): improve messages UI * fix(web): improve input message UI * pr feedback * fix: tests * pr feedback * pr feedback * pr feedback * fix permissions * regenerate api * pr feedback * pr feedback * multiple improvements on web * fix: ui colors * WIP * chore: open api * pr feedback * fix: add comment * chore: clean up * pr feedback * refactor: endpoints * chore: open api * fix: filter by type * fix: e2e * feat: e2e remove own comment * fix: web tests * remove console.log * chore: cleanup * fix: ui tweaks * pr feedback * fix web test * fix: unit tests * chore: remove unused code * revert useless changes * fix: grouping messages * fix: remove nullable on updatedAt * fix: text overflow * styling --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 577 ++++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 18 + mobile/openapi/README.md | 9 + mobile/openapi/doc/ActivityApi.md | 242 ++++++++ mobile/openapi/doc/ActivityCreateDto.md | 18 + mobile/openapi/doc/ActivityResponseDto.md | 20 + .../doc/ActivityStatisticsResponseDto.md | 15 + mobile/openapi/doc/ReactionType.md | 14 + mobile/openapi/doc/UserDto.md | 19 + mobile/openapi/lib/api.dart | 6 + mobile/openapi/lib/api/activity_api.dart | 227 +++++++ mobile/openapi/lib/api_client.dart | 10 + mobile/openapi/lib/api_helper.dart | 3 + .../lib/model/activity_create_dto.dart | 140 +++++ .../lib/model/activity_response_dto.dart | 219 +++++++ .../activity_statistics_response_dto.dart | 98 +++ mobile/openapi/lib/model/reaction_type.dart | 85 +++ mobile/openapi/lib/model/user_dto.dart | 130 ++++ mobile/openapi/test/activity_api_test.dart | 41 ++ .../test/activity_create_dto_test.dart | 42 ++ .../test/activity_response_dto_test.dart | 52 ++ ...activity_statistics_response_dto_test.dart | 27 + mobile/openapi/test/reaction_type_test.dart | 21 + mobile/openapi/test/user_dto_test.dart | 47 ++ server/immich-openapi-specs.json | 297 ++++++++- server/src/domain/access/access.core.ts | 17 + server/src/domain/activity/activity.dto.ts | 65 ++ .../src/domain/activity/activity.service.ts | 80 +++ server/src/domain/activity/activity.spec.ts | 168 +++++ server/src/domain/activity/index.ts | 2 + server/src/domain/domain.module.ts | 2 + server/src/domain/index.ts | 1 + .../domain/repositories/access.repository.ts | 4 + .../repositories/activity.repository.ts | 11 + server/src/domain/repositories/index.ts | 1 + .../user/response-dto/user-response.dto.ts | 19 +- server/src/immich/app.module.ts | 2 + .../immich/controllers/activity.controller.ts | 52 ++ server/src/immich/controllers/index.ts | 1 + server/src/infra/entities/activity.entity.ts | 51 ++ server/src/infra/entities/index.ts | 3 + server/src/infra/infra.module.ts | 3 + .../migrations/1698693294632-AddActivity.ts | 22 + .../infra/repositories/access.repository.ts | 22 + .../infra/repositories/activity.repository.ts | 64 ++ server/src/infra/repositories/index.ts | 1 + server/test/api/activity-api.ts | 14 + server/test/api/album-api.ts | 7 +- server/test/api/index.ts | 2 + server/test/e2e/activity.e2e-spec.ts | 376 ++++++++++++ server/test/fixtures/activity.stub.ts | 34 ++ .../repositories/access.repository.mock.ts | 5 + .../repositories/activity.repository.mock.ts | 10 + web/src/api/api.ts | 3 + web/src/api/open-api/api.ts | 577 ++++++++++++++++++ .../components/album-page/album-viewer.svelte | 5 +- .../asset-viewer/activity-viewer.svelte | 289 +++++++++ .../asset-viewer/asset-viewer.svelte | 166 ++++- .../asset-viewer/photo-viewer.svelte | 8 +- .../components/photos-page/asset-grid.svelte | 8 +- .../individual-shared-viewer.svelte | 5 +- .../gallery-viewer/gallery-viewer.svelte | 4 +- .../shared-components/user-avatar.svelte | 12 +- web/src/lib/utils/asset-utils.ts | 13 +- web/src/lib/utils/timesince.ts | 9 + .../(user)/albums/[albumId]/+page.svelte | 10 +- 66 files changed, 4487 insertions(+), 38 deletions(-) create mode 100644 mobile/openapi/doc/ActivityApi.md create mode 100644 mobile/openapi/doc/ActivityCreateDto.md create mode 100644 mobile/openapi/doc/ActivityResponseDto.md create mode 100644 mobile/openapi/doc/ActivityStatisticsResponseDto.md create mode 100644 mobile/openapi/doc/ReactionType.md create mode 100644 mobile/openapi/doc/UserDto.md create mode 100644 mobile/openapi/lib/api/activity_api.dart create mode 100644 mobile/openapi/lib/model/activity_create_dto.dart create mode 100644 mobile/openapi/lib/model/activity_response_dto.dart create mode 100644 mobile/openapi/lib/model/activity_statistics_response_dto.dart create mode 100644 mobile/openapi/lib/model/reaction_type.dart create mode 100644 mobile/openapi/lib/model/user_dto.dart create mode 100644 mobile/openapi/test/activity_api_test.dart create mode 100644 mobile/openapi/test/activity_create_dto_test.dart create mode 100644 mobile/openapi/test/activity_response_dto_test.dart create mode 100644 mobile/openapi/test/activity_statistics_response_dto_test.dart create mode 100644 mobile/openapi/test/reaction_type_test.dart create mode 100644 mobile/openapi/test/user_dto_test.dart create mode 100644 server/src/domain/activity/activity.dto.ts create mode 100644 server/src/domain/activity/activity.service.ts create mode 100644 server/src/domain/activity/activity.spec.ts create mode 100644 server/src/domain/activity/index.ts create mode 100644 server/src/domain/repositories/activity.repository.ts create mode 100644 server/src/immich/controllers/activity.controller.ts create mode 100644 server/src/infra/entities/activity.entity.ts create mode 100644 server/src/infra/migrations/1698693294632-AddActivity.ts create mode 100644 server/src/infra/repositories/activity.repository.ts create mode 100644 server/test/api/activity-api.ts create mode 100644 server/test/e2e/activity.e2e-spec.ts create mode 100644 server/test/fixtures/activity.stub.ts create mode 100644 server/test/repositories/activity.repository.mock.ts create mode 100644 web/src/lib/components/asset-viewer/activity-viewer.svelte create mode 100644 web/src/lib/utils/timesince.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 97dc8523c..f64a592b5 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -99,6 +99,103 @@ export interface APIKeyUpdateDto { */ 'name': string; } +/** + * + * @export + * @interface ActivityCreateDto + */ +export interface ActivityCreateDto { + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'albumId': string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'assetId'?: string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'comment'?: string; + /** + * + * @type {ReactionType} + * @memberof ActivityCreateDto + */ + 'type': ReactionType; +} + + +/** + * + * @export + * @interface ActivityResponseDto + */ +export interface ActivityResponseDto { + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'assetId': string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'comment'?: string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'type': ActivityResponseDtoTypeEnum; + /** + * + * @type {UserDto} + * @memberof ActivityResponseDto + */ + 'user': UserDto; +} + +export const ActivityResponseDtoTypeEnum = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ActivityResponseDtoTypeEnum = typeof ActivityResponseDtoTypeEnum[keyof typeof ActivityResponseDtoTypeEnum]; + +/** + * + * @export + * @interface ActivityStatisticsResponseDto + */ +export interface ActivityStatisticsResponseDto { + /** + * + * @type {number} + * @memberof ActivityStatisticsResponseDto + */ + 'comments': number; +} /** * * @export @@ -2490,6 +2587,20 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const ReactionType = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ReactionType = typeof ReactionType[keyof typeof ReactionType]; + + /** * * @export @@ -4248,6 +4359,43 @@ export interface UsageByUserDto { */ 'videos': number; } +/** + * + * @export + * @interface UserDto + */ +export interface UserDto { + /** + * + * @type {string} + * @memberof UserDto + */ + 'email': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'firstName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'lastName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'profileImagePath': string; +} /** * * @export @@ -4831,6 +4979,435 @@ export class APIKeyApi extends BaseAPI { } +/** + * ActivityApi - axios parameter creator + * @export + */ +export const ActivityApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity: async (activityCreateDto: ActivityCreateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'activityCreateDto' is not null or undefined + assertParamExists('createActivity', 'activityCreateDto', activityCreateDto) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(activityCreateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteActivity', 'id', id) + const localVarPath = `/activity/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivities', 'albumId', albumId) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics: async (albumId: string, assetId?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivityStatistics', 'albumId', albumId) + const localVarPath = `/activity/statistics`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ActivityApi - functional programming interface + * @export + */ +export const ActivityApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ActivityApiAxiosParamCreator(configuration) + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createActivity(activityCreateDto: ActivityCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createActivity(activityCreateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteActivity(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteActivity(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivityStatistics(albumId: string, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivityStatistics(albumId, assetId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * ActivityApi - factory interface + * @export + */ +export const ActivityApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ActivityApiFp(configuration) + return { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createActivity(requestParameters.activityCreateDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteActivity(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createActivity operation in ActivityApi. + * @export + * @interface ActivityApiCreateActivityRequest + */ +export interface ActivityApiCreateActivityRequest { + /** + * + * @type {ActivityCreateDto} + * @memberof ActivityApiCreateActivity + */ + readonly activityCreateDto: ActivityCreateDto +} + +/** + * Request parameters for deleteActivity operation in ActivityApi. + * @export + * @interface ActivityApiDeleteActivityRequest + */ +export interface ActivityApiDeleteActivityRequest { + /** + * + * @type {string} + * @memberof ActivityApiDeleteActivity + */ + readonly id: string +} + +/** + * Request parameters for getActivities operation in ActivityApi. + * @export + * @interface ActivityApiGetActivitiesRequest + */ +export interface ActivityApiGetActivitiesRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly assetId?: string + + /** + * + * @type {ReactionType} + * @memberof ActivityApiGetActivities + */ + readonly type?: ReactionType +} + +/** + * Request parameters for getActivityStatistics operation in ActivityApi. + * @export + * @interface ActivityApiGetActivityStatisticsRequest + */ +export interface ActivityApiGetActivityStatisticsRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly assetId?: string +} + +/** + * ActivityApi - object-oriented interface + * @export + * @class ActivityApi + * @extends {BaseAPI} + */ +export class ActivityApi extends BaseAPI { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).createActivity(requestParameters.activityCreateDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).deleteActivity(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * AlbumApi - axios parameter creator * @export diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index c73dcfd06..52b863a6f 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -8,6 +8,10 @@ doc/APIKeyCreateDto.md doc/APIKeyCreateResponseDto.md doc/APIKeyResponseDto.md doc/APIKeyUpdateDto.md +doc/ActivityApi.md +doc/ActivityCreateDto.md +doc/ActivityResponseDto.md +doc/ActivityStatisticsResponseDto.md doc/AddUsersDto.md doc/AdminSignupResponseDto.md doc/AlbumApi.md @@ -97,6 +101,7 @@ doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md +doc/ReactionType.md doc/RecognitionConfig.md doc/ScanLibraryDto.md doc/SearchAlbumResponseDto.md @@ -158,11 +163,13 @@ doc/UpdateTagDto.md doc/UpdateUserDto.md doc/UsageByUserDto.md doc/UserApi.md +doc/UserDto.md doc/UserResponseDto.md doc/ValidateAccessTokenResponseDto.md doc/VideoCodec.md git_push.sh lib/api.dart +lib/api/activity_api.dart lib/api/album_api.dart lib/api/api_key_api.dart lib/api/asset_api.dart @@ -187,6 +194,9 @@ lib/auth/authentication.dart lib/auth/http_basic_auth.dart lib/auth/http_bearer_auth.dart lib/auth/oauth.dart +lib/model/activity_create_dto.dart +lib/model/activity_response_dto.dart +lib/model/activity_statistics_response_dto.dart lib/model/add_users_dto.dart lib/model/admin_signup_response_dto.dart lib/model/album_count_response_dto.dart @@ -271,6 +281,7 @@ lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart +lib/model/reaction_type.dart lib/model/recognition_config.dart lib/model/scan_library_dto.dart lib/model/search_album_response_dto.dart @@ -326,10 +337,15 @@ lib/model/update_stack_parent_dto.dart lib/model/update_tag_dto.dart lib/model/update_user_dto.dart lib/model/usage_by_user_dto.dart +lib/model/user_dto.dart lib/model/user_response_dto.dart lib/model/validate_access_token_response_dto.dart lib/model/video_codec.dart pubspec.yaml +test/activity_api_test.dart +test/activity_create_dto_test.dart +test/activity_response_dto_test.dart +test/activity_statistics_response_dto_test.dart test/add_users_dto_test.dart test/admin_signup_response_dto_test.dart test/album_api_test.dart @@ -424,6 +440,7 @@ test/person_response_dto_test.dart test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart +test/reaction_type_test.dart test/recognition_config_test.dart test/scan_library_dto_test.dart test/search_album_response_dto_test.dart @@ -485,6 +502,7 @@ test/update_tag_dto_test.dart test/update_user_dto_test.dart test/usage_by_user_dto_test.dart test/user_api_test.dart +test/user_dto_test.dart test/user_response_dto_test.dart test/validate_access_token_response_dto_test.dart test/video_codec_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9e5462b08..d11037497 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -77,6 +77,10 @@ Class | Method | HTTP request | Description *APIKeyApi* | [**getKey**](doc//APIKeyApi.md#getkey) | **GET** /api-key/{id} | *APIKeyApi* | [**getKeys**](doc//APIKeyApi.md#getkeys) | **GET** /api-key | *APIKeyApi* | [**updateKey**](doc//APIKeyApi.md#updatekey) | **PUT** /api-key/{id} | +*ActivityApi* | [**createActivity**](doc//ActivityApi.md#createactivity) | **POST** /activity | +*ActivityApi* | [**deleteActivity**](doc//ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} | +*ActivityApi* | [**getActivities**](doc//ActivityApi.md#getactivities) | **GET** /activity | +*ActivityApi* | [**getActivityStatistics**](doc//ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics | *AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | @@ -204,6 +208,9 @@ Class | Method | HTTP request | Description - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md) - [APIKeyResponseDto](doc//APIKeyResponseDto.md) - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md) + - [ActivityCreateDto](doc//ActivityCreateDto.md) + - [ActivityResponseDto](doc//ActivityResponseDto.md) + - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md) - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) @@ -284,6 +291,7 @@ Class | Method | HTTP request | Description - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [ReactionType](doc//ReactionType.md) - [RecognitionConfig](doc//RecognitionConfig.md) - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) @@ -339,6 +347,7 @@ Class | Method | HTTP request | Description - [UpdateTagDto](doc//UpdateTagDto.md) - [UpdateUserDto](doc//UpdateUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) + - [UserDto](doc//UserDto.md) - [UserResponseDto](doc//UserResponseDto.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) - [VideoCodec](doc//VideoCodec.md) diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md new file mode 100644 index 000000000..8ae91efc2 --- /dev/null +++ b/mobile/openapi/doc/ActivityApi.md @@ -0,0 +1,242 @@ +# openapi.api.ActivityApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**createActivity**](ActivityApi.md#createactivity) | **POST** /activity | +[**deleteActivity**](ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} | +[**getActivities**](ActivityApi.md#getactivities) | **GET** /activity | +[**getActivityStatistics**](ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics | + + +# **createActivity** +> ActivityResponseDto createActivity(activityCreateDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final activityCreateDto = ActivityCreateDto(); // ActivityCreateDto | + +try { + final result = api_instance.createActivity(activityCreateDto); + print(result); +} catch (e) { + print('Exception when calling ActivityApi->createActivity: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **activityCreateDto** | [**ActivityCreateDto**](ActivityCreateDto.md)| | + +### Return type + +[**ActivityResponseDto**](ActivityResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **deleteActivity** +> deleteActivity(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api_instance.deleteActivity(id); +} catch (e) { + print('Exception when calling ActivityApi->deleteActivity: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **getActivities** +> List getActivities(albumId, assetId, type) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final type = ; // ReactionType | + +try { + final result = api_instance.getActivities(albumId, assetId, type); + print(result); +} catch (e) { + print('Exception when calling ActivityApi->getActivities: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **albumId** | **String**| | + **assetId** | **String**| | [optional] + **type** | [**ReactionType**](.md)| | [optional] + +### Return type + +[**List**](ActivityResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **getActivityStatistics** +> ActivityStatisticsResponseDto getActivityStatistics(albumId, assetId) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = ActivityApi(); +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.getActivityStatistics(albumId, assetId); + print(result); +} catch (e) { + print('Exception when calling ActivityApi->getActivityStatistics: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **albumId** | **String**| | + **assetId** | **String**| | [optional] + +### Return type + +[**ActivityStatisticsResponseDto**](ActivityStatisticsResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/doc/ActivityCreateDto.md b/mobile/openapi/doc/ActivityCreateDto.md new file mode 100644 index 000000000..32cfdb5c7 --- /dev/null +++ b/mobile/openapi/doc/ActivityCreateDto.md @@ -0,0 +1,18 @@ +# openapi.model.ActivityCreateDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**albumId** | **String** | | +**assetId** | **String** | | [optional] +**comment** | **String** | | [optional] +**type** | [**ReactionType**](ReactionType.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ActivityResponseDto.md b/mobile/openapi/doc/ActivityResponseDto.md new file mode 100644 index 000000000..3b8589b61 --- /dev/null +++ b/mobile/openapi/doc/ActivityResponseDto.md @@ -0,0 +1,20 @@ +# openapi.model.ActivityResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**assetId** | **String** | | +**comment** | **String** | | [optional] +**createdAt** | [**DateTime**](DateTime.md) | | +**id** | **String** | | +**type** | **String** | | +**user** | [**UserDto**](UserDto.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ActivityStatisticsResponseDto.md b/mobile/openapi/doc/ActivityStatisticsResponseDto.md new file mode 100644 index 000000000..00583aea9 --- /dev/null +++ b/mobile/openapi/doc/ActivityStatisticsResponseDto.md @@ -0,0 +1,15 @@ +# openapi.model.ActivityStatisticsResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**comments** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ReactionType.md b/mobile/openapi/doc/ReactionType.md new file mode 100644 index 000000000..0cc41e23a --- /dev/null +++ b/mobile/openapi/doc/ReactionType.md @@ -0,0 +1,14 @@ +# openapi.model.ReactionType + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/UserDto.md b/mobile/openapi/doc/UserDto.md new file mode 100644 index 000000000..617ceb9d3 --- /dev/null +++ b/mobile/openapi/doc/UserDto.md @@ -0,0 +1,19 @@ +# openapi.model.UserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**email** | **String** | | +**firstName** | **String** | | +**id** | **String** | | +**lastName** | **String** | | +**profileImagePath** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d72aafe58..2bd437010 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -29,6 +29,7 @@ part 'auth/http_basic_auth.dart'; part 'auth/http_bearer_auth.dart'; part 'api/api_key_api.dart'; +part 'api/activity_api.dart'; part 'api/album_api.dart'; part 'api/asset_api.dart'; part 'api/audit_api.dart'; @@ -49,6 +50,9 @@ part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; part 'model/api_key_response_dto.dart'; part 'model/api_key_update_dto.dart'; +part 'model/activity_create_dto.dart'; +part 'model/activity_response_dto.dart'; +part 'model/activity_statistics_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_signup_response_dto.dart'; part 'model/album_count_response_dto.dart'; @@ -129,6 +133,7 @@ part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/queue_status_dto.dart'; +part 'model/reaction_type.dart'; part 'model/recognition_config.dart'; part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; @@ -184,6 +189,7 @@ part 'model/update_stack_parent_dto.dart'; part 'model/update_tag_dto.dart'; part 'model/update_user_dto.dart'; part 'model/usage_by_user_dto.dart'; +part 'model/user_dto.dart'; part 'model/user_response_dto.dart'; part 'model/validate_access_token_response_dto.dart'; part 'model/video_codec.dart'; diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart new file mode 100644 index 000000000..fa04c68b5 --- /dev/null +++ b/mobile/openapi/lib/api/activity_api.dart @@ -0,0 +1,227 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ActivityApi { + ActivityApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /activity' operation and returns the [Response]. + /// Parameters: + /// + /// * [ActivityCreateDto] activityCreateDto (required): + Future createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/activity'; + + // ignore: prefer_final_locals + Object? postBody = activityCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [ActivityCreateDto] activityCreateDto (required): + Future createActivity(ActivityCreateDto activityCreateDto,) async { + final response = await createActivityWithHttpInfo(activityCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ActivityResponseDto',) as ActivityResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /activity/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteActivityWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/activity/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteActivity(String id,) async { + final response = await deleteActivityWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /activity' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + /// + /// * [ReactionType] type: + Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async { + // ignore: prefer_const_declarations + final path = r'/activity'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'albumId', albumId)); + if (assetId != null) { + queryParams.addAll(_queryParams('', 'assetId', assetId)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + /// + /// * [ReactionType] type: + Future?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async { + final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + + /// Performs an HTTP 'GET /activity/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + Future getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async { + // ignore: prefer_const_declarations + final path = r'/activity/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'albumId', albumId)); + if (assetId != null) { + queryParams.addAll(_queryParams('', 'assetId', assetId)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] albumId (required): + /// + /// * [String] assetId: + Future getActivityStatistics(String albumId, { String? assetId, }) async { + final response = await getActivityStatisticsWithHttpInfo(albumId, assetId: assetId, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ActivityStatisticsResponseDto',) as ActivityStatisticsResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a61a6b4a9..76a849d71 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -189,6 +189,12 @@ class ApiClient { return APIKeyResponseDto.fromJson(value); case 'APIKeyUpdateDto': return APIKeyUpdateDto.fromJson(value); + case 'ActivityCreateDto': + return ActivityCreateDto.fromJson(value); + case 'ActivityResponseDto': + return ActivityResponseDto.fromJson(value); + case 'ActivityStatisticsResponseDto': + return ActivityStatisticsResponseDto.fromJson(value); case 'AddUsersDto': return AddUsersDto.fromJson(value); case 'AdminSignupResponseDto': @@ -349,6 +355,8 @@ class ApiClient { return PersonUpdateDto.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'ReactionType': + return ReactionTypeTypeTransformer().decode(value); case 'RecognitionConfig': return RecognitionConfig.fromJson(value); case 'ScanLibraryDto': @@ -459,6 +467,8 @@ class ApiClient { return UpdateUserDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); + case 'UserDto': + return UserDto.fromJson(value); case 'UserResponseDto': return UserResponseDto.fromJson(value); case 'ValidateAccessTokenResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 417a282e0..da870ea99 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,6 +97,9 @@ String parameterToString(dynamic value) { if (value is PathType) { return PathTypeTypeTransformer().encode(value).toString(); } + if (value is ReactionType) { + return ReactionTypeTypeTransformer().encode(value).toString(); + } if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart new file mode 100644 index 000000000..89618d403 --- /dev/null +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -0,0 +1,140 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ActivityCreateDto { + /// Returns a new [ActivityCreateDto] instance. + ActivityCreateDto({ + required this.albumId, + this.assetId, + this.comment, + required this.type, + }); + + String albumId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? assetId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? comment; + + ReactionType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is ActivityCreateDto && + other.albumId == albumId && + other.assetId == assetId && + other.comment == comment && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (assetId == null ? 0 : assetId!.hashCode) + + (comment == null ? 0 : comment!.hashCode) + + (type.hashCode); + + @override + String toString() => 'ActivityCreateDto[albumId=$albumId, assetId=$assetId, comment=$comment, type=$type]'; + + Map toJson() { + final json = {}; + json[r'albumId'] = this.albumId; + if (this.assetId != null) { + json[r'assetId'] = this.assetId; + } else { + // json[r'assetId'] = null; + } + if (this.comment != null) { + json[r'comment'] = this.comment; + } else { + // json[r'comment'] = null; + } + json[r'type'] = this.type; + return json; + } + + /// Returns a new [ActivityCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ActivityCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ActivityCreateDto( + albumId: mapValueOfType(json, r'albumId')!, + assetId: mapValueOfType(json, r'assetId'), + comment: mapValueOfType(json, r'comment'), + type: ReactionType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ActivityCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ActivityCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ActivityCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart new file mode 100644 index 000000000..0f4cbd481 --- /dev/null +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -0,0 +1,219 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ActivityResponseDto { + /// Returns a new [ActivityResponseDto] instance. + ActivityResponseDto({ + required this.assetId, + this.comment, + required this.createdAt, + required this.id, + required this.type, + required this.user, + }); + + String? assetId; + + String? comment; + + DateTime createdAt; + + String id; + + ActivityResponseDtoTypeEnum type; + + UserDto user; + + @override + bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto && + other.assetId == assetId && + other.comment == comment && + other.createdAt == createdAt && + other.id == id && + other.type == type && + other.user == user; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId == null ? 0 : assetId!.hashCode) + + (comment == null ? 0 : comment!.hashCode) + + (createdAt.hashCode) + + (id.hashCode) + + (type.hashCode) + + (user.hashCode); + + @override + String toString() => 'ActivityResponseDto[assetId=$assetId, comment=$comment, createdAt=$createdAt, id=$id, type=$type, user=$user]'; + + Map toJson() { + final json = {}; + if (this.assetId != null) { + json[r'assetId'] = this.assetId; + } else { + // json[r'assetId'] = null; + } + if (this.comment != null) { + json[r'comment'] = this.comment; + } else { + // json[r'comment'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'id'] = this.id; + json[r'type'] = this.type; + json[r'user'] = this.user; + return json; + } + + /// Returns a new [ActivityResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ActivityResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ActivityResponseDto( + assetId: mapValueOfType(json, r'assetId'), + comment: mapValueOfType(json, r'comment'), + createdAt: mapDateTime(json, r'createdAt', '')!, + id: mapValueOfType(json, r'id')!, + type: ActivityResponseDtoTypeEnum.fromJson(json[r'type'])!, + user: UserDto.fromJson(json[r'user'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ActivityResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ActivityResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ActivityResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'createdAt', + 'id', + 'type', + 'user', + }; +} + + +class ActivityResponseDtoTypeEnum { + /// Instantiate a new enum with the provided [value]. + const ActivityResponseDtoTypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const comment = ActivityResponseDtoTypeEnum._(r'comment'); + static const like = ActivityResponseDtoTypeEnum._(r'like'); + + /// List of all possible values in this [enum][ActivityResponseDtoTypeEnum]. + static const values = [ + comment, + like, + ]; + + static ActivityResponseDtoTypeEnum? fromJson(dynamic value) => ActivityResponseDtoTypeEnumTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityResponseDtoTypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ActivityResponseDtoTypeEnum] to String, +/// and [decode] dynamic data back to [ActivityResponseDtoTypeEnum]. +class ActivityResponseDtoTypeEnumTypeTransformer { + factory ActivityResponseDtoTypeEnumTypeTransformer() => _instance ??= const ActivityResponseDtoTypeEnumTypeTransformer._(); + + const ActivityResponseDtoTypeEnumTypeTransformer._(); + + String encode(ActivityResponseDtoTypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a ActivityResponseDtoTypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ActivityResponseDtoTypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'comment': return ActivityResponseDtoTypeEnum.comment; + case r'like': return ActivityResponseDtoTypeEnum.like; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ActivityResponseDtoTypeEnumTypeTransformer] instance. + static ActivityResponseDtoTypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart new file mode 100644 index 000000000..1b6af1229 --- /dev/null +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ActivityStatisticsResponseDto { + /// Returns a new [ActivityStatisticsResponseDto] instance. + ActivityStatisticsResponseDto({ + required this.comments, + }); + + int comments; + + @override + bool operator ==(Object other) => identical(this, other) || other is ActivityStatisticsResponseDto && + other.comments == comments; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (comments.hashCode); + + @override + String toString() => 'ActivityStatisticsResponseDto[comments=$comments]'; + + Map toJson() { + final json = {}; + json[r'comments'] = this.comments; + return json; + } + + /// Returns a new [ActivityStatisticsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ActivityStatisticsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ActivityStatisticsResponseDto( + comments: mapValueOfType(json, r'comments')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ActivityStatisticsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ActivityStatisticsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ActivityStatisticsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ActivityStatisticsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'comments', + }; +} + diff --git a/mobile/openapi/lib/model/reaction_type.dart b/mobile/openapi/lib/model/reaction_type.dart new file mode 100644 index 000000000..dbe6c11d2 --- /dev/null +++ b/mobile/openapi/lib/model/reaction_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ReactionType { + /// Instantiate a new enum with the provided [value]. + const ReactionType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const comment = ReactionType._(r'comment'); + static const like = ReactionType._(r'like'); + + /// List of all possible values in this [enum][ReactionType]. + static const values = [ + comment, + like, + ]; + + static ReactionType? fromJson(dynamic value) => ReactionTypeTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ReactionType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ReactionType] to String, +/// and [decode] dynamic data back to [ReactionType]. +class ReactionTypeTypeTransformer { + factory ReactionTypeTypeTransformer() => _instance ??= const ReactionTypeTypeTransformer._(); + + const ReactionTypeTypeTransformer._(); + + String encode(ReactionType data) => data.value; + + /// Decodes a [dynamic value][data] to a ReactionType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ReactionType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'comment': return ReactionType.comment; + case r'like': return ReactionType.like; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ReactionTypeTypeTransformer] instance. + static ReactionTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart new file mode 100644 index 000000000..e96588cf5 --- /dev/null +++ b/mobile/openapi/lib/model/user_dto.dart @@ -0,0 +1,130 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UserDto { + /// Returns a new [UserDto] instance. + UserDto({ + required this.email, + required this.firstName, + required this.id, + required this.lastName, + required this.profileImagePath, + }); + + String email; + + String firstName; + + String id; + + String lastName; + + String profileImagePath; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserDto && + other.email == email && + other.firstName == firstName && + other.id == id && + other.lastName == lastName && + other.profileImagePath == profileImagePath; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (email.hashCode) + + (firstName.hashCode) + + (id.hashCode) + + (lastName.hashCode) + + (profileImagePath.hashCode); + + @override + String toString() => 'UserDto[email=$email, firstName=$firstName, id=$id, lastName=$lastName, profileImagePath=$profileImagePath]'; + + Map toJson() { + final json = {}; + json[r'email'] = this.email; + json[r'firstName'] = this.firstName; + json[r'id'] = this.id; + json[r'lastName'] = this.lastName; + json[r'profileImagePath'] = this.profileImagePath; + return json; + } + + /// Returns a new [UserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UserDto( + email: mapValueOfType(json, r'email')!, + firstName: mapValueOfType(json, r'firstName')!, + id: mapValueOfType(json, r'id')!, + lastName: mapValueOfType(json, r'lastName')!, + profileImagePath: mapValueOfType(json, r'profileImagePath')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UserDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'email', + 'firstName', + 'id', + 'lastName', + 'profileImagePath', + }; +} + diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart new file mode 100644 index 000000000..401264c2b --- /dev/null +++ b/mobile/openapi/test/activity_api_test.dart @@ -0,0 +1,41 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + + +/// tests for ActivityApi +void main() { + // final instance = ActivityApi(); + + group('tests for ActivityApi', () { + //Future createActivity(ActivityCreateDto activityCreateDto) async + test('test createActivity', () async { + // TODO + }); + + //Future deleteActivity(String id) async + test('test deleteActivity', () async { + // TODO + }); + + //Future> getActivities(String albumId, { String assetId, ReactionType type }) async + test('test getActivities', () async { + // TODO + }); + + //Future getActivityStatistics(String albumId, { String assetId }) async + test('test getActivityStatistics', () async { + // TODO + }); + + }); +} diff --git a/mobile/openapi/test/activity_create_dto_test.dart b/mobile/openapi/test/activity_create_dto_test.dart new file mode 100644 index 000000000..263f1e27d --- /dev/null +++ b/mobile/openapi/test/activity_create_dto_test.dart @@ -0,0 +1,42 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ActivityCreateDto +void main() { + // final instance = ActivityCreateDto(); + + group('test ActivityCreateDto', () { + // String albumId + test('to test the property `albumId`', () async { + // TODO + }); + + // String assetId + test('to test the property `assetId`', () async { + // TODO + }); + + // String comment + test('to test the property `comment`', () async { + // TODO + }); + + // ReactionType type + test('to test the property `type`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/activity_response_dto_test.dart b/mobile/openapi/test/activity_response_dto_test.dart new file mode 100644 index 000000000..5f70944b6 --- /dev/null +++ b/mobile/openapi/test/activity_response_dto_test.dart @@ -0,0 +1,52 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ActivityResponseDto +void main() { + // final instance = ActivityResponseDto(); + + group('test ActivityResponseDto', () { + // String assetId + test('to test the property `assetId`', () async { + // TODO + }); + + // String comment + test('to test the property `comment`', () async { + // TODO + }); + + // DateTime createdAt + test('to test the property `createdAt`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String type + test('to test the property `type`', () async { + // TODO + }); + + // UserDto user + test('to test the property `user`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/activity_statistics_response_dto_test.dart b/mobile/openapi/test/activity_statistics_response_dto_test.dart new file mode 100644 index 000000000..05f8bfdd0 --- /dev/null +++ b/mobile/openapi/test/activity_statistics_response_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ActivityStatisticsResponseDto +void main() { + // final instance = ActivityStatisticsResponseDto(); + + group('test ActivityStatisticsResponseDto', () { + // int comments + test('to test the property `comments`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/reaction_type_test.dart b/mobile/openapi/test/reaction_type_test.dart new file mode 100644 index 000000000..4c0dfc595 --- /dev/null +++ b/mobile/openapi/test/reaction_type_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for ReactionType +void main() { + + group('test ReactionType', () { + + }); + +} diff --git a/mobile/openapi/test/user_dto_test.dart b/mobile/openapi/test/user_dto_test.dart new file mode 100644 index 000000000..fa1b7da86 --- /dev/null +++ b/mobile/openapi/test/user_dto_test.dart @@ -0,0 +1,47 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for UserDto +void main() { + // final instance = UserDto(); + + group('test UserDto', () { + // String email + test('to test the property `email`', () async { + // TODO + }); + + // String firstName + test('to test the property `firstName`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String lastName + test('to test the property `lastName`', () async { + // TODO + }); + + // String profileImagePath + test('to test the property `profileImagePath`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 6f8d639e9..2ed01f30e 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1,6 +1,194 @@ { "openapi": "3.0.0", "paths": { + "/activity": { + "get": { + "operationId": "getActivities", + "parameters": [ + { + "name": "albumId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "assetId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/ReactionType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ActivityResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + }, + "post": { + "operationId": "createActivity", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + } + }, + "/activity/statistics": { + "get": { + "operationId": "getActivityStatistics", + "parameters": [ + { + "name": "albumId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "assetId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + } + }, + "/activity/{id}": { + "delete": { + "operationId": "deleteActivity", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Activity" + ] + } + }, "/album": { "get": { "operationId": "getAllAlbums", @@ -5512,6 +5700,77 @@ ], "type": "object" }, + "ActivityCreateDto": { + "properties": { + "albumId": { + "format": "uuid", + "type": "string" + }, + "assetId": { + "format": "uuid", + "type": "string" + }, + "comment": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/ReactionType" + } + }, + "required": [ + "albumId", + "type" + ], + "type": "object" + }, + "ActivityResponseDto": { + "properties": { + "assetId": { + "nullable": true, + "type": "string" + }, + "comment": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "comment", + "like" + ], + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserDto" + } + }, + "required": [ + "id", + "createdAt", + "type", + "user", + "assetId" + ], + "type": "object" + }, + "ActivityStatisticsResponseDto": { + "properties": { + "comments": { + "type": "integer" + } + }, + "required": [ + "comments" + ], + "type": "object" + }, "AddUsersDto": { "properties": { "sharedUserIds": { @@ -7432,6 +7691,13 @@ ], "type": "object" }, + "ReactionType": { + "enum": [ + "comment", + "like" + ], + "type": "string" + }, "RecognitionConfig": { "properties": { "enabled": { @@ -8757,6 +9023,33 @@ ], "type": "object" }, + "UserDto": { + "properties": { + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "profileImagePath": { + "type": "string" + } + }, + "required": [ + "id", + "firstName", + "lastName", + "email", + "profileImagePath" + ], + "type": "object" + }, "UserResponseDto": { "properties": { "createdAt": { @@ -8810,12 +9103,12 @@ }, "required": [ "id", - "email", "firstName", "lastName", + "email", + "profileImagePath", "storageLabel", "externalPath", - "profileImagePath", "shouldChangePassword", "isAdmin", "createdAt", diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 1ad971250..414252778 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -3,6 +3,9 @@ import { AuthUserDto } from '../auth'; import { IAccessRepository } from '../repositories'; export enum Permission { + ACTIVITY_CREATE = 'activity.create', + ACTIVITY_DELETE = 'activity.delete', + // ASSET_CREATE = 'asset.create', ASSET_READ = 'asset.read', ASSET_UPDATE = 'asset.update', @@ -133,6 +136,20 @@ export class AccessCore { private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) { switch (permission) { + // uses album id + case Permission.ACTIVITY_CREATE: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); + + // uses activity id + case Permission.ACTIVITY_DELETE: + return ( + (await this.repository.activity.hasOwnerAccess(authUser.id, id)) || + (await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id)) + ); + case Permission.ASSET_READ: return ( (await this.repository.asset.hasOwnerAccess(authUser.id, id)) || diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts new file mode 100644 index 000000000..894c9b29c --- /dev/null +++ b/server/src/domain/activity/activity.dto.ts @@ -0,0 +1,65 @@ +import { ActivityEntity } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; +import { Optional, ValidateUUID } from '../domain.util'; +import { UserDto, mapSimpleUser } from '../user/response-dto'; + +export enum ReactionType { + COMMENT = 'comment', + LIKE = 'like', +} + +export type MaybeDuplicate = { duplicate: boolean; value: T }; + +export class ActivityResponseDto { + id!: string; + createdAt!: Date; + type!: ReactionType; + user!: UserDto; + assetId!: string | null; + comment?: string | null; +} + +export class ActivityStatisticsResponseDto { + @ApiProperty({ type: 'integer' }) + comments!: number; +} + +export class ActivityDto { + @ValidateUUID() + albumId!: string; + + @ValidateUUID({ optional: true }) + assetId?: string; +} + +export class ActivitySearchDto extends ActivityDto { + @IsEnum(ReactionType) + @Optional() + @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) + type?: ReactionType; +} + +const isComment = (dto: ActivityCreateDto) => dto.type === 'comment'; + +export class ActivityCreateDto extends ActivityDto { + @IsEnum(ReactionType) + @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) + type!: ReactionType; + + @ValidateIf(isComment) + @IsNotEmpty() + @IsString() + comment?: string; +} + +export function mapActivity(activity: ActivityEntity): ActivityResponseDto { + return { + id: activity.id, + assetId: activity.assetId, + createdAt: activity.createdAt, + comment: activity.comment, + type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, + user: mapSimpleUser(activity.user), + }; +} diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts new file mode 100644 index 000000000..e5af6169a --- /dev/null +++ b/server/src/domain/activity/activity.service.ts @@ -0,0 +1,80 @@ +import { ActivityEntity } from '@app/infra/entities'; +import { Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from '../access'; +import { AuthUserDto } from '../auth'; +import { IAccessRepository, IActivityRepository } from '../repositories'; +import { + ActivityCreateDto, + ActivityDto, + ActivityResponseDto, + ActivitySearchDto, + ActivityStatisticsResponseDto, + MaybeDuplicate, + ReactionType, + mapActivity, +} from './activity.dto'; + +@Injectable() +export class ActivityService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IActivityRepository) private repository: IActivityRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId); + const activities = await this.repository.search({ + albumId: dto.albumId, + assetId: dto.assetId, + isLiked: dto.type && dto.type === ReactionType.LIKE, + }); + + return activities.map(mapActivity); + } + + async getStatistics(authUser: AuthUserDto, dto: ActivityDto): Promise { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId); + return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) }; + } + + async create(authUser: AuthUserDto, dto: ActivityCreateDto): Promise> { + await this.access.requirePermission(authUser, Permission.ACTIVITY_CREATE, dto.albumId); + + const common = { + userId: authUser.id, + assetId: dto.assetId, + albumId: dto.albumId, + }; + + let activity: ActivityEntity | null = null; + let duplicate = false; + + if (dto.type === 'like') { + delete dto.comment; + [activity] = await this.repository.search({ + ...common, + isLiked: true, + }); + duplicate = !!activity; + } + + if (!activity) { + activity = await this.repository.create({ + ...common, + isLiked: dto.type === ReactionType.LIKE, + comment: dto.comment, + }); + } + + return { duplicate, value: mapActivity(activity) }; + } + + async delete(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.ACTIVITY_DELETE, id); + await this.repository.delete(id); + } +} diff --git a/server/src/domain/activity/activity.spec.ts b/server/src/domain/activity/activity.spec.ts new file mode 100644 index 000000000..968f7421a --- /dev/null +++ b/server/src/domain/activity/activity.spec.ts @@ -0,0 +1,168 @@ +import { BadRequestException } from '@nestjs/common'; +import { authStub, IAccessRepositoryMock, newAccessRepositoryMock } from '@test'; +import { activityStub } from '@test/fixtures/activity.stub'; +import { newActivityRepositoryMock } from '@test/repositories/activity.repository.mock'; +import { IActivityRepository } from '../repositories'; +import { ReactionType } from './activity.dto'; +import { ActivityService } from './activity.service'; + +describe(ActivityService.name, () => { + let sut: ActivityService; + let accessMock: IAccessRepositoryMock; + let activityMock: jest.Mocked; + + beforeEach(async () => { + accessMock = newAccessRepositoryMock(); + activityMock = newActivityRepositoryMock(); + + sut = new ActivityService(accessMock, activityMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getAll', () => { + it('should get all', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([]); + + await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); + + expect(activityMock.search).toHaveBeenCalledWith({ + assetId: 'asset-id', + albumId: 'album-id', + isLiked: undefined, + }); + }); + + it('should filter by type=like', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([]); + + await expect( + sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }), + ).resolves.toEqual([]); + + expect(activityMock.search).toHaveBeenCalledWith({ + assetId: 'asset-id', + albumId: 'album-id', + isLiked: true, + }); + }); + + it('should filter by type=comment', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([]); + + await expect( + sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }), + ).resolves.toEqual([]); + + expect(activityMock.search).toHaveBeenCalledWith({ + assetId: 'asset-id', + albumId: 'album-id', + isLiked: false, + }); + }); + }); + + describe('getStatistics', () => { + it('should get the comment count', async () => { + activityMock.getStatistics.mockResolvedValue(1); + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + await expect( + sut.getStatistics(authStub.admin, { + assetId: 'asset-id', + albumId: activityStub.oneComment.albumId, + }), + ).resolves.toEqual({ comments: 1 }); + }); + }); + + describe('addComment', () => { + it('should require access to the album', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.COMMENT, + comment: 'comment', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should create a comment', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.create.mockResolvedValue(activityStub.oneComment); + + await sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.COMMENT, + comment: 'comment', + }); + + expect(activityMock.create).toHaveBeenCalledWith({ + userId: 'admin_id', + albumId: 'album-id', + assetId: 'asset-id', + comment: 'comment', + isLiked: false, + }); + }); + + it('should create a like', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.create.mockResolvedValue(activityStub.liked); + activityMock.search.mockResolvedValue([]); + + await sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.LIKE, + }); + + expect(activityMock.create).toHaveBeenCalledWith({ + userId: 'admin_id', + albumId: 'album-id', + assetId: 'asset-id', + isLiked: true, + }); + }); + + it('should skip if like exists', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + activityMock.search.mockResolvedValue([activityStub.liked]); + + await sut.create(authStub.admin, { + albumId: 'album-id', + assetId: 'asset-id', + type: ReactionType.LIKE, + }); + + expect(activityMock.create).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should require access', async () => { + accessMock.activity.hasOwnerAccess.mockResolvedValue(false); + await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException); + expect(activityMock.delete).not.toHaveBeenCalled(); + }); + + it('should let the activity owner delete a comment', async () => { + accessMock.activity.hasOwnerAccess.mockResolvedValue(true); + await sut.delete(authStub.admin, 'activity-id'); + expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + }); + + it('should let the album owner delete a comment', async () => { + accessMock.activity.hasAlbumOwnerAccess.mockResolvedValue(true); + await sut.delete(authStub.admin, 'activity-id'); + expect(activityMock.delete).toHaveBeenCalledWith('activity-id'); + }); + }); +}); diff --git a/server/src/domain/activity/index.ts b/server/src/domain/activity/index.ts new file mode 100644 index 000000000..f0d954014 --- /dev/null +++ b/server/src/domain/activity/index.ts @@ -0,0 +1,2 @@ +export * from './activity.dto'; +export * from './activity.service'; diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index dc00c692e..d03fd27d4 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -1,4 +1,5 @@ import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, Provider } from '@nestjs/common'; +import { ActivityService } from './activity'; import { AlbumService } from './album'; import { APIKeyService } from './api-key'; import { AssetService } from './asset'; @@ -21,6 +22,7 @@ import { TagService } from './tag'; import { UserService } from './user'; const providers: Provider[] = [ + ActivityService, AlbumService, APIKeyService, AssetService, diff --git a/server/src/domain/index.ts b/server/src/domain/index.ts index f2b05ac76..e76159d40 100644 --- a/server/src/domain/index.ts +++ b/server/src/domain/index.ts @@ -1,4 +1,5 @@ export * from './access'; +export * from './activity'; export * from './album'; export * from './api-key'; export * from './asset'; diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 7584762fc..43b53e605 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -1,6 +1,10 @@ export const IAccessRepository = 'IAccessRepository'; export interface IAccessRepository { + activity: { + hasOwnerAccess(userId: string, albumId: string): Promise; + hasAlbumOwnerAccess(userId: string, albumId: string): Promise; + }; asset: { hasOwnerAccess(userId: string, assetId: string): Promise; hasAlbumAccess(userId: string, assetId: string): Promise; diff --git a/server/src/domain/repositories/activity.repository.ts b/server/src/domain/repositories/activity.repository.ts new file mode 100644 index 000000000..6f5476a28 --- /dev/null +++ b/server/src/domain/repositories/activity.repository.ts @@ -0,0 +1,11 @@ +import { ActivityEntity } from '@app/infra/entities/activity.entity'; +import { ActivitySearch } from '@app/infra/repositories'; + +export const IActivityRepository = 'IActivityRepository'; + +export interface IActivityRepository { + search(options: ActivitySearch): Promise; + create(activity: Partial): Promise; + delete(id: string): Promise; + getStatistics(assetId: string | undefined, albumId: string): Promise; +} diff --git a/server/src/domain/repositories/index.ts b/server/src/domain/repositories/index.ts index 2c4a10cc2..ff098d8db 100644 --- a/server/src/domain/repositories/index.ts +++ b/server/src/domain/repositories/index.ts @@ -1,4 +1,5 @@ export * from './access.repository'; +export * from './activity.repository'; export * from './album.repository'; export * from './api-key.repository'; export * from './asset.repository'; diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index 59a387de1..b9f990378 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/server/src/domain/user/response-dto/user-response.dto.ts @@ -1,13 +1,16 @@ import { UserEntity } from '@app/infra/entities'; -export class UserResponseDto { +export class UserDto { id!: string; - email!: string; firstName!: string; lastName!: string; + email!: string; + profileImagePath!: string; +} + +export class UserResponseDto extends UserDto { storageLabel!: string | null; externalPath!: string | null; - profileImagePath!: string; shouldChangePassword!: boolean; isAdmin!: boolean; createdAt!: Date; @@ -17,15 +20,21 @@ export class UserResponseDto { memoriesEnabled?: boolean; } -export function mapUser(entity: UserEntity): UserResponseDto { +export const mapSimpleUser = (entity: UserEntity): UserDto => { return { id: entity.id, email: entity.email, firstName: entity.firstName, lastName: entity.lastName, + profileImagePath: entity.profileImagePath, + }; +}; + +export function mapUser(entity: UserEntity): UserResponseDto { + return { + ...mapSimpleUser(entity), storageLabel: entity.storageLabel, externalPath: entity.externalPath, - profileImagePath: entity.profileImagePath, shouldChangePassword: entity.shouldChangePassword, isAdmin: entity.isAdmin, createdAt: entity.createdAt, diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 25771d147..cf537c6b9 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -13,6 +13,7 @@ import { FileUploadInterceptor } from './app.interceptor'; import { AppService } from './app.service'; import { APIKeyController, + ActivityController, AlbumController, AppController, AssetController, @@ -39,6 +40,7 @@ import { TypeOrmModule.forFeature([AssetEntity]), ], controllers: [ + ActivityController, AssetController, AssetControllerV1, AppController, diff --git a/server/src/immich/controllers/activity.controller.ts b/server/src/immich/controllers/activity.controller.ts new file mode 100644 index 000000000..d6c2ea762 --- /dev/null +++ b/server/src/immich/controllers/activity.controller.ts @@ -0,0 +1,52 @@ +import { AuthUserDto } from '@app/domain'; +import { + ActivityDto, + ActivitySearchDto, + ActivityService, + ActivityCreateDto as CreateDto, + ActivityResponseDto as ResponseDto, + ActivityStatisticsResponseDto as StatsResponseDto, +} from '@app/domain/activity'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { AuthUser, Authenticated } from '../app.guard'; +import { UseValidation } from '../app.utils'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Activity') +@Controller('activity') +@Authenticated() +@UseValidation() +export class ActivityController { + constructor(private service: ActivityService) {} + + @Get() + getActivities(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivitySearchDto): Promise { + return this.service.getAll(authUser, dto); + } + + @Get('statistics') + getActivityStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: ActivityDto): Promise { + return this.service.getStatistics(authUser, dto); + } + + @Post() + async createActivity( + @AuthUser() authUser: AuthUserDto, + @Body() dto: CreateDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { duplicate, value } = await this.service.create(authUser, dto); + if (duplicate) { + res.status(HttpStatus.OK); + } + return value; + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteActivity(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(authUser, id); + } +} diff --git a/server/src/immich/controllers/index.ts b/server/src/immich/controllers/index.ts index fd6f0b01e..b54a63d86 100644 --- a/server/src/immich/controllers/index.ts +++ b/server/src/immich/controllers/index.ts @@ -1,3 +1,4 @@ +export * from './activity.controller'; export * from './album.controller'; export * from './api-key.controller'; export * from './app.controller'; diff --git a/server/src/infra/entities/activity.entity.ts b/server/src/infra/entities/activity.entity.ts new file mode 100644 index 000000000..255a3a708 --- /dev/null +++ b/server/src/infra/entities/activity.entity.ts @@ -0,0 +1,51 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AlbumEntity } from './album.entity'; +import { AssetEntity } from './asset.entity'; +import { UserEntity } from './user.entity'; + +@Entity('activity') +@Index('IDX_activity_like', ['assetId', 'userId', 'albumId'], { unique: true, where: '("isLiked" = true)' }) +@Check(`("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`) +export class ActivityEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column() + albumId!: string; + + @Column() + userId!: string; + + @Column({ nullable: true, type: 'uuid' }) + assetId!: string | null; + + @Column({ type: 'text', default: null }) + comment!: string | null; + + @Column({ type: 'boolean', default: false }) + isLiked!: boolean; + + @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + asset!: AssetEntity | null; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + user!: UserEntity; + + @ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + album!: AlbumEntity; +} diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index ef4d635b0..cbe2bf6c3 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -1,3 +1,4 @@ +import { ActivityEntity } from './activity.entity'; import { AlbumEntity } from './album.entity'; import { APIKeyEntity } from './api-key.entity'; import { AssetFaceEntity } from './asset-face.entity'; @@ -15,6 +16,7 @@ import { TagEntity } from './tag.entity'; import { UserTokenEntity } from './user-token.entity'; import { UserEntity } from './user.entity'; +export * from './activity.entity'; export * from './album.entity'; export * from './api-key.entity'; export * from './asset-face.entity'; @@ -33,6 +35,7 @@ export * from './user-token.entity'; export * from './user.entity'; export const databaseEntities = [ + ActivityEntity, AlbumEntity, APIKeyEntity, AssetEntity, diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 367458169..ffbedafff 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -1,5 +1,6 @@ import { IAccessRepository, + IActivityRepository, IAlbumRepository, IAssetRepository, IAuditRepository, @@ -35,6 +36,7 @@ import { bullConfig, bullQueues } from './infra.config'; import { APIKeyRepository, AccessRepository, + ActivityRepository, AlbumRepository, AssetRepository, AuditRepository, @@ -60,6 +62,7 @@ import { } from './repositories'; const providers: Provider[] = [ + { provide: IActivityRepository, useClass: ActivityRepository }, { provide: IAccessRepository, useClass: AccessRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAssetRepository, useClass: AssetRepository }, diff --git a/server/src/infra/migrations/1698693294632-AddActivity.ts b/server/src/infra/migrations/1698693294632-AddActivity.ts new file mode 100644 index 000000000..46041570e --- /dev/null +++ b/server/src/infra/migrations/1698693294632-AddActivity.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddActivity1698693294632 implements MigrationInterface { + name = 'AddActivity1698693294632' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "activity" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "assetId" uuid, "comment" text, "isLiked" boolean NOT NULL DEFAULT false, CONSTRAINT "CHK_2ab1e70f113f450eb40c1e3ec8" CHECK (("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)), CONSTRAINT "PK_24625a1d6b1b089c8ae206fe467" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_activity_like" ON "activity" ("assetId", "userId", "albumId") WHERE ("isLiked" = true)`); + await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_8091ea76b12338cb4428d33d782" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_1af8519996fbfb3684b58df280b" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_1af8519996fbfb3684b58df280b"`); + await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea"`); + await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_8091ea76b12338cb4428d33d782"`); + await queryRunner.query(`DROP INDEX "public"."IDX_activity_like"`); + await queryRunner.query(`DROP TABLE "activity"`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 50085c4aa..566514796 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -2,6 +2,7 @@ import { IAccessRepository } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { + ActivityEntity, AlbumEntity, AssetEntity, LibraryEntity, @@ -13,6 +14,7 @@ import { export class AccessRepository implements IAccessRepository { constructor( + @InjectRepository(ActivityEntity) private activityRepository: Repository, @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(AlbumEntity) private albumRepository: Repository, @InjectRepository(LibraryEntity) private libraryRepository: Repository, @@ -22,6 +24,26 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(UserTokenEntity) private tokenRepository: Repository, ) {} + activity = { + hasOwnerAccess: (userId: string, activityId: string): Promise => { + return this.activityRepository.exist({ + where: { + id: activityId, + userId, + }, + }); + }, + hasAlbumOwnerAccess: (userId: string, activityId: string): Promise => { + return this.activityRepository.exist({ + where: { + id: activityId, + album: { + ownerId: userId, + }, + }, + }); + }, + }; library = { hasOwnerAccess: (userId: string, libraryId: string): Promise => { return this.libraryRepository.exist({ diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts new file mode 100644 index 000000000..271124db5 --- /dev/null +++ b/server/src/infra/repositories/activity.repository.ts @@ -0,0 +1,64 @@ +import { IActivityRepository } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ActivityEntity } from '../entities/activity.entity'; + +export interface ActivitySearch { + albumId?: string; + assetId?: string; + userId?: string; + isLiked?: boolean; +} + +@Injectable() +export class ActivityRepository implements IActivityRepository { + constructor(@InjectRepository(ActivityEntity) private repository: Repository) {} + + search(options: ActivitySearch): Promise { + const { userId, assetId, albumId, isLiked } = options; + return this.repository.find({ + where: { + userId, + assetId, + albumId, + isLiked, + }, + relations: { + user: true, + }, + order: { + createdAt: 'ASC', + }, + }); + } + + create(entity: Partial): Promise { + return this.save(entity); + } + + async delete(id: string): Promise { + await this.repository.delete(id); + } + + getStatistics(assetId: string, albumId: string): Promise { + return this.repository.count({ + where: { assetId, albumId, isLiked: false }, + relations: { + user: true, + }, + }); + } + + private async save(entity: Partial) { + const { id } = await this.repository.save(entity); + return this.repository.findOneOrFail({ + where: { + id, + }, + relations: { + user: true, + }, + }); + } +} diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index bc2fba766..81ea7dd81 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -1,4 +1,5 @@ export * from './access.repository'; +export * from './activity.repository'; export * from './album.repository'; export * from './api-key.repository'; export * from './asset.repository'; diff --git a/server/test/api/activity-api.ts b/server/test/api/activity-api.ts new file mode 100644 index 000000000..f7cac4562 --- /dev/null +++ b/server/test/api/activity-api.ts @@ -0,0 +1,14 @@ +import { ActivityCreateDto, ActivityResponseDto } from '@app/domain'; +import request from 'supertest'; + +export const activityApi = { + create: async (server: any, accessToken: string, dto: ActivityCreateDto) => { + const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto); + expect(res.status === 200 || res.status === 201).toBe(true); + return res.body as ActivityResponseDto; + }, + delete: async (server: any, accessToken: string, id: string) => { + const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`); + expect(res.status).toEqual(204); + }, +}; diff --git a/server/test/api/album-api.ts b/server/test/api/album-api.ts index 3364c3452..70a016da1 100644 --- a/server/test/api/album-api.ts +++ b/server/test/api/album-api.ts @@ -1,4 +1,4 @@ -import { AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain'; +import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain'; import request from 'supertest'; export const albumApi = { @@ -15,4 +15,9 @@ export const albumApi = { expect(res.status).toEqual(200); return res.body as BulkIdResponseDto[]; }, + addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => { + const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto); + expect(res.status).toEqual(200); + return res.body as AlbumResponseDto; + }, }; diff --git a/server/test/api/index.ts b/server/test/api/index.ts index f04a3a209..d9321df27 100644 --- a/server/test/api/index.ts +++ b/server/test/api/index.ts @@ -1,3 +1,4 @@ +import { activityApi } from './activity-api'; import { albumApi } from './album-api'; import { assetApi } from './asset-api'; import { authApi } from './auth-api'; @@ -6,6 +7,7 @@ import { sharedLinkApi } from './shared-link-api'; import { userApi } from './user-api'; export const api = { + activityApi, authApi, assetApi, libraryApi, diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts new file mode 100644 index 000000000..c488630ec --- /dev/null +++ b/server/test/e2e/activity.e2e-spec.ts @@ -0,0 +1,376 @@ +import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain'; +import { ActivityController } from '@app/immich'; +import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; +import { api } from '@test/api'; +import { db } from '@test/db'; +import { errorStub, uuidStub } from '@test/fixtures'; +import { testApp } from '@test/test-utils'; +import request from 'supertest'; + +describe(`${ActivityController.name} (e2e)`, () => { + let server: any; + let admin: LoginResponseDto; + let asset: AssetFileUploadResponseDto; + let album: AlbumResponseDto; + + beforeAll(async () => { + [server] = await testApp.create(); + }); + + afterAll(async () => { + await testApp.teardown(); + }); + + beforeEach(async () => { + await db.reset(); + await api.authApi.adminSignUp(server); + admin = await api.authApi.adminLogin(server); + asset = await api.assetApi.upload(server, admin.accessToken, 'example'); + album = await api.albumApi.create(server, admin.accessToken, { albumName: 'Album 1', assetIds: [asset.id] }); + }); + + describe('GET /activity', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/activity'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require an albumId', async () => { + const { status, body } = await request(server) + .get('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); + }); + + it('should reject an invalid albumId', async () => { + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: uuidStub.invalid }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); + }); + + it('should reject an invalid assetId', async () => { + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID']))); + }); + + it('should start off empty', async () => { + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should filter by album id', async () => { + const album2 = await api.albumApi.create(server, admin.accessToken, { + albumName: 'Album 2', + assetIds: [asset.id], + }); + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.LIKE, + }), + api.activityApi.create(server, admin.accessToken, { + albumId: album2.id, + type: ReactionType.LIKE, + }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + + it('should filter by type=comment', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }), + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id, type: 'comment' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + + it('should filter by type=like', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id, type: 'like' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + + it('should filter by assetId', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }), + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + ]); + + const { status, body } = await request(server) + .get('/activity') + .query({ albumId: album.id, assetId: asset.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body.length).toBe(1); + expect(body[0]).toEqual(reaction); + }); + }); + + describe('POST /activity', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).post('/activity'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require an albumId', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: uuidStub.invalid }); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID']))); + }); + + it('should require a comment when type is comment', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: uuidStub.notFound, type: 'comment', comment: null }); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty'])); + }); + + it('should add a comment to an album', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: null, + createdAt: expect.any(String), + type: 'comment', + comment: 'This is my first comment', + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should add a like to an album', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'like' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: null, + createdAt: expect.any(String), + type: 'like', + comment: null, + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should return a 200 for a duplicate like on the album', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.LIKE, + }); + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, type: 'like' }); + expect(status).toEqual(200); + expect(body).toEqual(reaction); + }); + + it('should add a comment to an asset', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: asset.id, + createdAt: expect.any(String), + type: 'comment', + comment: 'This is my first comment', + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should add a like to an asset', async () => { + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, assetId: asset.id, type: 'like' }); + expect(status).toEqual(201); + expect(body).toEqual({ + id: expect.any(String), + assetId: asset.id, + createdAt: expect.any(String), + type: 'like', + comment: null, + user: expect.objectContaining({ email: admin.userEmail }), + }); + }); + + it('should return a 200 for a duplicate like on an asset', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + const { status, body } = await request(server) + .post('/activity') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ albumId: album.id, assetId: asset.id, type: 'like' }); + expect(status).toEqual(200); + expect(body).toEqual(reaction); + }); + }); + + describe('DELETE /activity/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(server) + .delete(`/activity/${uuidStub.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['id must be a UUID'])); + }); + + it('should remove a comment from an album', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(204); + }); + + it('should remove a like from an album', async () => { + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.LIKE, + }); + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(204); + }); + + it('should let the owner remove a comment by another user', async () => { + const { id: userId } = await api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }); + await api.albumApi.addUsers(server, admin.accessToken, album.id, { sharedUserIds: [userId] }); + const nonOwner = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + const reaction = await api.activityApi.create(server, nonOwner.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(204); + }); + + it('should not let a user remove a comment by another user', async () => { + const { id: userId } = await api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }); + await api.albumApi.addUsers(server, admin.accessToken, album.id, { sharedUserIds: [userId] }); + const nonOwner = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + const reaction = await api.activityApi.create(server, admin.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + const { status, body } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${nonOwner.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access')); + }); + + it('should let a non-owner remove their own comment', async () => { + const { id: userId } = await api.userApi.create(server, admin.accessToken, { + email: 'user1@immich.app', + password: 'Password123', + firstName: 'User 1', + lastName: 'Test', + }); + await api.albumApi.addUsers(server, admin.accessToken, album.id, { sharedUserIds: [userId] }); + const nonOwner = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }); + const reaction = await api.activityApi.create(server, nonOwner.accessToken, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + const { status } = await request(server) + .delete(`/activity/${reaction.id}`) + .set('Authorization', `Bearer ${nonOwner.accessToken}`); + expect(status).toBe(204); + }); + }); +}); diff --git a/server/test/fixtures/activity.stub.ts b/server/test/fixtures/activity.stub.ts new file mode 100644 index 000000000..91c699cec --- /dev/null +++ b/server/test/fixtures/activity.stub.ts @@ -0,0 +1,34 @@ +import { ActivityEntity } from '@app/infra/entities'; +import { albumStub } from './album.stub'; +import { assetStub } from './asset.stub'; +import { authStub } from './auth.stub'; +import { userStub } from './user.stub'; + +export const activityStub = { + oneComment: Object.freeze({ + id: 'activity-1', + comment: 'comment', + isLiked: false, + userId: authStub.admin.id, + user: userStub.admin, + assetId: assetStub.image.id, + asset: assetStub.image, + albumId: albumStub.oneAsset.id, + album: albumStub.oneAsset, + createdAt: new Date(), + updatedAt: new Date(), + }), + liked: Object.freeze({ + id: 'activity-2', + comment: null, + isLiked: true, + userId: authStub.admin.id, + user: userStub.admin, + assetId: assetStub.image.id, + asset: assetStub.image, + albumId: albumStub.oneAsset.id, + album: albumStub.oneAsset, + createdAt: new Date(), + updatedAt: new Date(), + }), +}; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 9fbf7922d..4f7992e86 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,6 +1,7 @@ import { AccessCore, IAccessRepository } from '@app/domain'; export interface IAccessRepositoryMock { + activity: jest.Mocked; asset: jest.Mocked; album: jest.Mocked; authDevice: jest.Mocked; @@ -15,6 +16,10 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => } return { + activity: { + hasOwnerAccess: jest.fn(), + hasAlbumOwnerAccess: jest.fn(), + }, asset: { hasOwnerAccess: jest.fn(), hasAlbumAccess: jest.fn(), diff --git a/server/test/repositories/activity.repository.mock.ts b/server/test/repositories/activity.repository.mock.ts new file mode 100644 index 000000000..349fa4636 --- /dev/null +++ b/server/test/repositories/activity.repository.mock.ts @@ -0,0 +1,10 @@ +import { IActivityRepository } from '@app/domain'; + +export const newActivityRepositoryMock = (): jest.Mocked => { + return { + search: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + getStatistics: jest.fn(), + }; +}; diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 9beb370d3..3a3584ed4 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -20,12 +20,14 @@ import { UserApi, UserApiFp, AuditApi, + ActivityApi, } from './open-api'; import { BASE_PATH } from './open-api/base'; import { DUMMY_BASE_URL, toPathString } from './open-api/common'; import type { ApiParams } from './types'; export class ImmichApi { + public activityApi: ActivityApi; public albumApi: AlbumApi; public libraryApi: LibraryApi; public assetApi: AssetApi; @@ -52,6 +54,7 @@ export class ImmichApi { constructor(params: ConfigurationParameters) { this.config = new Configuration(params); + this.activityApi = new ActivityApi(this.config); this.albumApi = new AlbumApi(this.config); this.auditApi = new AuditApi(this.config); this.libraryApi = new LibraryApi(this.config); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 97dc8523c..f64a592b5 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -99,6 +99,103 @@ export interface APIKeyUpdateDto { */ 'name': string; } +/** + * + * @export + * @interface ActivityCreateDto + */ +export interface ActivityCreateDto { + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'albumId': string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'assetId'?: string; + /** + * + * @type {string} + * @memberof ActivityCreateDto + */ + 'comment'?: string; + /** + * + * @type {ReactionType} + * @memberof ActivityCreateDto + */ + 'type': ReactionType; +} + + +/** + * + * @export + * @interface ActivityResponseDto + */ +export interface ActivityResponseDto { + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'assetId': string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'comment'?: string | null; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof ActivityResponseDto + */ + 'type': ActivityResponseDtoTypeEnum; + /** + * + * @type {UserDto} + * @memberof ActivityResponseDto + */ + 'user': UserDto; +} + +export const ActivityResponseDtoTypeEnum = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ActivityResponseDtoTypeEnum = typeof ActivityResponseDtoTypeEnum[keyof typeof ActivityResponseDtoTypeEnum]; + +/** + * + * @export + * @interface ActivityStatisticsResponseDto + */ +export interface ActivityStatisticsResponseDto { + /** + * + * @type {number} + * @memberof ActivityStatisticsResponseDto + */ + 'comments': number; +} /** * * @export @@ -2490,6 +2587,20 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const ReactionType = { + Comment: 'comment', + Like: 'like' +} as const; + +export type ReactionType = typeof ReactionType[keyof typeof ReactionType]; + + /** * * @export @@ -4248,6 +4359,43 @@ export interface UsageByUserDto { */ 'videos': number; } +/** + * + * @export + * @interface UserDto + */ +export interface UserDto { + /** + * + * @type {string} + * @memberof UserDto + */ + 'email': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'firstName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'lastName': string; + /** + * + * @type {string} + * @memberof UserDto + */ + 'profileImagePath': string; +} /** * * @export @@ -4831,6 +4979,435 @@ export class APIKeyApi extends BaseAPI { } +/** + * ActivityApi - axios parameter creator + * @export + */ +export const ActivityApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity: async (activityCreateDto: ActivityCreateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'activityCreateDto' is not null or undefined + assertParamExists('createActivity', 'activityCreateDto', activityCreateDto) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(activityCreateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteActivity', 'id', id) + const localVarPath = `/activity/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivities', 'albumId', albumId) + const localVarPath = `/activity`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics: async (albumId: string, assetId?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'albumId' is not null or undefined + assertParamExists('getActivityStatistics', 'albumId', albumId) + const localVarPath = `/activity/statistics`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (assetId !== undefined) { + localVarQueryParameter['assetId'] = assetId; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ActivityApi - functional programming interface + * @export + */ +export const ActivityApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ActivityApiAxiosParamCreator(configuration) + return { + /** + * + * @param {ActivityCreateDto} activityCreateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createActivity(activityCreateDto: ActivityCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createActivity(activityCreateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteActivity(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteActivity(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {ReactionType} [type] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivities(albumId: string, assetId?: string, type?: ReactionType, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} albumId + * @param {string} [assetId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getActivityStatistics(albumId: string, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivityStatistics(albumId, assetId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * ActivityApi - factory interface + * @export + */ +export const ActivityApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ActivityApiFp(configuration) + return { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createActivity(requestParameters.activityCreateDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteActivity(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createActivity operation in ActivityApi. + * @export + * @interface ActivityApiCreateActivityRequest + */ +export interface ActivityApiCreateActivityRequest { + /** + * + * @type {ActivityCreateDto} + * @memberof ActivityApiCreateActivity + */ + readonly activityCreateDto: ActivityCreateDto +} + +/** + * Request parameters for deleteActivity operation in ActivityApi. + * @export + * @interface ActivityApiDeleteActivityRequest + */ +export interface ActivityApiDeleteActivityRequest { + /** + * + * @type {string} + * @memberof ActivityApiDeleteActivity + */ + readonly id: string +} + +/** + * Request parameters for getActivities operation in ActivityApi. + * @export + * @interface ActivityApiGetActivitiesRequest + */ +export interface ActivityApiGetActivitiesRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly assetId?: string + + /** + * + * @type {ReactionType} + * @memberof ActivityApiGetActivities + */ + readonly type?: ReactionType +} + +/** + * Request parameters for getActivityStatistics operation in ActivityApi. + * @export + * @interface ActivityApiGetActivityStatisticsRequest + */ +export interface ActivityApiGetActivityStatisticsRequest { + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly albumId: string + + /** + * + * @type {string} + * @memberof ActivityApiGetActivityStatistics + */ + readonly assetId?: string +} + +/** + * ActivityApi - object-oriented interface + * @export + * @class ActivityApi + * @extends {BaseAPI} + */ +export class ActivityApi extends BaseAPI { + /** + * + * @param {ActivityApiCreateActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).createActivity(requestParameters.activityCreateDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).deleteActivity(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ActivityApi + */ + public getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig) { + return ActivityApiFp(this.configuration).getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * AlbumApi - axios parameter creator * @export diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index ddc165c18..2333a55f0 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -5,7 +5,7 @@ import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { locale } from '$lib/stores/preferences.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; - import type { AlbumResponseDto, SharedLinkResponseDto } from '@api'; + import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api'; import { onDestroy, onMount } from 'svelte'; import { dateFormats } from '../../constants'; import { createAssetInteractionStore } from '../../stores/asset-interaction.store'; @@ -22,6 +22,7 @@ import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js'; export let sharedLink: SharedLinkResponseDto; + export let user: UserResponseDto | undefined = undefined; const album = sharedLink.album as AlbumResponseDto; @@ -138,7 +139,7 @@
- +

+ import { createEventDispatcher } from 'svelte'; + import UserAvatar from '../shared-components/user-avatar.svelte'; + import { mdiClose, mdiHeart, mdiSend, mdiDotsVertical } from '@mdi/js'; + import Icon from '$lib/components/elements/icon.svelte'; + import { ActivityResponseDto, api, AssetTypeEnum, ReactionType, type UserResponseDto } from '@api'; + import { handleError } from '$lib/utils/handle-error'; + import { isTenMinutesApart } from '$lib/utils/timesince'; + import { clickOutside } from '$lib/utils/click-outside'; + import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; + import LoadingSpinner from '../shared-components/loading-spinner.svelte'; + import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { getAssetType } from '$lib/utils/asset-utils'; + import * as luxon from 'luxon'; + + const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; + + const timeSince = (dateTime: luxon.DateTime) => { + const diff = dateTime.diffNow().shiftTo(...units); + const unit = units.find((unit) => diff.get(unit) !== 0) || 'second'; + + const relativeFormatter = new Intl.RelativeTimeFormat('en', { + numeric: 'auto', + }); + return relativeFormatter.format(Math.trunc(diff.as(unit)), unit); + }; + + export let reactions: ActivityResponseDto[]; + export let user: UserResponseDto; + export let assetId: string; + export let albumId: string; + export let assetType: AssetTypeEnum; + export let albumOwnerId: string; + + let textArea: HTMLTextAreaElement; + let innerHeight: number; + let activityHeight: number; + let chatHeight: number; + let divHeight: number; + let previousAssetId: string | null; + let message = ''; + let isSendingMessage = false; + + const dispatch = createEventDispatcher(); + + $: showDeleteReaction = Array(reactions.length).fill(false); + $: { + if (innerHeight && activityHeight) { + divHeight = innerHeight - activityHeight; + } + } + + $: { + if (previousAssetId != assetId) { + getReactions(); + previousAssetId = assetId; + } + } + + const getReactions = async () => { + try { + const { data } = await api.activityApi.getActivities({ assetId, albumId }); + reactions = data; + } catch (error) { + handleError(error, 'Error when fetching reactions'); + } + }; + + const handleEnter = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSendComment(); + return; + } + }; + + const autoGrow = () => { + textArea.style.height = '5px'; + textArea.style.height = textArea.scrollHeight + 'px'; + }; + + const timeOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + } as Intl.DateTimeFormatOptions; + + const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => { + try { + await api.activityApi.deleteActivity({ id: reaction.id }); + reactions.splice(index, 1); + showDeleteReaction.splice(index, 1); + reactions = reactions; + if (reaction.type === 'like' && reaction.user.id === user.id) { + dispatch('deleteLike'); + } else { + dispatch('deleteComment'); + } + notificationController.show({ + message: `${reaction.type} deleted`, + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, `Can't remove ${reaction.type}`); + } + }; + + const handleSendComment = async () => { + if (!message) { + return; + } + const timeout = setTimeout(() => (isSendingMessage = true), 100); + try { + const { data } = await api.activityApi.createActivity({ + activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message }, + }); + reactions.push(data); + textArea.style.height = '18px'; + message = ''; + dispatch('addComment'); + // Re-render the activity feed + reactions = reactions; + } catch (error) { + handleError(error, "Can't add your comment"); + } finally { + clearTimeout(timeout); + } + isSendingMessage = false; + }; + + const showOptionsMenu = (index: number) => { + showDeleteReaction[index] = !showDeleteReaction[index]; + }; + + +

+
+
+
+ + +

Activity

+
+
+ {#if innerHeight} +
+ {#each reactions as reaction, index (reaction.id)} + {#if reaction.type === 'comment'} +
+
+ +
+ +
{reaction.comment}
+ {#if reaction.user.id === user.id || albumOwnerId === user.id} +
+ +
+ {/if} +
+ {#if showDeleteReaction[index]} + + {/if} +
+
+ {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} +
+ {timeSince(luxon.DateTime.fromISO(reaction.createdAt))} +
+ {/if} + {:else if reaction.type === 'like'} +
+
+
+ +
+ {`${reaction.user.firstName} ${reaction.user.lastName} liked this ${getAssetType( + assetType, + ).toLowerCase()}`} +
+ {#if reaction.user.id === user.id || albumOwnerId === user.id} +
+ +
+ {/if} +
+ {#if showDeleteReaction[index]} + + {/if} +
+
+ {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} +
+ {timeSince(luxon.DateTime.fromISO(reaction.createdAt))} +
+ {/if} +
+ {/if} + {/each} +
+ {/if} +
+ +
+
+
+
+ +
+
handleSendComment()}> +
+