浏览代码

fix: stop revisions propagation (#1008)

Karol Sójko 1 年之前
父节点
当前提交
7962b245b5
共有 27 个文件被更改,包括 357 次插入0 次删除
  1. 71 0
      packages/auth/bin/revisions_cleanup.ts
  2. 11 0
      packages/auth/docker/entrypoint-revisions-cleanup.js
  3. 4 0
      packages/auth/docker/entrypoint.sh
  4. 16 0
      packages/auth/src/Domain/Event/DomainEventFactory.ts
  5. 2 0
      packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts
  6. 3 0
      packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts
  7. 22 0
      packages/auth/src/Infra/TypeORM/TypeORMUserSubscriptionRepository.ts
  8. 7 0
      packages/domain-events/src/Domain/Event/ItemDeletedEvent.ts
  9. 4 0
      packages/domain-events/src/Domain/Event/ItemDeletedEventPayload.ts
  10. 7 0
      packages/domain-events/src/Domain/Event/RevisionsCleanupRequestedEvent.ts
  11. 3 0
      packages/domain-events/src/Domain/Event/RevisionsCleanupRequestedEventPayload.ts
  12. 4 0
      packages/domain-events/src/Domain/index.ts
  13. 16 0
      packages/revisions/src/Bootstrap/Container.ts
  14. 2 0
      packages/revisions/src/Bootstrap/Types.ts
  15. 21 0
      packages/revisions/src/Domain/Handler/ItemDeletedEventHandler.ts
  16. 1 0
      packages/revisions/src/Domain/Revision/RevisionRepositoryInterface.ts
  17. 32 0
      packages/revisions/src/Domain/UseCase/DeleteRevisions/DeleteRevisions.spec.ts
  18. 19 0
      packages/revisions/src/Domain/UseCase/DeleteRevisions/DeleteRevisions.ts
  19. 3 0
      packages/revisions/src/Domain/UseCase/DeleteRevisions/DeleteRevisionsDTO.ts
  20. 9 0
      packages/revisions/src/Infra/TypeORM/SQL/SQLRevisionRepository.ts
  21. 12 0
      packages/syncing-server/src/Bootstrap/Container.ts
  22. 1 0
      packages/syncing-server/src/Bootstrap/Types.ts
  23. 16 0
      packages/syncing-server/src/Domain/Event/DomainEventFactory.ts
  24. 2 0
      packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts
  25. 56 0
      packages/syncing-server/src/Domain/Handler/RevisionsCleanupRequestedEventHandler.ts
  26. 1 0
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts
  27. 12 0
      packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts

+ 71 - 0
packages/auth/bin/revisions_cleanup.ts

@@ -0,0 +1,71 @@
+import 'reflect-metadata'
+
+import { Logger } from 'winston'
+
+import { ContainerConfigLoader } from '../src/Bootstrap/Container'
+import TYPES from '../src/Bootstrap/Types'
+import { Env } from '../src/Bootstrap/Env'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
+import { UserSubscriptionRepositoryInterface } from '../src/Domain/Subscription/UserSubscriptionRepositoryInterface'
+import { SubscriptionPlanName } from '@standardnotes/domain-core'
+
+const requestCleanup = async (
+  userSubscriptionRepository: UserSubscriptionRepositoryInterface,
+  domainEventFactory: DomainEventFactoryInterface,
+  domainEventPublisher: DomainEventPublisherInterface,
+): Promise<void> => {
+  const proSubscriptionPlan = SubscriptionPlanName.create(SubscriptionPlanName.NAMES.ProPlan).getValue()
+  const plusSubscriptionPlan = SubscriptionPlanName.create(SubscriptionPlanName.NAMES.PlusPlan).getValue()
+
+  const totalSubscriptions = await userSubscriptionRepository.countByPlanName([
+    proSubscriptionPlan,
+    plusSubscriptionPlan,
+  ])
+
+  const limitPerPage = 100
+  const numberOfPages = Math.ceil(totalSubscriptions / limitPerPage)
+  for (let i = 0; i < numberOfPages; i++) {
+    const subscriptions = await userSubscriptionRepository.findByPlanName(
+      [proSubscriptionPlan, plusSubscriptionPlan],
+      i * limitPerPage,
+      limitPerPage,
+    )
+
+    for (const subscription of subscriptions) {
+      await domainEventPublisher.publish(
+        domainEventFactory.createRevisionsCleanupRequestedEvent({
+          userUuid: subscription.userUuid,
+        }),
+      )
+    }
+  }
+}
+
+const container = new ContainerConfigLoader('worker')
+void container.load().then((container) => {
+  const env: Env = new Env()
+  env.load()
+
+  const logger: Logger = container.get(TYPES.Auth_Logger)
+
+  logger.info('Starting revisions cleanup triggering...')
+
+  const domainEventFactory = container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory)
+  const domainEventPublisher = container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher)
+  const userSubscriptionRepository = container.get<UserSubscriptionRepositoryInterface>(
+    TYPES.Auth_UserSubscriptionRepository,
+  )
+
+  Promise.resolve(requestCleanup(userSubscriptionRepository, domainEventFactory, domainEventPublisher))
+    .then(() => {
+      logger.info('Finished revisions cleanup triggering')
+
+      process.exit(0)
+    })
+    .catch((error) => {
+      logger.error(`Failed to trigger revisions cleanup: ${(error as Error).message}`)
+
+      process.exit(1)
+    })
+})

+ 11 - 0
packages/auth/docker/entrypoint-revisions-cleanup.js

@@ -0,0 +1,11 @@
+'use strict'
+
+const path = require('path')
+
+const pnp = require(path.normalize(path.resolve(__dirname, '../../..', '.pnp.cjs'))).setup()
+
+const index = require(path.normalize(path.resolve(__dirname, '../dist/bin/revisions_cleanup.js')))
+
+Object.defineProperty(exports, '__esModule', { value: true })
+
+exports.default = index

+ 4 - 0
packages/auth/docker/entrypoint.sh

@@ -44,6 +44,10 @@ case "$COMMAND" in
     exec node docker/entrypoint-delete-accounts.js $FILE_NAME $MODE
     ;;
 
+  'revisions-cleanup' )
+    exec node docker/entrypoint-revisions-cleanup.js
+    ;;
+
    * )
     echo "[Docker] Unknown command"
     ;;

+ 16 - 0
packages/auth/src/Domain/Event/DomainEventFactory.ts

@@ -22,6 +22,7 @@ import {
   SessionRefreshedEvent,
   AccountDeletionVerificationRequestedEvent,
   FileQuotaRecalculationRequestedEvent,
+  RevisionsCleanupRequestedEvent,
 } from '@standardnotes/domain-events'
 import { Predicate, PredicateVerificationResult } from '@standardnotes/predicates'
 import { TimerInterface } from '@standardnotes/time'
@@ -35,6 +36,21 @@ import { KeyParamsData } from '@standardnotes/responses'
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(@inject(TYPES.Auth_Timer) private timer: TimerInterface) {}
 
+  createRevisionsCleanupRequestedEvent(dto: { userUuid: string }): RevisionsCleanupRequestedEvent {
+    return {
+      type: 'REVISIONS_CLEANUP_REQUESTED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.Auth,
+      },
+      payload: dto,
+    }
+  }
+
   createFileQuotaRecalculationRequestedEvent(dto: { userUuid: string }): FileQuotaRecalculationRequestedEvent {
     return {
       type: 'FILE_QUOTA_RECALCULATION_REQUESTED',

+ 2 - 0
packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts

@@ -20,11 +20,13 @@ import {
   SessionRefreshedEvent,
   AccountDeletionVerificationRequestedEvent,
   FileQuotaRecalculationRequestedEvent,
+  RevisionsCleanupRequestedEvent,
 } from '@standardnotes/domain-events'
 import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType'
 import { KeyParamsData } from '@standardnotes/responses'
 
 export interface DomainEventFactoryInterface {
+  createRevisionsCleanupRequestedEvent(dto: { userUuid: string }): RevisionsCleanupRequestedEvent
   createFileQuotaRecalculationRequestedEvent(dto: { userUuid: string }): FileQuotaRecalculationRequestedEvent
   createWebSocketMessageRequestedEvent(dto: { userUuid: string; message: JSONString }): WebSocketMessageRequestedEvent
   createEmailRequestedEvent(dto: {

+ 3 - 0
packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts

@@ -1,3 +1,4 @@
+import { SubscriptionPlanName } from '@standardnotes/domain-core'
 import { UserSubscription } from './UserSubscription'
 import { UserSubscriptionType } from './UserSubscriptionType'
 
@@ -7,6 +8,8 @@ export interface UserSubscriptionRepositoryInterface {
   findOneByUserUuid(userUuid: string): Promise<UserSubscription | null>
   findOneByUserUuidAndType(userUuid: string, type: UserSubscriptionType): Promise<UserSubscription | null>
   findByUserUuid(userUuid: string): Promise<UserSubscription[]>
+  countByPlanName(planNames: SubscriptionPlanName[]): Promise<number>
+  findByPlanName(planNames: SubscriptionPlanName[], offset: number, limit: number): Promise<UserSubscription[]>
   findOneByUserUuidAndSubscriptionId(userUuid: string, subscriptionId: number): Promise<UserSubscription | null>
   findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]>
   findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]>

+ 22 - 0
packages/auth/src/Infra/TypeORM/TypeORMUserSubscriptionRepository.ts

@@ -6,6 +6,7 @@ import TYPES from '../../Bootstrap/Types'
 import { UserSubscription } from '../../Domain/Subscription/UserSubscription'
 import { UserSubscriptionRepositoryInterface } from '../../Domain/Subscription/UserSubscriptionRepositoryInterface'
 import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType'
+import { SubscriptionPlanName } from '@standardnotes/domain-core'
 
 @injectable()
 export class TypeORMUserSubscriptionRepository implements UserSubscriptionRepositoryInterface {
@@ -15,6 +16,27 @@ export class TypeORMUserSubscriptionRepository implements UserSubscriptionReposi
     @inject(TYPES.Auth_Timer) private timer: TimerInterface,
   ) {}
 
+  async countByPlanName(planNames: SubscriptionPlanName[]): Promise<number> {
+    return await this.ormRepository
+      .createQueryBuilder()
+      .where('plan_name IN (:...planNames)', {
+        planNames: planNames.map((planName) => planName.value),
+      })
+      .getCount()
+  }
+
+  async findByPlanName(planNames: SubscriptionPlanName[], offset: number, limit: number): Promise<UserSubscription[]> {
+    return await this.ormRepository
+      .createQueryBuilder()
+      .where('plan_name IN (:...planNames)', {
+        planNames: planNames.map((planName) => planName.value),
+      })
+      .orderBy('created_at', 'ASC')
+      .skip(offset)
+      .take(limit)
+      .getMany()
+  }
+
   async countActiveSubscriptions(): Promise<number> {
     return await this.ormRepository
       .createQueryBuilder()

+ 7 - 0
packages/domain-events/src/Domain/Event/ItemDeletedEvent.ts

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { ItemDeletedEventPayload } from './ItemDeletedEventPayload'
+
+export interface ItemDeletedEvent extends DomainEventInterface {
+  type: 'ITEM_DELETED'
+  payload: ItemDeletedEventPayload
+}

+ 4 - 0
packages/domain-events/src/Domain/Event/ItemDeletedEventPayload.ts

@@ -0,0 +1,4 @@
+export interface ItemDeletedEventPayload {
+  userUuid: string
+  itemUuid: string
+}

+ 7 - 0
packages/domain-events/src/Domain/Event/RevisionsCleanupRequestedEvent.ts

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { RevisionsCleanupRequestedEventPayload } from './RevisionsCleanupRequestedEventPayload'
+
+export interface RevisionsCleanupRequestedEvent extends DomainEventInterface {
+  type: 'REVISIONS_CLEANUP_REQUESTED'
+  payload: RevisionsCleanupRequestedEventPayload
+}

+ 3 - 0
packages/domain-events/src/Domain/Event/RevisionsCleanupRequestedEventPayload.ts

@@ -0,0 +1,3 @@
+export interface RevisionsCleanupRequestedEventPayload {
+  userUuid: string
+}

+ 4 - 0
packages/domain-events/src/Domain/index.ts

@@ -40,6 +40,8 @@ export * from './Event/FileUploadedEvent'
 export * from './Event/FileUploadedEventPayload'
 export * from './Event/ItemDumpedEvent'
 export * from './Event/ItemDumpedEventPayload'
+export * from './Event/ItemDeletedEvent'
+export * from './Event/ItemDeletedEventPayload'
 export * from './Event/ItemRemovedFromSharedVaultEvent'
 export * from './Event/ItemRemovedFromSharedVaultEventPayload'
 export * from './Event/ItemRevisionCreationRequestedEvent'
@@ -70,6 +72,8 @@ export * from './Event/PredicateVerifiedEvent'
 export * from './Event/PredicateVerifiedEventPayload'
 export * from './Event/RefundProcessedEvent'
 export * from './Event/RefundProcessedEventPayload'
+export * from './Event/RevisionsCleanupRequestedEvent'
+export * from './Event/RevisionsCleanupRequestedEventPayload'
 export * from './Event/RevisionsCopyRequestedEvent'
 export * from './Event/RevisionsCopyRequestedEventPayload'
 export * from './Event/SessionCreatedEvent'

+ 16 - 0
packages/revisions/src/Bootstrap/Container.ts

@@ -52,6 +52,8 @@ import { RemoveRevisionsFromSharedVault } from '../Domain/UseCase/RemoveRevision
 import { ItemRemovedFromSharedVaultEventHandler } from '../Domain/Handler/ItemRemovedFromSharedVaultEventHandler'
 import { SharedVaultRemovedEventHandler } from '../Domain/Handler/SharedVaultRemovedEventHandler'
 import { CreateRevisionFromDump } from '../Domain/UseCase/CreateRevisionFromDump/CreateRevisionFromDump'
+import { DeleteRevisions } from '../Domain/UseCase/DeleteRevisions/DeleteRevisions'
+import { ItemDeletedEventHandler } from '../Domain/Handler/ItemDeletedEventHandler'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -226,6 +228,11 @@ export class ContainerConfigLoader {
       .toConstantValue(
         new DeleteRevision(container.get<RevisionRepositoryInterface>(TYPES.Revisions_SQLRevisionRepository)),
       )
+    container
+      .bind<DeleteRevisions>(TYPES.Revisions_DeleteRevisions)
+      .toConstantValue(
+        new DeleteRevisions(container.get<RevisionRepositoryInterface>(TYPES.Revisions_SQLRevisionRepository)),
+      )
     container
       .bind<CopyRevisions>(TYPES.Revisions_CopyRevisions)
       .toConstantValue(
@@ -311,6 +318,14 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Revisions_Logger),
         ),
       )
+    container
+      .bind<ItemDeletedEventHandler>(TYPES.Revisions_ItemDeletedEventHandler)
+      .toConstantValue(
+        new ItemDeletedEventHandler(
+          container.get<DeleteRevisions>(TYPES.Revisions_DeleteRevisions),
+          container.get<winston.Logger>(TYPES.Revisions_Logger),
+        ),
+      )
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['ITEM_DUMPED', container.get(TYPES.Revisions_ItemDumpedEventHandler)],
@@ -318,6 +333,7 @@ export class ContainerConfigLoader {
       ['REVISIONS_COPY_REQUESTED', container.get(TYPES.Revisions_RevisionsCopyRequestedEventHandler)],
       ['ITEM_REMOVED_FROM_SHARED_VAULT', container.get(TYPES.Revisions_ItemRemovedFromSharedVaultEventHandler)],
       ['SHARED_VAULT_REMOVED', container.get(TYPES.Revisions_SharedVaultRemovedEventHandler)],
+      ['ITEM_DELETED', container.get(TYPES.Revisions_ItemDeletedEventHandler)],
     ])
 
     if (isConfiguredForHomeServer) {

+ 2 - 0
packages/revisions/src/Bootstrap/Types.ts

@@ -33,6 +33,7 @@ const TYPES = {
   Revisions_GetRevisionsMetada: Symbol.for('Revisions_GetRevisionsMetada'),
   Revisions_GetRevision: Symbol.for('Revisions_GetRevision'),
   Revisions_DeleteRevision: Symbol.for('Revisions_DeleteRevision'),
+  Revisions_DeleteRevisions: Symbol.for('Revisions_DeleteRevisions'),
   Revisions_CopyRevisions: Symbol.for('Revisions_CopyRevisions'),
   Revisions_GetRequiredRoleToViewRevision: Symbol.for('Revisions_GetRequiredRoleToViewRevision'),
   Revisions_RemoveRevisionsFromSharedVault: Symbol.for('Revisions_RemoveRevisionsFromSharedVault'),
@@ -47,6 +48,7 @@ const TYPES = {
   Revisions_RevisionsCopyRequestedEventHandler: Symbol.for('Revisions_RevisionsCopyRequestedEventHandler'),
   Revisions_ItemRemovedFromSharedVaultEventHandler: Symbol.for('Revisions_ItemRemovedFromSharedVaultEventHandler'),
   Revisions_SharedVaultRemovedEventHandler: Symbol.for('Revisions_SharedVaultRemovedEventHandler'),
+  Revisions_ItemDeletedEventHandler: Symbol.for('Revisions_ItemDeletedEventHandler'),
   // Services
   Revisions_CrossServiceTokenDecoder: Symbol.for('Revisions_CrossServiceTokenDecoder'),
   Revisions_DomainEventSubscriber: Symbol.for('Revisions_DomainEventSubscriber'),

+ 21 - 0
packages/revisions/src/Domain/Handler/ItemDeletedEventHandler.ts

@@ -0,0 +1,21 @@
+import { DomainEventHandlerInterface, ItemDeletedEvent } from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+
+import { DeleteRevisions } from '../UseCase/DeleteRevisions/DeleteRevisions'
+
+export class ItemDeletedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private deleteRevisions: DeleteRevisions,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: ItemDeletedEvent): Promise<void> {
+    const result = await this.deleteRevisions.execute({ itemUuid: event.payload.itemUuid })
+
+    if (result.isFailed()) {
+      this.logger.error(`Could not delete revisions for item ${event.payload.itemUuid}: ${result.getError()}`, {
+        userId: event.payload.userUuid,
+      })
+    }
+  }
+}

+ 1 - 0
packages/revisions/src/Domain/Revision/RevisionRepositoryInterface.ts

@@ -6,6 +6,7 @@ import { RevisionMetadata } from './RevisionMetadata'
 export interface RevisionRepositoryInterface {
   countByUserUuid(userUuid: Uuid): Promise<number>
   removeByUserUuid(userUuid: Uuid): Promise<void>
+  removeByItemUuid(itemUuid: Uuid): Promise<void>
   removeOneByUuid(revisionUuid: Uuid, userUuid: Uuid): Promise<void>
   findOneByUuid(revisionUuid: Uuid, userUuid: Uuid, sharedVaultUuids: Uuid[]): Promise<Revision | null>
   findByItemUuid(itemUuid: Uuid): Promise<Array<Revision>>

+ 32 - 0
packages/revisions/src/Domain/UseCase/DeleteRevisions/DeleteRevisions.spec.ts

@@ -0,0 +1,32 @@
+import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface'
+import { DeleteRevisions } from './DeleteRevisions'
+
+describe('DeleteRevisions', () => {
+  let revisionRepository: RevisionRepositoryInterface
+
+  const createUseCase = () => new DeleteRevisions(revisionRepository)
+
+  beforeEach(() => {
+    revisionRepository = {} as jest.Mocked<RevisionRepositoryInterface>
+    revisionRepository.removeByItemUuid = jest.fn()
+  })
+
+  it('should remove revisions by item uuid', async () => {
+    const useCase = createUseCase()
+    const itemUuid = '00000000-0000-0000-0000-000000000000'
+
+    const result = await useCase.execute({ itemUuid })
+
+    expect(result.isFailed()).toBe(false)
+    expect(revisionRepository.removeByItemUuid).toHaveBeenCalled()
+  })
+
+  it('should return failed result if item uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const itemUuid = 'invalid'
+
+    const result = await useCase.execute({ itemUuid })
+
+    expect(result.isFailed()).toBe(true)
+  })
+})

+ 19 - 0
packages/revisions/src/Domain/UseCase/DeleteRevisions/DeleteRevisions.ts

@@ -0,0 +1,19 @@
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { RevisionRepositoryInterface } from '../../Revision/RevisionRepositoryInterface'
+import { DeleteRevisionsDTO } from './DeleteRevisionsDTO'
+
+export class DeleteRevisions implements UseCaseInterface<void> {
+  constructor(private revisionRepository: RevisionRepositoryInterface) {}
+
+  async execute(dto: DeleteRevisionsDTO): Promise<Result<void>> {
+    const itemUuidOrError = Uuid.create(dto.itemUuid)
+    if (itemUuidOrError.isFailed()) {
+      return Result.fail(`Could not delete revisions: ${itemUuidOrError.getError()}`)
+    }
+    const itemUuid = itemUuidOrError.getValue()
+
+    await this.revisionRepository.removeByItemUuid(itemUuid)
+
+    return Result.ok()
+  }
+}

+ 3 - 0
packages/revisions/src/Domain/UseCase/DeleteRevisions/DeleteRevisionsDTO.ts

@@ -0,0 +1,3 @@
+export interface DeleteRevisionsDTO {
+  itemUuid: string
+}

+ 9 - 0
packages/revisions/src/Infra/TypeORM/SQL/SQLRevisionRepository.ts

@@ -15,6 +15,15 @@ export class SQLRevisionRepository implements RevisionRepositoryInterface {
     protected logger: Logger,
   ) {}
 
+  async removeByItemUuid(itemUuid: Uuid): Promise<void> {
+    await this.ormRepository
+      .createQueryBuilder()
+      .delete()
+      .from('revisions_revisions')
+      .where('item_uuid = :itemUuid', { itemUuid: itemUuid.value })
+      .execute()
+  }
+
   async removeByUserUuid(userUuid: Uuid): Promise<void> {
     await this.ormRepository
       .createQueryBuilder()

+ 12 - 0
packages/syncing-server/src/Bootstrap/Container.ts

@@ -162,6 +162,7 @@ import { SyncResponse } from '@standardnotes/grpc'
 import { SyncResponseGRPCMapper } from '../Mapping/gRPC/SyncResponseGRPCMapper'
 import { AccountDeletionVerificationRequestedEventHandler } from '../Domain/Handler/AccountDeletionVerificationRequestedEventHandler'
 import { SendEventToClients } from '../Domain/UseCase/Syncing/SendEventToClients/SendEventToClients'
+import { RevisionsCleanupRequestedEventHandler } from '../Domain/Handler/RevisionsCleanupRequestedEventHandler'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -980,6 +981,16 @@ export class ContainerConfigLoader {
           container.get<Logger>(TYPES.Sync_Logger),
         ),
       )
+    container
+      .bind<RevisionsCleanupRequestedEventHandler>(TYPES.Sync_RevisionsCleanupRequestedEventHandler)
+      .toConstantValue(
+        new RevisionsCleanupRequestedEventHandler(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
 
     // Services
     container.bind<ContentDecoderInterface>(TYPES.Sync_ContentDecoder).toDynamicValue(() => new ContentDecoder())
@@ -1008,6 +1019,7 @@ export class ContainerConfigLoader {
         'SHARED_VAULT_REMOVED',
         container.get<SharedVaultRemovedEventHandler>(TYPES.Sync_SharedVaultRemovedEventHandler),
       ],
+      ['REVISIONS_CLEANUP_REQUESTED', container.get(TYPES.Sync_RevisionsCleanupRequestedEventHandler)],
     ])
     if (!isConfiguredForHomeServer) {
       container

+ 1 - 0
packages/syncing-server/src/Bootstrap/Types.ts

@@ -97,6 +97,7 @@ const TYPES = {
   Sync_SharedVaultFileUploadedEventHandler: Symbol.for('Sync_SharedVaultFileUploadedEventHandler'),
   Sync_SharedVaultFileMovedEventHandler: Symbol.for('Sync_SharedVaultFileMovedEventHandler'),
   Sync_SharedVaultRemovedEventHandler: Symbol.for('Sync_SharedVaultRemovedEventHandler'),
+  Sync_RevisionsCleanupRequestedEventHandler: Symbol.for('Sync_RevisionsCleanupRequestedEventHandler'),
   // Services
   Sync_ContentDecoder: Symbol.for('Sync_ContentDecoder'),
   Sync_DomainEventPublisher: Symbol.for('Sync_DomainEventPublisher'),

+ 16 - 0
packages/syncing-server/src/Domain/Event/DomainEventFactory.ts

@@ -4,6 +4,7 @@ import {
   DomainEventService,
   DuplicateItemSyncedEvent,
   EmailRequestedEvent,
+  ItemDeletedEvent,
   ItemDumpedEvent,
   ItemRemovedFromSharedVaultEvent,
   ItemRevisionCreationRequestedEvent,
@@ -316,6 +317,21 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
     }
   }
 
+  createItemDeletedEvent(dto: { itemUuid: string; userUuid: string }): ItemDeletedEvent {
+    return {
+      type: 'ITEM_DELETED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.SyncingServer,
+      },
+      payload: dto,
+    }
+  }
+
   createEmailRequestedEvent(dto: {
     userEmail: string
     messageIdentifier: string

+ 2 - 0
packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts

@@ -2,6 +2,7 @@ import {
   AccountDeletionVerificationPassedEvent,
   DuplicateItemSyncedEvent,
   EmailRequestedEvent,
+  ItemDeletedEvent,
   ItemDumpedEvent,
   ItemRemovedFromSharedVaultEvent,
   ItemRevisionCreationRequestedEvent,
@@ -77,6 +78,7 @@ export interface DomainEventFactoryInterface {
     userUuid?: string
   }): EmailRequestedEvent
   createDuplicateItemSyncedEvent(dto: { itemUuid: string; userUuid: string }): DuplicateItemSyncedEvent
+  createItemDeletedEvent(dto: { itemUuid: string; userUuid: string }): ItemDeletedEvent
   createItemRevisionCreationRequested(dto: { itemUuid: string; userUuid: string }): ItemRevisionCreationRequestedEvent
   createItemDumpedEvent(dto: { fileDumpPath: string; userUuid: string }): ItemDumpedEvent
   createRevisionsCopyRequestedEvent(

+ 56 - 0
packages/syncing-server/src/Domain/Handler/RevisionsCleanupRequestedEventHandler.ts

@@ -0,0 +1,56 @@
+import {
+  RevisionsCleanupRequestedEvent,
+  DomainEventHandlerInterface,
+  DomainEventPublisherInterface,
+} from '@standardnotes/domain-events'
+
+import { Logger } from 'winston'
+import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
+import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
+
+export class RevisionsCleanupRequestedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private itemRepository: ItemRepositoryInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: RevisionsCleanupRequestedEvent): Promise<void> {
+    const totalDeletedItems = await this.itemRepository.countAll({
+      userUuid: event.payload.userUuid,
+      deleted: true,
+    })
+
+    this.logger.info(`Found ${totalDeletedItems} deleted items`, {
+      userId: event.payload.userUuid,
+    })
+
+    const limitPerPage = 100
+    const numberOfPages = Math.ceil(totalDeletedItems / limitPerPage)
+
+    for (let i = 0; i < numberOfPages; i++) {
+      const items = await this.itemRepository.findAll({
+        userUuid: event.payload.userUuid,
+        deleted: true,
+        offset: i * limitPerPage,
+        limit: limitPerPage,
+        sortOrder: 'ASC',
+        sortBy: 'created_at_timestamp',
+      })
+
+      for (const item of items) {
+        await this.domainEventPublisher.publish(
+          this.domainEventFactory.createItemDeletedEvent({
+            itemUuid: item.id.toString(),
+            userUuid: item.props.userUuid.value,
+          }),
+        )
+      }
+    }
+
+    this.logger.info(`Finished processing ${totalDeletedItems} deleted items`, {
+      userId: event.payload.userUuid,
+    })
+  }
+}

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.spec.ts

@@ -109,6 +109,7 @@ describe('UpdateExistingItem', () => {
     domainEventFactory.createItemRemovedFromSharedVaultEvent = jest
       .fn()
       .mockReturnValue({} as jest.Mocked<DomainEventInterface>)
+    domainEventFactory.createItemDeletedEvent = jest.fn().mockReturnValue({} as jest.Mocked<DomainEventInterface>)
 
     determineSharedVaultOperationOnItem = {} as jest.Mocked<DetermineSharedVaultOperationOnItem>
     determineSharedVaultOperationOnItem.execute = jest.fn().mockResolvedValue(

+ 12 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/UpdateExistingItem/UpdateExistingItem.ts

@@ -159,6 +159,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       dto.existingItem.props.keySystemAssociation = undefined
     }
 
+    let wasMarkedAsDeleted = false
     if (dto.itemHash.props.deleted === true) {
       dto.existingItem.props.deleted = true
       dto.existingItem.props.content = null
@@ -166,6 +167,8 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       dto.existingItem.props.encItemKey = null
       dto.existingItem.props.authHash = null
       dto.existingItem.props.itemsKeyId = null
+
+      wasMarkedAsDeleted = true
     }
 
     await this.itemRepository.update(dto.existingItem)
@@ -196,6 +199,15 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
       )
     }
 
+    if (wasMarkedAsDeleted) {
+      await this.domainEventPublisher.publish(
+        this.domainEventFactory.createItemDeletedEvent({
+          itemUuid: dto.existingItem.id.toString(),
+          userUuid: dto.existingItem.props.userUuid.value,
+        }),
+      )
+    }
+
     const notificationsResult = await this.addNotificationsAndPublishEvents(userUuid, sharedVaultOperation, dto)
     if (notificationsResult.isFailed()) {
       return Result.fail(notificationsResult.getError())