feat(revisions): add copying revisions on duplicated items

This commit is contained in:
Karol Sójko
2022-11-28 15:04:33 +01:00
parent 784728cd54
commit 7bb698e442
18 changed files with 311 additions and 4 deletions
@@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { RevisionsCopyRequestedEventPayload } from './RevisionsCopyRequestedEventPayload'
export interface RevisionsCopyRequestedEvent extends DomainEventInterface {
type: 'REVISIONS_COPY_REQUESTED'
payload: RevisionsCopyRequestedEventPayload
}
@@ -0,0 +1,4 @@
export interface RevisionsCopyRequestedEventPayload {
newItemUuid: string
originalItemUuid: string
}
@@ -74,6 +74,8 @@ export * from './Event/RefundRequestedEvent'
export * from './Event/RefundRequestedEventPayload'
export * from './Event/RefundProcessedEvent'
export * from './Event/RefundProcessedEventPayload'
export * from './Event/RevisionsCopyRequestedEvent'
export * from './Event/RevisionsCopyRequestedEventPayload'
export * from './Event/SharedSubscriptionInvitationCanceledEvent'
export * from './Event/SharedSubscriptionInvitationCanceledEventPayload'
export * from './Event/SharedSubscriptionInvitationCreatedEvent'
@@ -39,6 +39,8 @@ import { FSDumpRepository } from '../Infra/FS/FSDumpRepository'
import { GetRevision } from '../Domain/UseCase/GetRevision/GetRevision'
import { DeleteRevision } from '../Domain/UseCase/DeleteRevision/DeleteRevision'
import { AccountDeletionRequestedEventHandler } from '../Domain/Handler/AccountDeletionRequestedEventHandler'
import { RevisionsCopyRequestedEventHandler } from '../Domain/Handler/RevisionsCopyRequestedEventHandler'
import { CopyRevisions } from '../Domain/UseCase/CopyRevisions/CopyRevisions'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -161,6 +163,9 @@ export class ContainerConfigLoader {
container
.bind<DeleteRevision>(TYPES.DeleteRevision)
.toConstantValue(new DeleteRevision(container.get(TYPES.RevisionRepository)))
container
.bind<CopyRevisions>(TYPES.CopyRevisions)
.toConstantValue(new CopyRevisions(container.get(TYPES.RevisionRepository)))
// Controller
container
@@ -185,6 +190,11 @@ export class ContainerConfigLoader {
.toConstantValue(
new AccountDeletionRequestedEventHandler(container.get(TYPES.RevisionRepository), container.get(TYPES.Logger)),
)
container
.bind<RevisionsCopyRequestedEventHandler>(TYPES.RevisionsCopyRequestedEventHandler)
.toConstantValue(
new RevisionsCopyRequestedEventHandler(container.get(TYPES.CopyRevisions), container.get(TYPES.Logger)),
)
// Services
container
@@ -27,11 +27,13 @@ const TYPES = {
GetRevisionsMetada: Symbol.for('GetRevisionsMetada'),
GetRevision: Symbol.for('GetRevision'),
DeleteRevision: Symbol.for('DeleteRevision'),
CopyRevisions: Symbol.for('CopyRevisions'),
// Controller
RevisionsController: Symbol.for('RevisionsController'),
// Handlers
ItemDumpedEventHandler: Symbol.for('ItemDumpedEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
RevisionsCopyRequestedEventHandler: Symbol.for('RevisionsCopyRequestedEventHandler'),
// Services
CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
@@ -0,0 +1,42 @@
import { Result } from '@standardnotes/domain-core'
import { RevisionsCopyRequestedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { CopyRevisions } from '../UseCase/CopyRevisions/CopyRevisions'
import { RevisionsCopyRequestedEventHandler } from './RevisionsCopyRequestedEventHandler'
describe('RevisionsCopyRequestedEventHandler', () => {
let copyRevisions: CopyRevisions
let logger: Logger
let event: RevisionsCopyRequestedEvent
const createHandler = () => new RevisionsCopyRequestedEventHandler(copyRevisions, logger)
beforeEach(() => {
copyRevisions = {} as jest.Mocked<CopyRevisions>
copyRevisions.execute = jest.fn().mockReturnValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
event = {} as jest.Mocked<RevisionsCopyRequestedEvent>
event.payload = {
newItemUuid: '1-2-3',
originalItemUuid: '2-3-4',
}
})
it('should copy revisions', async () => {
await createHandler().handle(event)
expect(copyRevisions.execute).toHaveBeenCalled()
})
it('should indicate if copying revisions fail', async () => {
copyRevisions.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
await createHandler().handle(event)
expect(logger.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,18 @@
import { DomainEventHandlerInterface, RevisionsCopyRequestedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { CopyRevisions } from '../UseCase/CopyRevisions/CopyRevisions'
export class RevisionsCopyRequestedEventHandler implements DomainEventHandlerInterface {
constructor(private copyRevisions: CopyRevisions, private logger: Logger) {}
async handle(event: RevisionsCopyRequestedEvent): Promise<void> {
const result = await this.copyRevisions.execute({
newItemUuid: event.payload.newItemUuid,
originalItemUuid: event.payload.originalItemUuid,
})
if (result.isFailed()) {
this.logger.error(`Could not copy revisions: ${result.getError()}`)
}
}
}
@@ -0,0 +1,16 @@
import { ContentType } from './ContentType'
describe('ContentType', () => {
it('should create a value obejct', () => {
const valueOrError = ContentType.create('Note')
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().value).not.toBeNull()
})
it('should fail to create a value obejct', () => {
const valueOrError = ContentType.create('test')
expect(valueOrError.isFailed()).toBeTruthy()
})
})
@@ -0,0 +1,22 @@
import { Timestamps, Uuid } from '@standardnotes/domain-core'
import { ContentType } from './ContentType'
import { Revision } from './Revision'
describe('Revision', () => {
it('should create an entity', () => {
const entityOrError = Revision.create({
itemUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
userUuid: Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue(),
content: 'test',
contentType: ContentType.create('Note').getValue(),
itemsKeyId: 'test',
encItemKey: 'test',
authHash: 'test',
creationDate: new Date(1),
timestamps: Timestamps.create(new Date(1), new Date(2)).getValue(),
})
expect(entityOrError.isFailed()).toBeFalsy()
expect(entityOrError.getValue().id).not.toBeNull()
})
})
@@ -7,6 +7,7 @@ export interface RevisionRepositoryInterface {
removeByUserUuid(userUuid: Uuid): Promise<void>
removeOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<void>
findOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<Revision | null>
findByItemUuid(itemUuid: Uuid): Promise<Array<Revision>>
findMetadataByItemId(itemUuid: Uuid, userUuid: Uuid): Promise<Array<RevisionMetadata>>
save(revision: Revision): Promise<Revision>
}
@@ -0,0 +1,59 @@
import { Result } from '@standardnotes/domain-core'
import { Revision } from '../../Revision/Revision'
import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface'
import { CopyRevisions } from './CopyRevisions'
describe('CopyRevisions', () => {
let revisionRepository: RevisionRepositoryInterface
const createUseCase = () => new CopyRevisions(revisionRepository)
beforeEach(() => {
revisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
revisionRepository.findByItemUuid = jest.fn().mockReturnValue([{} as jest.Mocked<Revision>])
revisionRepository.save = jest.fn()
})
it('should not copy revisions to new item if revision creation fails', async () => {
const revisionMock = jest.spyOn(Revision, 'create')
revisionMock.mockImplementation(() => Result.fail('Oops'))
const result = await createUseCase().execute({
originalItemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
newItemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeTruthy()
revisionMock.mockRestore()
})
it('should copy revisions to new item', async () => {
const result = await createUseCase().execute({
originalItemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
newItemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeFalsy()
expect(revisionRepository.save).toHaveBeenCalled()
expect(result.getValue()).toEqual('Revisions copied')
})
it('should not copy revisions for an invalid item uuid', async () => {
const result = await createUseCase().execute({
originalItemUuid: '1-2-3',
newItemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeTruthy()
})
it('should not delete revision for a an invalid new item uuid', async () => {
const result = await createUseCase().execute({
newItemUuid: '1-2-3',
originalItemUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeTruthy()
})
})
@@ -0,0 +1,43 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Revision } from '../../Revision/Revision'
import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface'
import { CopyRevisionsDTO } from './CopyRevisionsDTO'
export class CopyRevisions implements UseCaseInterface<string> {
constructor(private revisionRepository: RevisionRepositoryInterface) {}
async execute(dto: CopyRevisionsDTO): Promise<Result<string>> {
const orignalItemUuidOrError = Uuid.create(dto.originalItemUuid)
if (orignalItemUuidOrError.isFailed()) {
return Result.fail<string>(`Could not copy revisions: ${orignalItemUuidOrError.getError()}`)
}
const originalItemUuid = orignalItemUuidOrError.getValue()
const newItemUuidOrError = Uuid.create(dto.newItemUuid)
if (newItemUuidOrError.isFailed()) {
return Result.fail<string>(`Could not copy revisions: ${newItemUuidOrError.getError()}`)
}
const newItemUuid = newItemUuidOrError.getValue()
const revisions = await this.revisionRepository.findByItemUuid(originalItemUuid)
for (const existingRevision of revisions) {
const revisionCopyOrError = Revision.create({
...existingRevision.props,
itemUuid: newItemUuid,
})
if (revisionCopyOrError.isFailed()) {
return Result.fail<string>(`Could not create revision copy: ${revisionCopyOrError.getError()}`)
}
const revisionCopy = revisionCopyOrError.getValue()
await this.revisionRepository.save(revisionCopy)
}
return Result.ok<string>('Revisions copied')
}
}
@@ -0,0 +1,4 @@
export interface CopyRevisionsDTO {
originalItemUuid: string
newItemUuid: string
}
@@ -1,7 +1,7 @@
import { MapperInterface, Uuid } from '@standardnotes/domain-core'
import { Repository } from 'typeorm'
import { Revision } from '../../Domain/Revision/Revision'
import { Revision } from '../../Domain/Revision/Revision'
import { RevisionMetadata } from '../../Domain/Revision/RevisionMetadata'
import { RevisionRepositoryInterface } from '../../Domain/Revision/RevisionRepositoryInterface'
import { TypeORMRevision } from '../TypeORM/TypeORMRevision'
@@ -13,6 +13,20 @@ export class MySQLRevisionRepository implements RevisionRepositoryInterface {
private revisionMapper: MapperInterface<Revision, TypeORMRevision>,
) {}
async findByItemUuid(itemUuid: Uuid): Promise<Revision[]> {
const typeormRevisions = await this.ormRepository
.createQueryBuilder()
.where('item_uuid = :itemUuid', { itemUuid })
.getMany()
const revisions = []
for (const revision of typeormRevisions) {
revisions.push(this.revisionMapper.toDomain(revision))
}
return revisions
}
async removeByUserUuid(userUuid: Uuid): Promise<void> {
await this.ormRepository
.createQueryBuilder()
@@ -10,6 +10,7 @@ import {
ItemRevisionCreationRequestedEvent,
ItemsSyncedEvent,
OneDriveBackupFailedEvent,
RevisionsCopyRequestedEvent,
UserContentSizeRecalculationRequestedEvent,
} from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
@@ -21,6 +22,27 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
export class DomainEventFactory implements DomainEventFactoryInterface {
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
createRevisionsCopyRequestedEvent(
userUuid: string,
dto: {
originalItemUuid: string
newItemUuid: string
},
): RevisionsCopyRequestedEvent {
return {
type: 'REVISIONS_COPY_REQUESTED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: userUuid,
userIdentifierType: 'uuid',
},
origin: DomainEventService.SyncingServer,
},
payload: dto,
}
}
createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent {
return {
type: 'ITEM_DUMPED',
@@ -8,6 +8,7 @@ import {
ItemRevisionCreationRequestedEvent,
ItemsSyncedEvent,
OneDriveBackupFailedEvent,
RevisionsCopyRequestedEvent,
UserContentSizeRecalculationRequestedEvent,
} from '@standardnotes/domain-events'
@@ -35,4 +36,8 @@ export interface DomainEventFactoryInterface {
createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent
createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent
createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent
createRevisionsCopyRequestedEvent(
userUuid: string,
dto: { originalItemUuid: string; newItemUuid: string },
): RevisionsCopyRequestedEvent
}
@@ -1,11 +1,16 @@
import 'reflect-metadata'
import { DuplicateItemSyncedEvent } from '@standardnotes/domain-events'
import {
DomainEventPublisherInterface,
DuplicateItemSyncedEvent,
RevisionsCopyRequestedEvent,
} from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { Item } from '../Item/Item'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { DuplicateItemSyncedEventHandler } from './DuplicateItemSyncedEventHandler'
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
describe('DuplicateItemSyncedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
@@ -14,8 +19,17 @@ describe('DuplicateItemSyncedEventHandler', () => {
let duplicateItem: Item
let originalItem: Item
let event: DuplicateItemSyncedEvent
let domainEventFactory: DomainEventFactoryInterface
let domainEventPublisher: DomainEventPublisherInterface
const createHandler = () => new DuplicateItemSyncedEventHandler(itemRepository, revisionService, logger)
const createHandler = () =>
new DuplicateItemSyncedEventHandler(
itemRepository,
revisionService,
domainEventFactory,
domainEventPublisher,
logger,
)
beforeEach(() => {
originalItem = {
@@ -45,6 +59,14 @@ describe('DuplicateItemSyncedEventHandler', () => {
userUuid: '1-2-3',
itemUuid: '2-3-4',
}
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createRevisionsCopyRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<RevisionsCopyRequestedEvent>)
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
})
it('should copy revisions from original item to the duplicate item', async () => {
@@ -1,7 +1,12 @@
import { DomainEventHandlerInterface, DuplicateItemSyncedEvent } from '@standardnotes/domain-events'
import {
DomainEventHandlerInterface,
DomainEventPublisherInterface,
DuplicateItemSyncedEvent,
} from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface'
@@ -10,6 +15,8 @@ export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterf
constructor(
@inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface,
@inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface,
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
@@ -35,6 +42,13 @@ export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterf
if (existingOriginalItem !== null) {
await this.revisionService.copyRevisions(existingOriginalItem.uuid, item.uuid)
await this.domainEventPublisher.publish(
this.domainEventFactory.createRevisionsCopyRequestedEvent(event.payload.userUuid, {
originalItemUuid: existingOriginalItem.uuid,
newItemUuid: item.uuid,
}),
)
}
}
}