瀏覽代碼

fix(syncing-server): remove notifications after adding item to vault (#672)

Karol Sójko 1 年之前
父節點
當前提交
8f88a87c93

+ 12 - 5
packages/syncing-server/src/Bootstrap/Container.ts

@@ -152,6 +152,7 @@ import { NotificationHttpMapper } from '../Mapping/Http/NotificationHttpMapper'
 import { NotificationHttpRepresentation } from '../Mapping/Http/NotificationHttpRepresentation'
 import { DetermineSharedVaultOperationOnItem } from '../Domain/UseCase/SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem'
 import { SharedVaultFilter } from '../Domain/Item/SaveRule/SharedVaultFilter'
+import { RemoveNotificationsForUser } from '../Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -549,6 +550,14 @@ export class ContainerConfigLoader {
           container.get(TYPES.Sync_DomainEventFactory),
         ),
       )
+    container
+      .bind<AddNotificationForUser>(TYPES.Sync_AddNotificationForUser)
+      .toConstantValue(
+        new AddNotificationForUser(container.get(TYPES.Sync_NotificationRepository), container.get(TYPES.Sync_Timer)),
+      )
+    container
+      .bind<RemoveNotificationsForUser>(TYPES.Sync_RemoveNotificationsForUser)
+      .toConstantValue(new RemoveNotificationsForUser(container.get(TYPES.Sync_NotificationRepository)))
     container
       .bind<UpdateExistingItem>(TYPES.Sync_UpdateExistingItem)
       .toConstantValue(
@@ -558,6 +567,9 @@ export class ContainerConfigLoader {
           container.get(TYPES.Sync_DomainEventPublisher),
           container.get(TYPES.Sync_DomainEventFactory),
           container.get(TYPES.Sync_REVISIONS_FREQUENCY),
+          container.get(TYPES.Sync_DetermineSharedVaultOperationOnItem),
+          container.get(TYPES.Sync_AddNotificationForUser),
+          container.get(TYPES.Sync_RemoveNotificationsForUser),
         ),
       )
     container
@@ -673,11 +685,6 @@ export class ContainerConfigLoader {
           container.get(TYPES.Sync_SharedVaultRepository),
         ),
       )
-    container
-      .bind<AddNotificationForUser>(TYPES.Sync_AddNotificationForUser)
-      .toConstantValue(
-        new AddNotificationForUser(container.get(TYPES.Sync_NotificationRepository), container.get(TYPES.Sync_Timer)),
-      )
     container
       .bind<RemoveUserFromSharedVault>(TYPES.Sync_RemoveSharedVaultUser)
       .toConstantValue(

+ 1 - 0
packages/syncing-server/src/Bootstrap/Types.ts

@@ -57,6 +57,7 @@ const TYPES = {
   Sync_GetSharedVaultUsers: Symbol.for('Sync_GetSharedVaultUsers'),
   Sync_AddUserToSharedVault: Symbol.for('Sync_AddUserToSharedVault'),
   Sync_AddNotificationForUser: Symbol.for('Sync_AddNotificationForUser'),
+  Sync_RemoveNotificationsForUser: Symbol.for('Sync_RemoveNotificationsForUser'),
   Sync_RemoveSharedVaultUser: Symbol.for('Sync_RemoveSharedVaultUser'),
   Sync_InviteUserToSharedVault: Symbol.for('Sync_InviteUserToSharedVault'),
   Sync_UpdateSharedVaultInvite: Symbol.for('Sync_UpdateSharedVaultInvite'),

+ 3 - 1
packages/syncing-server/src/Domain/Notifications/NotificationRepositoryInterface.ts

@@ -1,4 +1,4 @@
-import { Uuid } from '@standardnotes/domain-core'
+import { NotificationType, Uuid } from '@standardnotes/domain-core'
 
 import { Notification } from './Notification'
 
@@ -6,4 +6,6 @@ export interface NotificationRepositoryInterface {
   save(notification: Notification): Promise<void>
   findByUserUuidUpdatedAfter(userUuid: Uuid, lastSyncTime: number): Promise<Notification[]>
   findByUserUuid(userUuid: Uuid): Promise<Notification[]>
+  findByUserUuidAndType(userUuid: Uuid, type: NotificationType): Promise<Notification[]>
+  remove(notification: Notification): Promise<void>
 }

+ 64 - 0
packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser.spec.ts

@@ -0,0 +1,64 @@
+import { NotificationPayload, NotificationType, Timestamps, Uuid } from '@standardnotes/domain-core'
+
+import { NotificationRepositoryInterface } from '../../../Notifications/NotificationRepositoryInterface'
+import { RemoveNotificationsForUser } from './RemoveNotificationsForUser'
+import { Notification } from '../../../Notifications/Notification'
+
+describe('RemoveNotificationsForUser', () => {
+  let notificationRepository: NotificationRepositoryInterface
+  let notification: Notification
+
+  const createUseCase = () => new RemoveNotificationsForUser(notificationRepository)
+
+  beforeEach(() => {
+    notification = Notification.create({
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      type: NotificationType.create(NotificationType.TYPES.SharedVaultItemRemoved).getValue(),
+      payload: NotificationPayload.create({
+        itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        type: NotificationType.create(NotificationType.TYPES.SharedVaultItemRemoved).getValue(),
+        version: '1.0',
+      }).getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+
+    notificationRepository = {} as jest.Mocked<NotificationRepositoryInterface>
+    notificationRepository.findByUserUuidAndType = jest.fn().mockResolvedValue([notification])
+    notificationRepository.remove = jest.fn()
+  })
+
+  it('should remove notifications for user', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      type: NotificationType.TYPES.SharedVaultItemRemoved,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(notificationRepository.remove).toHaveBeenCalledWith(notification)
+  })
+
+  it('should fail if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'invalid',
+      type: NotificationType.TYPES.SharedVaultItemRemoved,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should fail if notification type is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      type: 'invalid',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+})

+ 29 - 0
packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser.ts

@@ -0,0 +1,29 @@
+import { NotificationType, Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
+import { RemoveNotificationsForUserDTO } from './RemoveNotificationsForUserDTO'
+import { NotificationRepositoryInterface } from '../../../Notifications/NotificationRepositoryInterface'
+
+export class RemoveNotificationsForUser implements UseCaseInterface<void> {
+  constructor(private notificationRepository: NotificationRepositoryInterface) {}
+
+  async execute(dto: RemoveNotificationsForUserDTO): Promise<Result<void>> {
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const typeOrError = NotificationType.create(dto.type)
+    if (typeOrError.isFailed()) {
+      return Result.fail(typeOrError.getError())
+    }
+    const type = typeOrError.getValue()
+
+    const notifications = await this.notificationRepository.findByUserUuidAndType(userUuid, type)
+    for (const notification of notifications) {
+      await this.notificationRepository.remove(notification)
+    }
+
+    return Result.ok()
+  }
+}

+ 4 - 0
packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUserDTO.ts

@@ -0,0 +1,4 @@
+export interface RemoveNotificationsForUserDTO {
+  type: string
+  userUuid: string
+}

+ 152 - 3
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts

@@ -5,9 +5,21 @@ import { Item } from '../../../Item/Item'
 import { ItemHash } from '../../../Item/ItemHash'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { UpdateExistingItem } from './UpdateExistingItem'
-import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId, Result } from '@standardnotes/domain-core'
+import {
+  Uuid,
+  ContentType,
+  Dates,
+  Timestamps,
+  UniqueEntityId,
+  Result,
+  NotificationPayload,
+} from '@standardnotes/domain-core'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
 import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
+import { DetermineSharedVaultOperationOnItem } from '../../SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem'
+import { AddNotificationForUser } from '../../Messaging/AddNotificationForUser/AddNotificationForUser'
+import { RemoveNotificationsForUser } from '../../Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
+import { SharedVaultOperationOnItem } from '../../../SharedVault/SharedVaultOperationOnItem'
 
 describe('UpdateExistingItem', () => {
   let itemRepository: ItemRepositoryInterface
@@ -16,8 +28,21 @@ describe('UpdateExistingItem', () => {
   let domainEventFactory: DomainEventFactoryInterface
   let itemHash1: ItemHash
   let item1: Item
-
-  const createUseCase = () => new UpdateExistingItem(itemRepository, timer, domainEventPublisher, domainEventFactory, 5)
+  let determineSharedVaultOperationOnItem: DetermineSharedVaultOperationOnItem
+  let addNotificationForUser: AddNotificationForUser
+  let removeNotificationsForUser: RemoveNotificationsForUser
+
+  const createUseCase = () =>
+    new UpdateExistingItem(
+      itemRepository,
+      timer,
+      domainEventPublisher,
+      domainEventFactory,
+      5,
+      determineSharedVaultOperationOnItem,
+      addNotificationForUser,
+      removeNotificationsForUser,
+    )
 
   beforeEach(() => {
     const timeHelper = new Timer()
@@ -80,6 +105,25 @@ describe('UpdateExistingItem', () => {
     domainEventFactory.createItemRevisionCreationRequested = jest
       .fn()
       .mockReturnValue({} as jest.Mocked<DomainEventInterface>)
+
+    determineSharedVaultOperationOnItem = {} as jest.Mocked<DetermineSharedVaultOperationOnItem>
+    determineSharedVaultOperationOnItem.execute = jest.fn().mockResolvedValue(
+      Result.ok(
+        SharedVaultOperationOnItem.create({
+          existingItem: item1,
+          incomingItemHash: itemHash1,
+          sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
+          userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        }).getValue(),
+      ),
+    )
+
+    addNotificationForUser = {} as jest.Mocked<AddNotificationForUser>
+    addNotificationForUser.execute = jest.fn().mockReturnValue(Result.ok())
+
+    removeNotificationsForUser = {} as jest.Mocked<RemoveNotificationsForUser>
+    removeNotificationsForUser.execute = jest.fn().mockReturnValue(Result.ok())
   })
 
   it('should update item', async () => {
@@ -349,6 +393,111 @@ describe('UpdateExistingItem', () => {
       expect(result.isFailed()).toBeTruthy()
       mock.mockRestore()
     })
+
+    it('should return error if it fails to determine the shared vault operation on item', async () => {
+      const useCase = createUseCase()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+    })
+
+    it('should return error if it fails to add notification for user', async () => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            existingItem: item1,
+            incomingItemHash: itemHash1,
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          }).getValue(),
+        ),
+      )
+
+      addNotificationForUser.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
+
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+    })
+
+    it('should return error if it fails to create notification payload for user', async () => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            existingItem: item1,
+            incomingItemHash: itemHash1,
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          }).getValue(),
+        ),
+      )
+
+      const mock = jest.spyOn(NotificationPayload, 'create')
+      mock.mockImplementation(() => {
+        return Result.fail('Oops')
+      })
+
+      const useCase = createUseCase()
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+
+      mock.mockRestore()
+    })
+
+    it('should return error if it fails to remove notifications for user', async () => {
+      const useCase = createUseCase()
+
+      removeNotificationsForUser.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
+
+      const itemHash = ItemHash.create({
+        ...itemHash1.props,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+      }).getValue()
+
+      const result = await useCase.execute({
+        existingItem: item1,
+        itemHash,
+        sessionUuid: '00000000-0000-0000-0000-000000000000',
+        performingUserUuid: '00000000-0000-0000-0000-000000000000',
+      })
+      expect(result.isFailed()).toBeTruthy()
+    })
   })
 
   describe('when item is associated to a key system', () => {

+ 69 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts

@@ -1,6 +1,8 @@
 import {
   ContentType,
   Dates,
+  NotificationPayload,
+  NotificationType,
   Result,
   Timestamps,
   UniqueEntityId,
@@ -17,6 +19,10 @@ import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
 import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
 import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
+import { DetermineSharedVaultOperationOnItem } from '../../SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem'
+import { SharedVaultOperationOnItem } from '../../../SharedVault/SharedVaultOperationOnItem'
+import { AddNotificationForUser } from '../../Messaging/AddNotificationForUser/AddNotificationForUser'
+import { RemoveNotificationsForUser } from '../../Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
 
 export class UpdateExistingItem implements UseCaseInterface<Item> {
   constructor(
@@ -25,6 +31,9 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
     private domainEventPublisher: DomainEventPublisherInterface,
     private domainEventFactory: DomainEventFactoryInterface,
     private revisionFrequency: number,
+    private determineSharedVaultOperationOnItem: DetermineSharedVaultOperationOnItem,
+    private addNotificationForUser: AddNotificationForUser,
+    private removeNotificationsForUser: RemoveNotificationsForUser,
   ) {}
 
   async execute(dto: UpdateExistingItemDTO): Promise<Result<Item>> {
@@ -113,6 +122,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
 
     dto.existingItem.props.contentSize = Buffer.byteLength(JSON.stringify(dto.existingItem))
 
+    let sharedVaultOperation: SharedVaultOperationOnItem | null = null
     if (dto.itemHash.representsASharedVaultItem()) {
       const sharedVaultAssociationOrError = SharedVaultAssociation.create(
         {
@@ -138,6 +148,16 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       }
 
       dto.existingItem.props.sharedVaultAssociation = sharedVaultAssociationOrError.getValue()
+
+      const sharedVaultOperationOrError = await this.determineSharedVaultOperationOnItem.execute({
+        existingItem: dto.existingItem,
+        itemHash: dto.itemHash,
+        userUuid: userUuid.value,
+      })
+      if (sharedVaultOperationOrError.isFailed()) {
+        return Result.fail(sharedVaultOperationOrError.getError())
+      }
+      sharedVaultOperation = sharedVaultOperationOrError.getValue()
     }
 
     if (
@@ -199,6 +219,55 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       )
     }
 
+    const notificationsResult = await this.addNotifications(dto.existingItem.uuid, userUuid, sharedVaultOperation)
+    if (notificationsResult.isFailed()) {
+      return Result.fail(notificationsResult.getError())
+    }
+
     return Result.ok(dto.existingItem)
   }
+
+  private async addNotifications(
+    itemUuid: Uuid,
+    userUuid: Uuid,
+    sharedVaultOperation: SharedVaultOperationOnItem | null,
+  ): Promise<Result<void>> {
+    if (
+      sharedVaultOperation &&
+      sharedVaultOperation.props.type === SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault
+    ) {
+      const notificationPayloadOrError = NotificationPayload.create({
+        sharedVaultUuid: sharedVaultOperation.props.sharedVaultUuid,
+        type: NotificationType.create(NotificationType.TYPES.SharedVaultItemRemoved).getValue(),
+        itemUuid: itemUuid,
+        version: '1.0',
+      })
+      if (notificationPayloadOrError.isFailed()) {
+        return Result.fail(notificationPayloadOrError.getError())
+      }
+      const payload = notificationPayloadOrError.getValue()
+
+      const result = await this.addNotificationForUser.execute({
+        payload,
+        type: NotificationType.TYPES.SharedVaultItemRemoved,
+        userUuid: userUuid.value,
+        version: '1.0',
+      })
+      if (result.isFailed()) {
+        return Result.fail(result.getError())
+      }
+    }
+
+    if (sharedVaultOperation && sharedVaultOperation.props.type === SharedVaultOperationOnItem.TYPES.AddToSharedVault) {
+      const result = await this.removeNotificationsForUser.execute({
+        type: NotificationType.TYPES.SharedVaultItemRemoved,
+        userUuid: userUuid.value,
+      })
+      if (result.isFailed()) {
+        return Result.fail(result.getError())
+      }
+    }
+
+    return Result.ok()
+  }
 }

+ 19 - 1
packages/syncing-server/src/Infra/TypeORM/TypeORMNotificationRepository.ts

@@ -1,5 +1,5 @@
 import { Repository } from 'typeorm'
-import { MapperInterface, Uuid } from '@standardnotes/domain-core'
+import { MapperInterface, NotificationType, Uuid } from '@standardnotes/domain-core'
 
 import { NotificationRepositoryInterface } from '../../Domain/Notifications/NotificationRepositoryInterface'
 import { TypeORMNotification } from './TypeORMNotification'
@@ -11,6 +11,24 @@ export class TypeORMNotificationRepository implements NotificationRepositoryInte
     private mapper: MapperInterface<Notification, TypeORMNotification>,
   ) {}
 
+  async findByUserUuidAndType(userUuid: Uuid, type: NotificationType): Promise<Notification[]> {
+    const persistence = await this.ormRepository
+      .createQueryBuilder('notification')
+      .where('notification.user_uuid = :userUuid', {
+        userUuid: userUuid.value,
+      })
+      .andWhere('notification.type = :type', {
+        type: type.value,
+      })
+      .getMany()
+
+    return persistence.map((p) => this.mapper.toDomain(p))
+  }
+
+  async remove(notification: Notification): Promise<void> {
+    await this.ormRepository.remove(this.mapper.toProjection(notification))
+  }
+
   async save(sharedVault: Notification): Promise<void> {
     const persistence = this.mapper.toProjection(sharedVault)