mirror of
https://github.com/standardnotes/server
synced 2026-07-14 00:01:54 -04:00
feat(revisions): add copying revisions on duplicated items
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user