Compare commits

...

4 Commits

Author SHA1 Message Date
standardci dec2cc2aaf chore(release): publish new version
- @standardnotes/revisions-server@1.7.0
2022-11-28 11:44:16 +00:00
Karol Sójko b4e8971ad2 feat(revisions): add handling account deletion requests 2022-11-28 12:42:25 +01:00
standardci 84e436265e chore(release): publish new version
- @standardnotes/revisions-server@1.6.0
2022-11-28 11:31:09 +00:00
Karol Sójko ac8a69f8d4 feat(revisions): add deleting revisions 2022-11-28 12:28:38 +01:00
16 changed files with 263 additions and 8 deletions
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.7.0](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.6.0...@standardnotes/revisions-server@1.7.0) (2022-11-28)
### Features
* **revisions:** add handling account deletion requests ([b4e8971](https://github.com/standardnotes/server/commit/b4e8971ad27fd198239f6eb976b8286575373ed6))
# [1.6.0](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.5.0...@standardnotes/revisions-server@1.6.0) (2022-11-28)
### Features
* **revisions:** add deleting revisions ([ac8a69f](https://github.com/standardnotes/server/commit/ac8a69f8d428e3cf8e4df5269db3cb31d9b118d5))
# [1.5.0](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.4.8...@standardnotes/revisions-server@1.5.0) (2022-11-28)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/revisions-server",
"version": "1.5.0",
"version": "1.7.0",
"engines": {
"node": ">=18.0.0 <19.0.0"
},
@@ -37,6 +37,8 @@ import { DumpRepositoryInterface } from '../Domain/Dump/DumpRepositoryInterface'
import { S3DumpRepository } from '../Infra/S3/S3ItemDumpRepository'
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'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -156,6 +158,9 @@ export class ContainerConfigLoader {
container
.bind<GetRevision>(TYPES.GetRevision)
.toConstantValue(new GetRevision(container.get(TYPES.RevisionRepository)))
container
.bind<DeleteRevision>(TYPES.DeleteRevision)
.toConstantValue(new DeleteRevision(container.get(TYPES.RevisionRepository)))
// Controller
container
@@ -164,6 +169,7 @@ export class ContainerConfigLoader {
new RevisionsController(
container.get(TYPES.GetRevisionsMetada),
container.get(TYPES.GetRevision),
container.get(TYPES.DeleteRevision),
container.get(TYPES.Logger),
),
)
@@ -174,6 +180,11 @@ export class ContainerConfigLoader {
.toConstantValue(
new ItemDumpedEventHandler(container.get(TYPES.DumpRepository), container.get(TYPES.RevisionRepository)),
)
container
.bind<AccountDeletionRequestedEventHandler>(TYPES.AccountDeletionRequestedEventHandler)
.toConstantValue(
new AccountDeletionRequestedEventHandler(container.get(TYPES.RevisionRepository), container.get(TYPES.Logger)),
)
// Services
container
@@ -187,6 +198,7 @@ export class ContainerConfigLoader {
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['ITEM_DUMPED', container.get(TYPES.ItemDumpedEventHandler)],
['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.AccountDeletionRequestedEventHandler)],
])
if (env.get('SQS_QUEUE_URL', true)) {
@@ -26,10 +26,12 @@ const TYPES = {
// use cases
GetRevisionsMetada: Symbol.for('GetRevisionsMetada'),
GetRevision: Symbol.for('GetRevision'),
DeleteRevision: Symbol.for('DeleteRevision'),
// Controller
RevisionsController: Symbol.for('RevisionsController'),
// Handlers
ItemDumpedEventHandler: Symbol.for('ItemDumpedEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
// Services
CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
@@ -1,6 +1,7 @@
import { Result } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { DeleteRevision } from '../Domain/UseCase/DeleteRevision/DeleteRevision'
import { GetRevision } from '../Domain/UseCase/GetRevision/GetRevision'
import { GetRevisionsMetada } from '../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada'
@@ -9,9 +10,10 @@ import { RevisionsController } from './RevisionsController'
describe('RevisionsController', () => {
let getRevisionsMetadata: GetRevisionsMetada
let getRevision: GetRevision
let deleteRevision: DeleteRevision
let logger: Logger
const createController = () => new RevisionsController(getRevisionsMetadata, getRevision, logger)
const createController = () => new RevisionsController(getRevisionsMetadata, getRevision, deleteRevision, logger)
beforeEach(() => {
getRevisionsMetadata = {} as jest.Mocked<GetRevisionsMetada>
@@ -20,6 +22,9 @@ describe('RevisionsController', () => {
getRevision = {} as jest.Mocked<GetRevision>
getRevision.execute = jest.fn().mockReturnValue(Result.ok())
deleteRevision = {} as jest.Mocked<DeleteRevision>
deleteRevision.execute = jest.fn().mockReturnValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.warn = jest.fn()
})
@@ -49,4 +54,17 @@ describe('RevisionsController', () => {
expect(response.status).toEqual(400)
})
it('should delete revision', async () => {
const response = await createController().deleteRevision({ revisionUuid: '1-2-3', userUuid: '1-2-3' })
expect(response.status).toEqual(200)
})
it('should indicate failure to delete revision', async () => {
deleteRevision.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const response = await createController().deleteRevision({ revisionUuid: '1-2-3', userUuid: '1-2-3' })
expect(response.status).toEqual(400)
})
})
@@ -5,11 +5,14 @@ import { GetRevisionsMetada } from '../Domain/UseCase/GetRevisionsMetada/GetRevi
import { GetRevisionsMetadataRequestParams } from '../Infra/Http/GetRevisionsMetadataRequestParams'
import { GetRevisionRequestParams } from '../Infra/Http/GetRevisionRequestParams'
import { GetRevision } from '../Domain/UseCase/GetRevision/GetRevision'
import { DeleteRevision } from '../Domain/UseCase/DeleteRevision/DeleteRevision'
import { DeleteRevisionRequestParams } from '../Infra/Http/DeleteRevisionRequestParams'
export class RevisionsController {
constructor(
private getRevisionsMetadata: GetRevisionsMetada,
private doGetRevision: GetRevision,
private doDeleteRevision: DeleteRevision,
private logger: Logger,
) {}
@@ -39,13 +42,13 @@ export class RevisionsController {
}
async getRevision(params: GetRevisionRequestParams): Promise<HttpResponse> {
const revisionMetadataOrError = await this.doGetRevision.execute({
const revisionOrError = await this.doGetRevision.execute({
revisionUuid: params.revisionUuid,
userUuid: params.userUuid,
})
if (revisionMetadataOrError.isFailed()) {
this.logger.warn(revisionMetadataOrError.getError())
if (revisionOrError.isFailed()) {
this.logger.warn(revisionOrError.getError())
return {
status: HttpStatusCode.BadRequest,
@@ -59,7 +62,32 @@ export class RevisionsController {
return {
status: HttpStatusCode.Success,
data: { revision: revisionMetadataOrError.getValue() },
data: { revision: revisionOrError.getValue() },
}
}
async deleteRevision(params: DeleteRevisionRequestParams): Promise<HttpResponse> {
const revisionOrError = await this.doDeleteRevision.execute({
revisionUuid: params.revisionUuid,
userUuid: params.userUuid,
})
if (revisionOrError.isFailed()) {
this.logger.warn(revisionOrError.getError())
return {
status: HttpStatusCode.BadRequest,
data: {
error: {
message: 'Could not delete revision.',
},
},
}
}
return {
status: HttpStatusCode.Success,
data: { message: revisionOrError.getValue() },
}
}
}
@@ -0,0 +1,45 @@
import 'reflect-metadata'
import { AccountDeletionRequestedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler'
import { RevisionRepositoryInterface } from '../Revision/RevisionRepositoryInterface'
describe('AccountDeletionRequestedEventHandler', () => {
let revisionRepository: RevisionRepositoryInterface
let logger: Logger
let event: AccountDeletionRequestedEvent
const createHandler = () => new AccountDeletionRequestedEventHandler(revisionRepository, logger)
beforeEach(() => {
revisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
revisionRepository.removeByUserUuid = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.warn = jest.fn()
event = {} as jest.Mocked<AccountDeletionRequestedEvent>
event.createdAt = new Date(1)
event.payload = {
userUuid: '2-3-4',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: '1-2-3',
}
})
it('should remove all revisions for a user', async () => {
event.payload.userUuid = '84c0f8e8-544a-4c7e-9adf-26209303bc1d'
await createHandler().handle(event)
expect(revisionRepository.removeByUserUuid).toHaveBeenCalled()
})
it('should not remove all revisions for an invalid user uuid', async () => {
await createHandler().handle(event)
expect(revisionRepository.removeByUserUuid).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,23 @@
import { Uuid } from '@standardnotes/domain-core'
import { AccountDeletionRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { RevisionRepositoryInterface } from '../Revision/RevisionRepositoryInterface'
export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface {
constructor(private revisionRepository: RevisionRepositoryInterface, private logger: Logger) {}
async handle(event: AccountDeletionRequestedEvent): Promise<void> {
const userUuidOrError = Uuid.create(event.payload.userUuid)
if (userUuidOrError.isFailed()) {
this.logger.warn(`Failed account cleanup: ${userUuidOrError.getError()}`)
return
}
const userUuid = userUuidOrError.getValue()
await this.revisionRepository.removeByUserUuid(userUuid)
this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`)
}
}
@@ -4,6 +4,8 @@ import { Revision } from './Revision'
import { RevisionMetadata } from './RevisionMetadata'
export interface RevisionRepositoryInterface {
removeByUserUuid(userUuid: Uuid): Promise<void>
removeOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<void>
findOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<Revision | null>
findMetadataByItemId(itemUuid: Uuid, userUuid: Uuid): Promise<Array<RevisionMetadata>>
save(revision: Revision): Promise<Revision>
@@ -0,0 +1,41 @@
import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface'
import { DeleteRevision } from './DeleteRevision'
describe('DeleteRevision', () => {
let revisionRepository: RevisionRepositoryInterface
const createUseCase = () => new DeleteRevision(revisionRepository)
beforeEach(() => {
revisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
revisionRepository.removeOneByUuid = jest.fn()
})
it('should delete revision', async () => {
const result = await createUseCase().execute({
revisionUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
userUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual('Revision removed')
})
it('should not delete revision for an invalid item uuid', async () => {
const result = await createUseCase().execute({
revisionUuid: '1-2-3',
userUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeTruthy()
})
it('should not delete revision for a an invalid user uuid', async () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',
revisionUuid: '84c0f8e8-544a-4c7e-9adf-26209303bc1d',
})
expect(result.isFailed()).toBeTruthy()
})
})
@@ -0,0 +1,26 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface'
import { DeleteRevisionDTO } from './DeleteRevisionDTO'
export class DeleteRevision implements UseCaseInterface<string> {
constructor(private revisionRepository: RevisionRepositoryInterface) {}
async execute(dto: DeleteRevisionDTO): Promise<Result<string>> {
const revisionUuidOrError = Uuid.create(dto.revisionUuid)
if (revisionUuidOrError.isFailed()) {
return Result.fail<string>(`Could not delete revision: ${revisionUuidOrError.getError()}`)
}
const revisionUuid = revisionUuidOrError.getValue()
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail<string>(`Could not delete revision: ${userUuidOrError.getError()}`)
}
const userUuid = userUuidOrError.getValue()
await this.revisionRepository.removeOneByUuid(revisionUuid, userUuid)
return Result.ok<string>('Revision removed')
}
}
@@ -0,0 +1,4 @@
export interface DeleteRevisionDTO {
userUuid: string
revisionUuid: string
}
@@ -16,7 +16,7 @@ export class GetRevision implements UseCaseInterface<Revision> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail<Revision>(`Could not get revisions: ${userUuidOrError.getError()}`)
return Result.fail<Revision>(`Could not get revision: ${userUuidOrError.getError()}`)
}
const userUuid = userUuidOrError.getValue()
@@ -0,0 +1,4 @@
export interface DeleteRevisionRequestParams {
revisionUuid: string
userUuid: string
}
@@ -1,5 +1,5 @@
import { Request, Response } from 'express'
import { BaseHttpController, controller, httpGet, results } from 'inversify-express-utils'
import { BaseHttpController, controller, httpDelete, httpGet, results } from 'inversify-express-utils'
import { inject } from 'inversify'
import TYPES from '../../Bootstrap/Types'
@@ -20,4 +20,24 @@ export class InversifyExpressRevisionsController extends BaseHttpController {
return this.json(result.data, result.status)
}
@httpGet('/:uuid')
public async getRevision(req: Request, response: Response): Promise<results.JsonResult> {
const result = await this.revisionsController.getRevision({
revisionUuid: req.params.uuid,
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
}
@httpDelete('/:uuid')
public async deleteRevision(req: Request, response: Response): Promise<results.JsonResult> {
const result = await this.revisionsController.deleteRevision({
revisionUuid: req.params.uuid,
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
}
}
@@ -13,6 +13,24 @@ export class MySQLRevisionRepository implements RevisionRepositoryInterface {
private revisionMapper: MapperInterface<Revision, TypeORMRevision>,
) {}
async removeByUserUuid(userUuid: Uuid): Promise<void> {
await this.ormRepository
.createQueryBuilder()
.delete()
.from('revisions')
.where('user_uuid = :userUuid', { userUuid })
.execute()
}
async removeOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<void> {
await this.ormRepository
.createQueryBuilder()
.delete()
.from('revisions')
.where('uuid = :revisionUuid AND user_uuid = :userUuid', { userUuid, revisionUuid })
.execute()
}
async findOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<Revision | null> {
const typeormRevision = await this.ormRepository
.createQueryBuilder()