diff --git a/packages/domain-events/src/Domain/Event/ItemDumpedEvent.ts b/packages/domain-events/src/Domain/Event/ItemDumpedEvent.ts new file mode 100644 index 000000000..f8cc16083 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/ItemDumpedEvent.ts @@ -0,0 +1,7 @@ +import { DomainEventInterface } from './DomainEventInterface' +import { ItemDumpedEventPayload } from './ItemDumpedEventPayload' + +export interface ItemDumpedEvent extends DomainEventInterface { + type: 'ITEM_DUMPED' + payload: ItemDumpedEventPayload +} diff --git a/packages/domain-events/src/Domain/Event/ItemDumpedEventPayload.ts b/packages/domain-events/src/Domain/Event/ItemDumpedEventPayload.ts new file mode 100644 index 000000000..7c0164d1d --- /dev/null +++ b/packages/domain-events/src/Domain/Event/ItemDumpedEventPayload.ts @@ -0,0 +1,3 @@ +export interface ItemDumpedEventPayload { + fileDumpPath: string +} diff --git a/packages/domain-events/src/Domain/index.ts b/packages/domain-events/src/Domain/index.ts index 9a1621509..fa76564a2 100644 --- a/packages/domain-events/src/Domain/index.ts +++ b/packages/domain-events/src/Domain/index.ts @@ -46,6 +46,8 @@ export * from './Event/GoogleDriveBackupFailedEvent' export * from './Event/GoogleDriveBackupFailedEventPayload' export * from './Event/InvoiceGeneratedEvent' export * from './Event/InvoiceGeneratedEventPayload' +export * from './Event/ItemDumpedEvent' +export * from './Event/ItemDumpedEventPayload' export * from './Event/ItemRevisionCreationRequestedEvent' export * from './Event/ItemRevisionCreationRequestedEventPayload' export * from './Event/ItemsSyncedEvent' diff --git a/packages/syncing-server/jest.config.js b/packages/syncing-server/jest.config.js index 7ea4a7e4c..fa01338bf 100644 --- a/packages/syncing-server/jest.config.js +++ b/packages/syncing-server/jest.config.js @@ -7,6 +7,6 @@ module.exports = { transform: { ...tsjPreset.transform, }, - coveragePathIgnorePatterns: ['/Bootstrap/', 'HealthCheckController'], + coveragePathIgnorePatterns: ['/Bootstrap/', 'HealthCheckController', '/Infra/'], setupFilesAfterEnv: ['./test-setup.ts'], } diff --git a/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts b/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts index d1d6269a3..8d362b2a8 100644 --- a/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts +++ b/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts @@ -6,6 +6,7 @@ import { EmailArchiveExtensionSyncedEvent, EmailBackupAttachmentCreatedEvent, GoogleDriveBackupFailedEvent, + ItemDumpedEvent, ItemRevisionCreationRequestedEvent, ItemsSyncedEvent, OneDriveBackupFailedEvent, @@ -20,6 +21,23 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' export class DomainEventFactory implements DomainEventFactoryInterface { constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} + createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent { + return { + type: 'ITEM_DUMPED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.SyncingServer, + }, + payload: { + fileDumpPath, + }, + } + } + createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent { return { type: 'ITEM_REVISION_CREATION_REQUESTED', diff --git a/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts index 482ec2c9c..cb723ee0a 100644 --- a/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts @@ -4,6 +4,7 @@ import { EmailArchiveExtensionSyncedEvent, EmailBackupAttachmentCreatedEvent, GoogleDriveBackupFailedEvent, + ItemDumpedEvent, ItemRevisionCreationRequestedEvent, ItemsSyncedEvent, OneDriveBackupFailedEvent, @@ -33,4 +34,5 @@ export interface DomainEventFactoryInterface { }): EmailBackupAttachmentCreatedEvent createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent + createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent } diff --git a/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts b/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts index a1b0ca2ab..89eb67bd3 100644 --- a/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts +++ b/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.spec.ts @@ -1,18 +1,34 @@ import 'reflect-metadata' -import { ItemRevisionCreationRequestedEvent } from '@standardnotes/domain-events' +import { + DomainEventPublisherInterface, + DomainEventService, + ItemRevisionCreationRequestedEvent, +} from '@standardnotes/domain-events' import { Item } from '../Item/Item' import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' import { ItemRevisionCreationRequestedEventHandler } from './ItemRevisionCreationRequestedEventHandler' import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface' +import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' describe('ItemRevisionCreationRequestedEventHandler', () => { let itemRepository: ItemRepositoryInterface let revisionService: RevisionServiceInterface let event: ItemRevisionCreationRequestedEvent let item: Item + let itemBackupService: ItemBackupServiceInterface + let domainEventFactory: DomainEventFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface - const createHandler = () => new ItemRevisionCreationRequestedEventHandler(itemRepository, revisionService) + const createHandler = () => + new ItemRevisionCreationRequestedEventHandler( + itemRepository, + revisionService, + itemBackupService, + domainEventFactory, + domainEventPublisher, + ) beforeEach(() => { item = { @@ -31,12 +47,30 @@ describe('ItemRevisionCreationRequestedEventHandler', () => { event.payload = { itemUuid: '2-3-4', } + event.meta = { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: DomainEventService.SyncingServer, + } + + itemBackupService = {} as jest.Mocked + itemBackupService.dump = jest.fn().mockReturnValue('foo://bar') + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createItemDumpedEvent = jest.fn() + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() }) it('should create a revision for an item', async () => { await createHandler().handle(event) expect(revisionService.createRevision).toHaveBeenCalled() + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled() }) it('should not create a revision for an item that does not exist', async () => { @@ -46,4 +80,13 @@ describe('ItemRevisionCreationRequestedEventHandler', () => { expect(revisionService.createRevision).not.toHaveBeenCalled() }) + + it('should not create a revision for an item if the dump was not created', async () => { + itemBackupService.dump = jest.fn().mockReturnValue('') + + await createHandler().handle(event) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createItemDumpedEvent).not.toHaveBeenCalled() + }) }) diff --git a/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts b/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts index 22f030000..9a17c1022 100644 --- a/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts +++ b/packages/syncing-server/src/Domain/Handler/ItemRevisionCreationRequestedEventHandler.ts @@ -1,7 +1,13 @@ -import { ItemRevisionCreationRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events' +import { + ItemRevisionCreationRequestedEvent, + DomainEventHandlerInterface, + DomainEventPublisherInterface, +} from '@standardnotes/domain-events' import { inject, injectable } from 'inversify' import TYPES from '../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface' @@ -10,6 +16,9 @@ export class ItemRevisionCreationRequestedEventHandler implements DomainEventHan constructor( @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, @inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface, + @inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, ) {} async handle(event: ItemRevisionCreationRequestedEvent): Promise { @@ -18,6 +27,13 @@ export class ItemRevisionCreationRequestedEventHandler implements DomainEventHan return } + const fileDumpPath = await this.itemBackupService.dump(item) + if (fileDumpPath) { + await this.domainEventPublisher.publish( + this.domainEventFactory.createItemDumpedEvent(fileDumpPath, event.meta.correlation.userIdentifier), + ) + } + await this.revisionService.createRevision(item) } } diff --git a/packages/syncing-server/src/Domain/Item/ItemBackupServiceInterface.ts b/packages/syncing-server/src/Domain/Item/ItemBackupServiceInterface.ts index 3da9c1e4d..8604fe5d5 100644 --- a/packages/syncing-server/src/Domain/Item/ItemBackupServiceInterface.ts +++ b/packages/syncing-server/src/Domain/Item/ItemBackupServiceInterface.ts @@ -3,4 +3,5 @@ import { Item } from './Item' export interface ItemBackupServiceInterface { backup(items: Array, authParams: KeyParamsData): Promise + dump(item: Item): Promise } diff --git a/packages/syncing-server/src/Infra/S3/S3ItemBackupService.spec.ts b/packages/syncing-server/src/Infra/S3/S3ItemBackupService.spec.ts deleted file mode 100644 index 86fbb350d..000000000 --- a/packages/syncing-server/src/Infra/S3/S3ItemBackupService.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import 'reflect-metadata' - -import { KeyParamsData } from '@standardnotes/responses' -import { S3 } from 'aws-sdk' -import { Logger } from 'winston' -import { Item } from '../../Domain/Item/Item' -import { S3ItemBackupService } from './S3ItemBackupService' -import { ProjectorInterface } from '../../Projection/ProjectorInterface' -import { ItemProjection } from '../../Projection/ItemProjection' - -describe('S3ItemBackupService', () => { - let s3Client: S3 | undefined - let itemProjector: ProjectorInterface - let s3BackupBucketName = 'backup-bucket' - let logger: Logger - let item: Item - let keyParams: KeyParamsData - - const createService = () => new S3ItemBackupService(s3BackupBucketName, itemProjector, logger, s3Client) - - beforeEach(() => { - s3Client = {} as jest.Mocked - s3Client.upload = jest.fn().mockReturnValue({ - promise: jest.fn().mockReturnValue(Promise.resolve({ Key: 'test' })), - }) - - logger = {} as jest.Mocked - logger.warn = jest.fn() - - item = {} as jest.Mocked - - keyParams = {} as jest.Mocked - - itemProjector = {} as jest.Mocked> - itemProjector.projectFull = jest.fn().mockReturnValue({ foo: 'bar' }) - }) - - it('should upload items to S3 as a backup file', async () => { - await createService().backup([item], keyParams) - - expect((s3Client).upload).toHaveBeenCalledWith({ - Body: '{"items":[{"foo":"bar"}],"auth_params":{}}', - Bucket: 'backup-bucket', - Key: expect.any(String), - }) - }) - - it('should not upload items to S3 if bucket name is not configured', async () => { - s3BackupBucketName = '' - await createService().backup([item], keyParams) - - expect((s3Client).upload).not.toHaveBeenCalled() - }) - - it('should not upload items to S3 if S3 client is not configured', async () => { - s3Client = undefined - expect(await createService().backup([item], keyParams)).toEqual('') - }) -}) diff --git a/packages/syncing-server/src/Infra/S3/S3ItemBackupService.ts b/packages/syncing-server/src/Infra/S3/S3ItemBackupService.ts index 27d0a9a85..dd9d00b64 100644 --- a/packages/syncing-server/src/Infra/S3/S3ItemBackupService.ts +++ b/packages/syncing-server/src/Infra/S3/S3ItemBackupService.ts @@ -3,6 +3,7 @@ import { KeyParamsData } from '@standardnotes/responses' import { S3 } from 'aws-sdk' import { inject, injectable } from 'inversify' import { Logger } from 'winston' + import TYPES from '../../Bootstrap/Types' import { Item } from '../../Domain/Item/Item' import { ItemBackupServiceInterface } from '../../Domain/Item/ItemBackupServiceInterface' @@ -18,6 +19,26 @@ export class S3ItemBackupService implements ItemBackupServiceInterface { @inject(TYPES.S3) private s3Client?: S3, ) {} + async dump(item: Item): Promise { + if (!this.s3BackupBucketName || this.s3Client === undefined) { + this.logger.warn('S3 backup not configured') + + return '' + } + + const uploadResult = await this.s3Client + .upload({ + Bucket: this.s3BackupBucketName, + Key: uuid.v4(), + Body: JSON.stringify({ + item: await this.itemProjector.projectFull(item), + }), + }) + .promise() + + return uploadResult.Key + } + async backup(items: Item[], authParams: KeyParamsData): Promise { if (!this.s3BackupBucketName || this.s3Client === undefined) { this.logger.warn('S3 backup not configured')