From ba57646f9f59ffaec221ec3ef6d67c4e539fa677 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jason@rasm.me>
Date: Thu, 12 Sep 2024 14:12:39 -0400
Subject: [PATCH] refactor(server): client emit events (#12606)

* refactor(server): client emit events

* chore: test coverage
---
 server/src/interfaces/event.interface.ts      | 14 ++++
 server/src/services/asset-media.service.ts    |  5 +-
 server/src/services/asset.service.ts          |  7 +-
 .../src/services/notification.service.spec.ts | 73 +++++++++++++++++++
 server/src/services/notification.service.ts   | 41 ++++++++++-
 server/src/services/stack.service.ts          | 12 ++-
 server/src/services/trash.service.spec.ts     |  6 +-
 server/src/services/trash.service.ts          |  4 +-
 8 files changed, 142 insertions(+), 20 deletions(-)

diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts
index 0cd0207155..eced261dbe 100644
--- a/server/src/interfaces/event.interface.ts
+++ b/server/src/interfaces/event.interface.ts
@@ -22,10 +22,24 @@ type EmitEventMap = {
   'asset.untag': [{ assetId: string }];
   'asset.hide': [{ assetId: string; userId: string }];
   'asset.show': [{ assetId: string; userId: string }];
+  'asset.trash': [{ assetId: string; userId: string }];
+  'asset.delete': [{ assetId: string; userId: string }];
+
+  // asset bulk events
+  'assets.trash': [{ assetIds: string[]; userId: string }];
+  'assets.restore': [{ assetIds: string[]; userId: string }];
 
   // session events
   'session.delete': [{ sessionId: string }];
 
+  // stack events
+  'stack.create': [{ stackId: string; userId: string }];
+  'stack.update': [{ stackId: string; userId: string }];
+  'stack.delete': [{ stackId: string; userId: string }];
+
+  // stack bulk events
+  'stacks.delete': [{ stackIds: string[]; userId: string }];
+
   // user events
   'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
 };
diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts
index 111d222c16..df3b183442 100644
--- a/server/src/services/asset-media.service.ts
+++ b/server/src/services/asset-media.service.ts
@@ -30,7 +30,7 @@ import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entit
 import { AssetType, Permission } from 'src/enum';
 import { IAccessRepository } from 'src/interfaces/access.interface';
 import { IAssetRepository } from 'src/interfaces/asset.interface';
-import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { IJobRepository, JobName } from 'src/interfaces/job.interface';
 import { ILoggerRepository } from 'src/interfaces/logger.interface';
 import { IStorageRepository } from 'src/interfaces/storage.interface';
@@ -194,8 +194,7 @@ export class AssetMediaService {
       const copiedPhoto = await this.createCopy(asset);
       // and immediate trash it
       await this.assetRepository.softDeleteAll([copiedPhoto.id]);
-
-      this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]);
+      await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id });
 
       await this.userRepository.updateUsage(auth.user.id, file.size);
 
diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts
index 06ca3af7d5..98d3dd1459 100644
--- a/server/src/services/asset.service.ts
+++ b/server/src/services/asset.service.ts
@@ -23,7 +23,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
 import { Permission } from 'src/enum';
 import { IAccessRepository } from 'src/interfaces/access.interface';
 import { IAssetRepository } from 'src/interfaces/asset.interface';
-import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import {
   IAssetDeleteJob,
   IJobRepository,
@@ -273,7 +273,8 @@ export class AssetService {
     if (!asset.libraryId) {
       await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
     }
-    this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
+
+    await this.eventRepository.emit('asset.delete', { assetId: id, userId: asset.ownerId });
 
     // delete the motion if it is not used by another asset
     if (asset.livePhotoVideoId) {
@@ -311,7 +312,7 @@ export class AssetService {
       );
     } else {
       await this.assetRepository.softDeleteAll(ids);
-      this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids);
+      await this.eventRepository.emit('assets.trash', { assetIds: ids, userId: auth.user.id });
     }
   }
 
diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts
index 9d9f8f5fcf..9ef1310bfb 100644
--- a/server/src/services/notification.service.spec.ts
+++ b/server/src/services/notification.service.spec.ts
@@ -144,6 +144,23 @@ describe(NotificationService.name, () => {
     });
   });
 
+  describe('onAssetHide', () => {
+    it('should send connected clients an event', () => {
+      sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id');
+    });
+  });
+
+  describe('onAssetShow', () => {
+    it('should queue the generate thumbnail job', async () => {
+      await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' });
+      expect(jobMock.queue).toHaveBeenCalledWith({
+        name: JobName.GENERATE_THUMBNAIL,
+        data: { id: 'asset-id', notify: true },
+      });
+    });
+  });
+
   describe('onUserSignupEvent', () => {
     it('skips when notify is false', async () => {
       await sut.onUserSignup({ id: '', notify: false });
@@ -179,6 +196,62 @@ describe(NotificationService.name, () => {
     });
   });
 
+  describe('onAssetTrash', () => {
+    it('should send connected clients an event', () => {
+      sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']);
+    });
+  });
+
+  describe('onAssetDelete', () => {
+    it('should send connected clients an event', () => {
+      sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id');
+    });
+  });
+
+  describe('onAssetsTrash', () => {
+    it('should send connected clients an event', () => {
+      sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']);
+    });
+  });
+
+  describe('onAssetsRestore', () => {
+    it('should send connected clients an event', () => {
+      sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']);
+    });
+  });
+
+  describe('onStackCreate', () => {
+    it('should send connected clients an event', () => {
+      sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
+    });
+  });
+
+  describe('onStackUpdate', () => {
+    it('should send connected clients an event', () => {
+      sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
+    });
+  });
+
+  describe('onStackDelete', () => {
+    it('should send connected clients an event', () => {
+      sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
+    });
+  });
+
+  describe('onStacksDelete', () => {
+    it('should send connected clients an event', () => {
+      sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' });
+      expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []);
+    });
+  });
+
   describe('sendTestEmail', () => {
     it('should throw error if user could not be found', async () => {
       await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found');
diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts
index 01da235bf0..4eef49c631 100644
--- a/server/src/services/notification.service.ts
+++ b/server/src/services/notification.service.ts
@@ -60,7 +60,6 @@ export class NotificationService {
 
   @OnEmit({ event: 'asset.hide' })
   onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
-    // Notify clients to hide the linked live photo asset
     this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
   }
 
@@ -69,6 +68,46 @@ export class NotificationService {
     await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } });
   }
 
+  @OnEmit({ event: 'asset.trash' })
+  onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]);
+  }
+
+  @OnEmit({ event: 'asset.delete' })
+  onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId);
+  }
+
+  @OnEmit({ event: 'assets.trash' })
+  onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds);
+  }
+
+  @OnEmit({ event: 'assets.restore' })
+  onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds);
+  }
+
+  @OnEmit({ event: 'stack.create' })
+  onStackCreate({ userId }: ArgOf<'stack.create'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
+  }
+
+  @OnEmit({ event: 'stack.update' })
+  onStackUpdate({ userId }: ArgOf<'stack.update'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
+  }
+
+  @OnEmit({ event: 'stack.delete' })
+  onStackDelete({ userId }: ArgOf<'stack.delete'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
+  }
+
+  @OnEmit({ event: 'stacks.delete' })
+  onStacksDelete({ userId }: ArgOf<'stacks.delete'>) {
+    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
+  }
+
   @OnEmit({ event: 'user.signup' })
   async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
     if (notify) {
diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts
index bebc8517d6..29a598d4b4 100644
--- a/server/src/services/stack.service.ts
+++ b/server/src/services/stack.service.ts
@@ -4,7 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
 import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
 import { Permission } from 'src/enum';
 import { IAccessRepository } from 'src/interfaces/access.interface';
-import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { IStackRepository } from 'src/interfaces/stack.interface';
 import { requireAccess } from 'src/utils/access';
 
@@ -30,7 +30,7 @@ export class StackService {
 
     const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
 
-    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
+    await this.eventRepository.emit('stack.create', { stackId: stack.id, userId: auth.user.id });
 
     return mapStack(stack, { auth });
   }
@@ -50,7 +50,7 @@ export class StackService {
 
     const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId });
 
-    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
+    await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id });
 
     return mapStack(updatedStack, { auth });
   }
@@ -58,15 +58,13 @@ export class StackService {
   async delete(auth: AuthDto, id: string): Promise<void> {
     await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] });
     await this.stackRepository.delete(id);
-
-    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
+    await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id });
   }
 
   async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
     await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
     await this.stackRepository.deleteAll(dto.ids);
-
-    this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
+    await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id });
   }
 
   private async findOrFail(id: string) {
diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts
index 73a4f3d57b..5c0609956a 100644
--- a/server/src/services/trash.service.spec.ts
+++ b/server/src/services/trash.service.spec.ts
@@ -1,6 +1,6 @@
 import { BadRequestException } from '@nestjs/common';
 import { IAssetRepository } from 'src/interfaces/asset.interface';
-import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { IJobRepository, JobName } from 'src/interfaces/job.interface';
 import { TrashService } from 'src/services/trash.service';
 import { assetStub } from 'test/fixtures/asset.stub';
@@ -62,9 +62,7 @@ describe(TrashService.name, () => {
       assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
       await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
       expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
-      expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [
-        assetStub.image.id,
-      ]);
+      expect(eventMock.emit).toHaveBeenCalledWith('assets.restore', { assetIds: ['asset-id'], userId: 'user-id' });
     });
   });
 
diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts
index ac141521dd..712b9e50f2 100644
--- a/server/src/services/trash.service.ts
+++ b/server/src/services/trash.service.ts
@@ -5,7 +5,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
 import { Permission } from 'src/enum';
 import { IAccessRepository } from 'src/interfaces/access.interface';
 import { IAssetRepository } from 'src/interfaces/asset.interface';
-import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
 import { requireAccess } from 'src/utils/access';
 import { usePagination } from 'src/utils/pagination';
@@ -64,6 +64,6 @@ export class TrashService {
     }
 
     await this.assetRepository.restoreAll(ids);
-    this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
+    await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
   }
 }