Преглед на файлове

feat: add verifiying if user has no items before mass deleting spam accounts (#936)

Karol Sójko преди 1 година
родител
ревизия
c11abe1bd3
променени са 19 файла, в които са добавени 244 реда и са изтрити 20 реда
  1. 13 1
      packages/auth/src/Bootstrap/Container.ts
  2. 1 0
      packages/auth/src/Bootstrap/Types.ts
  3. 19 0
      packages/auth/src/Domain/Event/DomainEventFactory.ts
  4. 5 0
      packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts
  5. 21 0
      packages/auth/src/Domain/Handler/AccountDeletionVerificationPassedEventHandler.ts
  6. 38 9
      packages/auth/src/Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile.spec.ts
  7. 21 9
      packages/auth/src/Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile.ts
  8. 1 0
      packages/auth/src/Domain/User/UserRepositoryInterface.ts
  9. 7 1
      packages/auth/src/Infra/TypeORM/TypeORMUserRepository.ts
  10. 7 0
      packages/domain-events/src/Domain/Event/AccountDeletionVerificationPassedEvent.ts
  11. 4 0
      packages/domain-events/src/Domain/Event/AccountDeletionVerificationPassedEventPayload.ts
  12. 7 0
      packages/domain-events/src/Domain/Event/AccountDeletionVerificationRequestedEvent.ts
  13. 4 0
      packages/domain-events/src/Domain/Event/AccountDeletionVerificationRequestedEventPayload.ts
  14. 4 0
      packages/domain-events/src/Domain/index.ts
  15. 17 0
      packages/syncing-server/src/Bootstrap/Container.ts
  16. 3 0
      packages/syncing-server/src/Bootstrap/Types.ts
  17. 19 0
      packages/syncing-server/src/Domain/Event/DomainEventFactory.ts
  18. 5 0
      packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts
  19. 48 0
      packages/syncing-server/src/Domain/Handler/AccountDeletionVerificationRequestedEventHandler.ts

+ 13 - 1
packages/auth/src/Bootstrap/Container.ts

@@ -280,6 +280,7 @@ import { TriggerEmailBackupForAllUsers } from '../Domain/UseCase/TriggerEmailBac
 import { CSVFileReaderInterface } from '../Domain/CSV/CSVFileReaderInterface'
 import { S3CsvFileReader } from '../Infra/S3/S3CsvFileReader'
 import { DeleteAccountsFromCSVFile } from '../Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile'
+import { AccountDeletionVerificationPassedEventHandler } from '../Domain/Handler/AccountDeletionVerificationPassedEventHandler'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -1274,7 +1275,9 @@ export class ContainerConfigLoader {
         .toConstantValue(
           new DeleteAccountsFromCSVFile(
             container.get<CSVFileReaderInterface>(TYPES.Auth_CSVFileReader),
-            container.get<DeleteAccount>(TYPES.Auth_DeleteAccount),
+            container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
+            container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
+            container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
             container.get<winston.Logger>(TYPES.Auth_Logger),
           ),
         )
@@ -1328,6 +1331,14 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Auth_Logger),
         ),
       )
+    container
+      .bind<AccountDeletionVerificationPassedEventHandler>(TYPES.Auth_AccountDeletionVerificationPassedEventHandler)
+      .toConstantValue(
+        new AccountDeletionVerificationPassedEventHandler(
+          container.get<DeleteAccount>(TYPES.Auth_DeleteAccount),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
     container
       .bind<SubscriptionPurchasedEventHandler>(TYPES.Auth_SubscriptionPurchasedEventHandler)
       .toConstantValue(
@@ -1516,6 +1527,7 @@ export class ContainerConfigLoader {
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Auth_AccountDeletionRequestedEventHandler)],
+      ['ACCOUNT_DELETION_VERIFICATION_PASSED', container.get(TYPES.Auth_AccountDeletionVerificationPassedEventHandler)],
       ['SUBSCRIPTION_PURCHASED', container.get(TYPES.Auth_SubscriptionPurchasedEventHandler)],
       ['SUBSCRIPTION_CANCELLED', container.get(TYPES.Auth_SubscriptionCancelledEventHandler)],
       ['SUBSCRIPTION_RENEWED', container.get(TYPES.Auth_SubscriptionRenewedEventHandler)],

+ 1 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -171,6 +171,7 @@ const TYPES = {
   Auth_DeleteAccountsFromCSVFile: Symbol.for('Auth_DeleteAccountsFromCSVFile'),
   // Handlers
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
+  Auth_AccountDeletionVerificationPassedEventHandler: Symbol.for('Auth_AccountDeletionVerificationPassedEventHandler'),
   Auth_SubscriptionPurchasedEventHandler: Symbol.for('Auth_SubscriptionPurchasedEventHandler'),
   Auth_SubscriptionCancelledEventHandler: Symbol.for('Auth_SubscriptionCancelledEventHandler'),
   Auth_SubscriptionReassignedEventHandler: Symbol.for('Auth_SubscriptionReassignedEventHandler'),

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

@@ -20,6 +20,7 @@ import {
   StatisticPersistenceRequestedEvent,
   SessionCreatedEvent,
   SessionRefreshedEvent,
+  AccountDeletionVerificationRequestedEvent,
 } from '@standardnotes/domain-events'
 import { Predicate, PredicateVerificationResult } from '@standardnotes/predicates'
 import { TimerInterface } from '@standardnotes/time'
@@ -33,6 +34,24 @@ import { KeyParamsData } from '@standardnotes/responses'
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(@inject(TYPES.Auth_Timer) private timer: TimerInterface) {}
 
+  createAccountDeletionVerificationRequestedEvent(dto: {
+    userUuid: string
+    email: string
+  }): AccountDeletionVerificationRequestedEvent {
+    return {
+      type: 'ACCOUNT_DELETION_VERIFICATION_REQUESTED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.Auth,
+      },
+      payload: dto,
+    }
+  }
+
   createSessionCreatedEvent(dto: { userUuid: string }): SessionCreatedEvent {
     return {
       type: 'SESSION_CREATED',

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

@@ -18,6 +18,7 @@ import {
   StatisticPersistenceRequestedEvent,
   SessionCreatedEvent,
   SessionRefreshedEvent,
+  AccountDeletionVerificationRequestedEvent,
 } from '@standardnotes/domain-events'
 import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType'
 import { KeyParamsData } from '@standardnotes/responses'
@@ -56,6 +57,10 @@ export interface DomainEventFactoryInterface {
       ownerUuid: string
     }
   }): AccountDeletionRequestedEvent
+  createAccountDeletionVerificationRequestedEvent(dto: {
+    userUuid: string
+    email: string
+  }): AccountDeletionVerificationRequestedEvent
   createUserRolesChangedEvent(userUuid: string, email: string, currentRoles: string[]): UserRolesChangedEvent
   createUserEmailChangedEvent(userUuid: string, fromEmail: string, toEmail: string): UserEmailChangedEvent
   createUserDisabledSessionUserAgentLoggingEvent(dto: {

+ 21 - 0
packages/auth/src/Domain/Handler/AccountDeletionVerificationPassedEventHandler.ts

@@ -0,0 +1,21 @@
+import { AccountDeletionVerificationPassedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+
+import { DeleteAccount } from '../UseCase/DeleteAccount/DeleteAccount'
+
+export class AccountDeletionVerificationPassedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private deleteAccount: DeleteAccount,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: AccountDeletionVerificationPassedEvent): Promise<void> {
+    const result = await this.deleteAccount.execute({
+      userUuid: event.payload.userUuid,
+    })
+
+    if (result.isFailed()) {
+      this.logger.error(`AccountDeletionVerificationPassedEventHandler failed: ${result.getError()}`)
+    }
+  }
+}

+ 38 - 9
packages/auth/src/Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile.spec.ts

@@ -1,33 +1,51 @@
 import { Logger } from 'winston'
 import { Result } from '@standardnotes/domain-core'
+import { AccountDeletionVerificationRequestedEvent, DomainEventPublisherInterface } from '@standardnotes/domain-events'
 
 import { CSVFileReaderInterface } from '../../CSV/CSVFileReaderInterface'
-import { DeleteAccount } from '../DeleteAccount/DeleteAccount'
 import { DeleteAccountsFromCSVFile } from './DeleteAccountsFromCSVFile'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { User } from '../../User/User'
 
 describe('DeleteAccountsFromCSVFile', () => {
   let csvFileReader: CSVFileReaderInterface
-  let deleteAccount: DeleteAccount
+  let userRepository: UserRepositoryInterface
+  let domainEventPublisher: DomainEventPublisherInterface
+  let domainEventFactory: DomainEventFactoryInterface
   let logger: Logger
 
-  const createUseCase = () => new DeleteAccountsFromCSVFile(csvFileReader, deleteAccount, logger)
+  const createUseCase = () =>
+    new DeleteAccountsFromCSVFile(csvFileReader, domainEventPublisher, domainEventFactory, userRepository, logger)
 
   beforeEach(() => {
+    const user = {} as jest.Mocked<User>
+
     csvFileReader = {} as jest.Mocked<CSVFileReaderInterface>
     csvFileReader.getValues = jest.fn().mockResolvedValue(Result.ok(['email1']))
 
-    deleteAccount = {} as jest.Mocked<DeleteAccount>
-    deleteAccount.execute = jest.fn().mockResolvedValue(Result.ok(''))
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findAllByUsernameOrEmail = jest.fn().mockResolvedValue([user])
+
+    domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
+    domainEventPublisher.publish = jest.fn()
+
+    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
+    domainEventFactory.createAccountDeletionVerificationRequestedEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<AccountDeletionVerificationRequestedEvent>)
 
     logger = {} as jest.Mocked<Logger>
     logger.info = jest.fn()
   })
 
-  it('should delete accounts', async () => {
+  it('should request account deletion verification', async () => {
     const useCase = createUseCase()
 
     const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
 
+    expect(domainEventPublisher.publish).toHaveBeenCalled()
+
     expect(result.isFailed()).toBeFalsy()
   })
 
@@ -56,12 +74,12 @@ describe('DeleteAccountsFromCSVFile', () => {
 
     const result = await useCase.execute({ fileName: 'test.csv', dryRun: true })
 
-    expect(deleteAccount.execute).not.toHaveBeenCalled()
+    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
     expect(result.isFailed()).toBeFalsy()
   })
 
-  it('should return error if delete account fails', async () => {
-    deleteAccount.execute = jest.fn().mockResolvedValue(Result.fail('Oops'))
+  it('should return error username is invalid', async () => {
+    csvFileReader.getValues = jest.fn().mockResolvedValue(Result.ok(['']))
 
     const useCase = createUseCase()
 
@@ -69,4 +87,15 @@ describe('DeleteAccountsFromCSVFile', () => {
 
     expect(result.isFailed()).toBeTruthy()
   })
+
+  it('should do nothing if users could not be found', async () => {
+    userRepository.findAllByUsernameOrEmail = jest.fn().mockResolvedValue([])
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
+
+    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(result.isFailed()).toBeFalsy()
+  })
 })

+ 21 - 9
packages/auth/src/Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile.ts

@@ -1,14 +1,18 @@
-import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
 import { Logger } from 'winston'
 
-import { DeleteAccount } from '../DeleteAccount/DeleteAccount'
 import { CSVFileReaderInterface } from '../../CSV/CSVFileReaderInterface'
 import { DeleteAccountsFromCSVFileDTO } from './DeleteAccountsFromCSVFileDTO'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
 
 export class DeleteAccountsFromCSVFile implements UseCaseInterface<void> {
   constructor(
     private csvFileReader: CSVFileReaderInterface,
-    private deleteAccount: DeleteAccount,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private userRepository: UserRepositoryInterface,
     private logger: Logger,
   ) {}
 
@@ -33,12 +37,20 @@ export class DeleteAccountsFromCSVFile implements UseCaseInterface<void> {
     }
 
     for (const email of emails) {
-      const deleteAccountOrError = await this.deleteAccount.execute({
-        username: email,
-      })
-
-      if (deleteAccountOrError.isFailed()) {
-        return Result.fail(deleteAccountOrError.getError())
+      const usernameOrError = Username.create(email)
+      if (usernameOrError.isFailed()) {
+        return Result.fail(usernameOrError.getError())
+      }
+      const username = usernameOrError.getValue()
+
+      const users = await this.userRepository.findAllByUsernameOrEmail(username)
+      for (const user of users) {
+        await this.domainEventPublisher.publish(
+          this.domainEventFactory.createAccountDeletionVerificationRequestedEvent({
+            userUuid: user.uuid,
+            email: user.email,
+          }),
+        )
       }
     }
 

+ 1 - 0
packages/auth/src/Domain/User/UserRepositoryInterface.ts

@@ -8,6 +8,7 @@ export interface UserRepositoryInterface {
   streamTeam(memberEmail?: Email): Promise<ReadStream>
   findOneByUuid(uuid: Uuid): Promise<User | null>
   findOneByUsernameOrEmail(usernameOrEmail: Email | Username): Promise<User | null>
+  findAllByUsernameOrEmail(usernameOrEmail: Email | Username): Promise<User[]>
   findAllCreatedBetween(dto: { start: Date; end: Date; offset: number; limit: number }): Promise<User[]>
   countAllCreatedBetween(start: Date, end: Date): Promise<number>
   save(user: User): Promise<User>

+ 7 - 1
packages/auth/src/Infra/TypeORM/TypeORMUserRepository.ts

@@ -69,7 +69,13 @@ export class TypeORMUserRepository implements UserRepositoryInterface {
     return this.ormRepository
       .createQueryBuilder('user')
       .where('user.email = :email', { email: usernameOrEmail.value })
-      .cache(`user_email_${usernameOrEmail.value}`, 60000)
       .getOne()
   }
+
+  async findAllByUsernameOrEmail(usernameOrEmail: Email | Username): Promise<User[]> {
+    return this.ormRepository
+      .createQueryBuilder('user')
+      .where('user.email = :email', { email: usernameOrEmail.value })
+      .getMany()
+  }
 }

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

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { AccountDeletionVerificationPassedEventPayload } from './AccountDeletionVerificationPassedEventPayload'
+
+export interface AccountDeletionVerificationPassedEvent extends DomainEventInterface {
+  type: 'ACCOUNT_DELETION_VERIFICATION_PASSED'
+  payload: AccountDeletionVerificationPassedEventPayload
+}

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

@@ -0,0 +1,4 @@
+export interface AccountDeletionVerificationPassedEventPayload {
+  userUuid: string
+  email: string
+}

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

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { AccountDeletionVerificationRequestedEventPayload } from './AccountDeletionVerificationRequestedEventPayload'
+
+export interface AccountDeletionVerificationRequestedEvent extends DomainEventInterface {
+  type: 'ACCOUNT_DELETION_VERIFICATION_REQUESTED'
+  payload: AccountDeletionVerificationRequestedEventPayload
+}

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

@@ -0,0 +1,4 @@
+export interface AccountDeletionVerificationRequestedEventPayload {
+  userUuid: string
+  email: string
+}

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

@@ -1,5 +1,9 @@
 export * from './Event/AccountDeletionRequestedEvent'
 export * from './Event/AccountDeletionRequestedEventPayload'
+export * from './Event/AccountDeletionVerificationPassedEvent'
+export * from './Event/AccountDeletionVerificationPassedEventPayload'
+export * from './Event/AccountDeletionVerificationRequestedEvent'
+export * from './Event/AccountDeletionVerificationRequestedEventPayload'
 export * from './Event/DiscountApplyRequestedEvent'
 export * from './Event/DiscountApplyRequestedEventPayload'
 export * from './Event/DiscountWithdrawRequestedEvent'

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

@@ -160,6 +160,7 @@ import { DumpItem } from '../Domain/UseCase/Syncing/DumpItem/DumpItem'
 import { SyncResponse20200115 } from '../Domain/Item/SyncResponse/SyncResponse20200115'
 import { SyncResponse } from '@standardnotes/grpc'
 import { SyncResponseGRPCMapper } from '../Mapping/gRPC/SyncResponseGRPCMapper'
+import { AccountDeletionVerificationRequestedEventHandler } from '../Domain/Handler/AccountDeletionVerificationRequestedEventHandler'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -907,6 +908,18 @@ export class ContainerConfigLoader {
           container.get<Logger>(TYPES.Sync_Logger),
         ),
       )
+    container
+      .bind<AccountDeletionVerificationRequestedEventHandler>(
+        TYPES.Sync_AccountDeletionVerificationRequestedEventHandler,
+      )
+      .toConstantValue(
+        new AccountDeletionVerificationRequestedEventHandler(
+          container.get<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
     container
       .bind<ItemRevisionCreationRequestedEventHandler>(TYPES.Sync_ItemRevisionCreationRequestedEventHandler)
       .toConstantValue(
@@ -957,6 +970,10 @@ export class ContainerConfigLoader {
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['DUPLICATE_ITEM_SYNCED', container.get(TYPES.Sync_DuplicateItemSyncedEventHandler)],
       ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Sync_AccountDeletionRequestedEventHandler)],
+      [
+        'ACCOUNT_DELETION_VERIFICATION_REQUESTED',
+        container.get(TYPES.Sync_AccountDeletionVerificationRequestedEventHandler),
+      ],
       ['ITEM_REVISION_CREATION_REQUESTED', container.get(TYPES.Sync_ItemRevisionCreationRequestedEventHandler)],
       [
         'SHARED_VAULT_FILE_UPLOADED',

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

@@ -85,6 +85,9 @@ const TYPES = {
   Sync_DumpItem: Symbol.for('Sync_DumpItem'),
   // Handlers
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
+  Sync_AccountDeletionVerificationRequestedEventHandler: Symbol.for(
+    'Sync_AccountDeletionVerificationRequestedEventHandler',
+  ),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
   Sync_EmailBackupRequestedEventHandler: Symbol.for('Sync_EmailBackupRequestedEventHandler'),
   Sync_ItemRevisionCreationRequestedEventHandler: Symbol.for('Sync_ItemRevisionCreationRequestedEventHandler'),

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

@@ -1,5 +1,6 @@
 /* istanbul ignore file */
 import {
+  AccountDeletionVerificationPassedEvent,
   DomainEventService,
   DuplicateItemSyncedEvent,
   EmailRequestedEvent,
@@ -22,6 +23,24 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(private timer: TimerInterface) {}
 
+  createAccountDeletionVerificationPassedEvent(dto: {
+    userUuid: string
+    email: string
+  }): AccountDeletionVerificationPassedEvent {
+    return {
+      type: 'ACCOUNT_DELETION_VERIFICATION_PASSED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.SyncingServer,
+      },
+      payload: dto,
+    }
+  }
+
   createUserDesignatedAsSurvivorInSharedVaultEvent(dto: {
     sharedVaultUuid: string
     userUuid: string

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

@@ -1,4 +1,5 @@
 import {
+  AccountDeletionVerificationPassedEvent,
   DuplicateItemSyncedEvent,
   EmailRequestedEvent,
   ItemDumpedEvent,
@@ -93,4 +94,8 @@ export interface DomainEventFactoryInterface {
     userUuid: string
     timestamp: number
   }): UserDesignatedAsSurvivorInSharedVaultEvent
+  createAccountDeletionVerificationPassedEvent(dto: {
+    userUuid: string
+    email: string
+  }): AccountDeletionVerificationPassedEvent
 }

+ 48 - 0
packages/syncing-server/src/Domain/Handler/AccountDeletionVerificationRequestedEventHandler.ts

@@ -0,0 +1,48 @@
+import {
+  AccountDeletionVerificationRequestedEvent,
+  DomainEventHandlerInterface,
+  DomainEventPublisherInterface,
+} from '@standardnotes/domain-events'
+import { Uuid } from '@standardnotes/domain-core'
+import { Logger } from 'winston'
+
+import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
+import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
+
+export class AccountDeletionVerificationRequestedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private itemRepository: ItemRepositoryInterface,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: AccountDeletionVerificationRequestedEvent): Promise<void> {
+    const userUuidOrError = Uuid.create(event.payload.userUuid)
+    if (userUuidOrError.isFailed()) {
+      this.logger.error(`AccountDeletionVerificationRequestedEventHandler failed: ${userUuidOrError.getError()}`)
+
+      return
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const itemsCount = await this.itemRepository.countAll({
+      userUuid: userUuid.value,
+    })
+
+    if (itemsCount !== 0) {
+      this.logger.warn(
+        `AccountDeletionVerificationRequestedEventHandler: User ${userUuid.value} has ${itemsCount} items and cannot be deleted.`,
+      )
+
+      return
+    }
+
+    await this.domainEventPublisher.publish(
+      this.domainEventFactory.createAccountDeletionVerificationPassedEvent({
+        userUuid: userUuid.value,
+        email: event.payload.email,
+      }),
+    )
+  }
+}