diff --git a/packages/syncing-server/src/Bootstrap/Container.ts b/packages/syncing-server/src/Bootstrap/Container.ts index 5151c33f5..b7e1efb3e 100644 --- a/packages/syncing-server/src/Bootstrap/Container.ts +++ b/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(TYPES.Sync_AddNotificationForUser) + .toConstantValue( + new AddNotificationForUser(container.get(TYPES.Sync_NotificationRepository), container.get(TYPES.Sync_Timer)), + ) + container + .bind(TYPES.Sync_RemoveNotificationsForUser) + .toConstantValue(new RemoveNotificationsForUser(container.get(TYPES.Sync_NotificationRepository))) container .bind(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(TYPES.Sync_AddNotificationForUser) - .toConstantValue( - new AddNotificationForUser(container.get(TYPES.Sync_NotificationRepository), container.get(TYPES.Sync_Timer)), - ) container .bind(TYPES.Sync_RemoveSharedVaultUser) .toConstantValue( diff --git a/packages/syncing-server/src/Bootstrap/Types.ts b/packages/syncing-server/src/Bootstrap/Types.ts index 7de67cdaa..738bccf32 100644 --- a/packages/syncing-server/src/Bootstrap/Types.ts +++ b/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'), diff --git a/packages/syncing-server/src/Domain/Notifications/NotificationRepositoryInterface.ts b/packages/syncing-server/src/Domain/Notifications/NotificationRepositoryInterface.ts index 95b3b1848..606a3aa2a 100644 --- a/packages/syncing-server/src/Domain/Notifications/NotificationRepositoryInterface.ts +++ b/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 findByUserUuidUpdatedAfter(userUuid: Uuid, lastSyncTime: number): Promise findByUserUuid(userUuid: Uuid): Promise + findByUserUuidAndType(userUuid: Uuid, type: NotificationType): Promise + remove(notification: Notification): Promise } diff --git a/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser.spec.ts b/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser.spec.ts new file mode 100644 index 000000000..4df150be4 --- /dev/null +++ b/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 + 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() + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser.ts b/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser.ts new file mode 100644 index 000000000..efac0ced6 --- /dev/null +++ b/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 { + constructor(private notificationRepository: NotificationRepositoryInterface) {} + + async execute(dto: RemoveNotificationsForUserDTO): Promise> { + 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() + } +} diff --git a/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUserDTO.ts b/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUserDTO.ts new file mode 100644 index 000000000..ac9b6c572 --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/Messaging/RemoveNotificationsForUser/RemoveNotificationsForUserDTO.ts @@ -0,0 +1,4 @@ +export interface RemoveNotificationsForUserDTO { + type: string + userUuid: string +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts index 7963460d3..9c899c406 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts +++ b/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 + let determineSharedVaultOperationOnItem: DetermineSharedVaultOperationOnItem + let addNotificationForUser: AddNotificationForUser + let removeNotificationsForUser: RemoveNotificationsForUser - const createUseCase = () => new UpdateExistingItem(itemRepository, timer, domainEventPublisher, domainEventFactory, 5) + 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) + + determineSharedVaultOperationOnItem = {} as jest.Mocked + 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.execute = jest.fn().mockReturnValue(Result.ok()) + + removeNotificationsForUser = {} as jest.Mocked + 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', () => { diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts index 9d0b966ea..86abaac19 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts +++ b/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 { constructor( @@ -25,6 +31,9 @@ export class UpdateExistingItem implements UseCaseInterface { private domainEventPublisher: DomainEventPublisherInterface, private domainEventFactory: DomainEventFactoryInterface, private revisionFrequency: number, + private determineSharedVaultOperationOnItem: DetermineSharedVaultOperationOnItem, + private addNotificationForUser: AddNotificationForUser, + private removeNotificationsForUser: RemoveNotificationsForUser, ) {} async execute(dto: UpdateExistingItemDTO): Promise> { @@ -113,6 +122,7 @@ export class UpdateExistingItem implements UseCaseInterface { 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 { } 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 { ) } + 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> { + 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() + } } diff --git a/packages/syncing-server/src/Infra/TypeORM/TypeORMNotificationRepository.ts b/packages/syncing-server/src/Infra/TypeORM/TypeORMNotificationRepository.ts index 517e9cf87..44e8fc197 100644 --- a/packages/syncing-server/src/Infra/TypeORM/TypeORMNotificationRepository.ts +++ b/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, ) {} + async findByUserUuidAndType(userUuid: Uuid, type: NotificationType): Promise { + 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 { + await this.ormRepository.remove(this.mapper.toProjection(notification)) + } + async save(sharedVault: Notification): Promise { const persistence = this.mapper.toProjection(sharedVault)