Quellcode durchsuchen

feat: add a way to trigger transition procedure for revisions (#798)

* feat: add a way to trigger transition procedure for revisions

* fix: localstack linking

* fix: revisions endpoints
Karol Sójko vor 1 Jahr
Ursprung
Commit
25ffd6b803

+ 1 - 0
.pnp.cjs

@@ -5861,6 +5861,7 @@ const RAW_RUNTIME_STATE =
         "packageDependencies": [\
           ["@standardnotes/revisions-server", "workspace:packages/revisions"],\
           ["@aws-sdk/client-s3", "npm:3.342.0"],\
+          ["@aws-sdk/client-sns", "npm:3.342.0"],\
           ["@aws-sdk/client-sqs", "npm:3.342.0"],\
           ["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.1"],\
           ["@standardnotes/api", "npm:1.26.26"],\

+ 5 - 0
docker/localstack_bootstrap.sh

@@ -178,6 +178,11 @@ LINKING_RESULT=$(link_queue_and_topic $SYNCING_SERVER_TOPIC_ARN $REVISIONS_QUEUE
 echo "linking done:"
 echo "$LINKING_RESULT"
 
+echo "linking topic $REVISIONS_TOPIC_ARN to queue $REVISIONS_QUEUE_ARN"
+LINKING_RESULT=$(link_queue_and_topic $REVISIONS_TOPIC_ARN $REVISIONS_QUEUE_ARN)
+echo "linking done:"
+echo "$LINKING_RESULT"
+
 QUEUE_NAME="scheduler-local-queue"
 
 echo "creating queue $QUEUE_NAME"

+ 19 - 9
packages/api-gateway/src/Controller/v2/RevisionsControllerV2.ts

@@ -1,23 +1,23 @@
 import { Request, Response } from 'express'
 import { inject } from 'inversify'
-import { BaseHttpController, controller, httpDelete, httpGet } from 'inversify-express-utils'
+import { BaseHttpController, controller, httpDelete, httpGet, httpPost } from 'inversify-express-utils'
 
 import { TYPES } from '../../Bootstrap/Types'
 import { ServiceProxyInterface } from '../../Service/Http/ServiceProxyInterface'
 import { EndpointResolverInterface } from '../../Service/Resolver/EndpointResolverInterface'
 
-@controller('/v2/items/:itemUuid/revisions', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
+@controller('/v2', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
 export class RevisionsControllerV2 extends BaseHttpController {
   constructor(
-    @inject(TYPES.ApiGateway_ServiceProxy) private httpService: ServiceProxyInterface,
+    @inject(TYPES.ApiGateway_ServiceProxy) private serviceProxy: ServiceProxyInterface,
     @inject(TYPES.ApiGateway_EndpointResolver) private endpointResolver: EndpointResolverInterface,
   ) {
     super()
   }
 
-  @httpGet('/')
+  @httpGet('/items/:itemUuid/revisions')
   async getRevisions(request: Request, response: Response): Promise<void> {
-    await this.httpService.callRevisionsServer(
+    await this.serviceProxy.callRevisionsServer(
       request,
       response,
       this.endpointResolver.resolveEndpointOrMethodIdentifier(
@@ -28,9 +28,9 @@ export class RevisionsControllerV2 extends BaseHttpController {
     )
   }
 
-  @httpGet('/:uuid')
+  @httpGet('/items/:itemUuid/revisions/:uuid')
   async getRevision(request: Request, response: Response): Promise<void> {
-    await this.httpService.callRevisionsServer(
+    await this.serviceProxy.callRevisionsServer(
       request,
       response,
       this.endpointResolver.resolveEndpointOrMethodIdentifier(
@@ -42,9 +42,9 @@ export class RevisionsControllerV2 extends BaseHttpController {
     )
   }
 
-  @httpDelete('/:uuid')
+  @httpDelete('/items/:itemUuid/revisions/:uuid')
   async deleteRevision(request: Request, response: Response): Promise<void> {
-    await this.httpService.callRevisionsServer(
+    await this.serviceProxy.callRevisionsServer(
       request,
       response,
       this.endpointResolver.resolveEndpointOrMethodIdentifier(
@@ -55,4 +55,14 @@ export class RevisionsControllerV2 extends BaseHttpController {
       ),
     )
   }
+
+  @httpPost('/revisions/transition')
+  async transition(request: Request, response: Response): Promise<void> {
+    await this.serviceProxy.callSyncingServer(
+      request,
+      response,
+      this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'revisions/transition'),
+      request.body,
+    )
+  }
 }

+ 1 - 0
packages/api-gateway/src/Service/Resolver/EndpointResolver.ts

@@ -65,6 +65,7 @@ export class EndpointResolver implements EndpointResolverInterface {
     ['[GET]:items/:itemUuid/revisions', 'revisions.revisions.getRevisions'],
     ['[GET]:items/:itemUuid/revisions/:id', 'revisions.revisions.getRevision'],
     ['[DELETE]:items/:itemUuid/revisions/:id', 'revisions.revisions.deleteRevision'],
+    ['[POST]:revisions/transition', 'revisions.revisions.transition'],
     // Messages Controller
     ['[GET]:messages/', 'sync.messages.get-received'],
     ['[GET]:messages/outbound', 'sync.messages.get-sent'],

+ 2 - 0
packages/revisions/.env.sample

@@ -20,6 +20,8 @@ DB_TYPE=mysql
 REDIS_URL=redis://cache
 CACHE_TYPE=redis
 
+SNS_TOPIC_ARN=
+SNS_AWS_REGION=
 SQS_QUEUE_URL=
 SQS_AWS_REGION=
 S3_AWS_REGION=

+ 1 - 0
packages/revisions/package.json

@@ -26,6 +26,7 @@
   },
   "dependencies": {
     "@aws-sdk/client-s3": "^3.332.0",
+    "@aws-sdk/client-sns": "^3.332.0",
     "@aws-sdk/client-sqs": "^3.332.0",
     "@standardnotes/api": "^1.26.26",
     "@standardnotes/common": "workspace:^",

+ 114 - 42
packages/revisions/src/Bootstrap/Container.ts

@@ -2,6 +2,7 @@ import { ControllerContainer, ControllerContainerInterface, MapperInterface } fr
 import { Container, interfaces } from 'inversify'
 import { MongoRepository, Repository } from 'typeorm'
 import * as winston from 'winston'
+import { SNSClient, SNSClientConfig } from '@aws-sdk/client-sns'
 
 import { Revision } from '../Domain/Revision/Revision'
 import { RevisionMetadata } from '../Domain/Revision/RevisionMetadata'
@@ -25,6 +26,7 @@ import {
   DomainEventMessageHandlerInterface,
   DomainEventHandlerInterface,
   DomainEventSubscriberFactoryInterface,
+  DomainEventPublisherInterface,
 } from '@standardnotes/domain-events'
 import {
   SQSNewRelicEventMessageHandler,
@@ -32,6 +34,7 @@ import {
   SQSDomainEventSubscriberFactory,
   DirectCallEventMessageHandler,
   DirectCallDomainEventPublisher,
+  SNSDomainEventPublisher,
 } from '@standardnotes/domain-events-infra'
 import { DumpRepositoryInterface } from '../Domain/Dump/DumpRepositoryInterface'
 import { AccountDeletionRequestedEventHandler } from '../Domain/Handler/AccountDeletionRequestedEventHandler'
@@ -55,6 +58,10 @@ import { TypeORMRevisionRepositoryResolver } from '../Infra/TypeORM/TypeORMRevis
 import { RevisionMetadataHttpRepresentation } from '../Mapping/Http/RevisionMetadataHttpRepresentation'
 import { RevisionHttpRepresentation } from '../Mapping/Http/RevisionHttpRepresentation'
 import { TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser/TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser'
+import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
+import { DomainEventFactory } from '../Domain/Event/DomainEventFactory'
+import { TransitionStatusUpdatedEventHandler } from '../Domain/Handler/TransitionStatusUpdatedEventHandler'
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
 
 export class ContainerConfigLoader {
   async load(configuration?: {
@@ -98,6 +105,8 @@ export class ContainerConfigLoader {
     }
     container.bind<winston.Logger>(TYPES.Revisions_Logger).toConstantValue(logger)
 
+    container.bind<TimerInterface>(TYPES.Revisions_Timer).toDynamicValue(() => new Timer())
+
     const appDataSource = new AppDataSource(env)
     await appDataSource.initialize()
 
@@ -108,6 +117,85 @@ export class ContainerConfigLoader {
     container.bind(TYPES.Revisions_NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
     container.bind(TYPES.Revisions_VERSION).toConstantValue(env.get('VERSION', true) ?? 'development')
 
+    if (!isConfiguredForHomeServer) {
+      // env vars
+      container.bind(TYPES.Revisions_SNS_TOPIC_ARN).toConstantValue(env.get('SNS_TOPIC_ARN'))
+      container.bind(TYPES.Revisions_SNS_AWS_REGION).toConstantValue(env.get('SNS_AWS_REGION', true))
+      container.bind(TYPES.Revisions_SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL'))
+      container.bind(TYPES.Revisions_S3_AWS_REGION).toConstantValue(env.get('S3_AWS_REGION', true))
+      container.bind(TYPES.Revisions_S3_BACKUP_BUCKET_NAME).toConstantValue(env.get('S3_BACKUP_BUCKET_NAME', true))
+
+      container.bind<SNSClient>(TYPES.Revisions_SNS).toDynamicValue((context: interfaces.Context) => {
+        const env: Env = context.container.get(TYPES.Revisions_Env)
+
+        const snsConfig: SNSClientConfig = {
+          apiVersion: 'latest',
+          region: env.get('SNS_AWS_REGION', true),
+        }
+        if (env.get('SNS_ENDPOINT', true)) {
+          snsConfig.endpoint = env.get('SNS_ENDPOINT', true)
+        }
+        if (env.get('SNS_ACCESS_KEY_ID', true) && env.get('SNS_SECRET_ACCESS_KEY', true)) {
+          snsConfig.credentials = {
+            accessKeyId: env.get('SNS_ACCESS_KEY_ID', true),
+            secretAccessKey: env.get('SNS_SECRET_ACCESS_KEY', true),
+          }
+        }
+
+        return new SNSClient(snsConfig)
+      })
+
+      container
+        .bind<DomainEventPublisherInterface>(TYPES.Revisions_DomainEventPublisher)
+        .toDynamicValue((context: interfaces.Context) => {
+          return new SNSDomainEventPublisher(
+            context.container.get(TYPES.Revisions_SNS),
+            context.container.get(TYPES.Revisions_SNS_TOPIC_ARN),
+          )
+        })
+
+      container.bind<SQSClient>(TYPES.Revisions_SQS).toDynamicValue((context: interfaces.Context) => {
+        const env: Env = context.container.get(TYPES.Revisions_Env)
+
+        const sqsConfig: SQSClientConfig = {
+          region: env.get('SQS_AWS_REGION'),
+        }
+        if (env.get('SQS_ENDPOINT', true)) {
+          sqsConfig.endpoint = env.get('SQS_ENDPOINT', true)
+        }
+        if (env.get('SQS_ACCESS_KEY_ID', true) && env.get('SQS_SECRET_ACCESS_KEY', true)) {
+          sqsConfig.credentials = {
+            accessKeyId: env.get('SQS_ACCESS_KEY_ID', true),
+            secretAccessKey: env.get('SQS_SECRET_ACCESS_KEY', true),
+          }
+        }
+
+        return new SQSClient(sqsConfig)
+      })
+
+      container.bind<S3Client | undefined>(TYPES.Revisions_S3).toDynamicValue((context: interfaces.Context) => {
+        const env: Env = context.container.get(TYPES.Revisions_Env)
+
+        let s3Client = undefined
+        if (env.get('S3_AWS_REGION', true)) {
+          s3Client = new S3Client({
+            apiVersion: 'latest',
+            region: env.get('S3_AWS_REGION', true),
+          })
+        }
+
+        return s3Client
+      })
+    } else {
+      container
+        .bind<DomainEventPublisherInterface>(TYPES.Revisions_DomainEventPublisher)
+        .toConstantValue(directCallDomainEventPublisher)
+    }
+
+    container
+      .bind<DomainEventFactoryInterface>(TYPES.Revisions_DomainEventFactory)
+      .toConstantValue(new DomainEventFactory(container.get(TYPES.Revisions_Timer)))
+
     // Map
     container
       .bind<MapperInterface<RevisionMetadata, SQLRevision>>(TYPES.Revisions_SQLRevisionMetadataPersistenceMapper)
@@ -173,8 +261,6 @@ export class ContainerConfigLoader {
         ),
       )
 
-    container.bind<TimerInterface>(TYPES.Revisions_Timer).toDynamicValue(() => new Timer())
-
     container
       .bind<GetRequiredRoleToViewRevision>(TYPES.Revisions_GetRequiredRoleToViewRevision)
       .toDynamicValue((context: interfaces.Context) => {
@@ -234,6 +320,16 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Revisions_Logger),
         ),
       )
+    container
+      .bind<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
+        TYPES.Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
+      )
+      .toConstantValue(
+        new TriggerTransitionFromPrimaryToSecondaryDatabaseForUser(
+          container.get<DomainEventPublisherInterface>(TYPES.Revisions_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Revisions_DomainEventFactory),
+        ),
+      )
 
     // env vars
     container.bind(TYPES.Revisions_AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET'))
@@ -263,46 +359,6 @@ export class ContainerConfigLoader {
       .bind<MapperInterface<Revision, string>>(TYPES.Revisions_RevisionItemStringMapper)
       .toDynamicValue(() => new RevisionItemStringMapper())
 
-    if (!isConfiguredForHomeServer) {
-      // env vars
-      container.bind(TYPES.Revisions_SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL'))
-      container.bind(TYPES.Revisions_S3_AWS_REGION).toConstantValue(env.get('S3_AWS_REGION', true))
-      container.bind(TYPES.Revisions_S3_BACKUP_BUCKET_NAME).toConstantValue(env.get('S3_BACKUP_BUCKET_NAME', true))
-
-      container.bind<SQSClient>(TYPES.Revisions_SQS).toDynamicValue((context: interfaces.Context) => {
-        const env: Env = context.container.get(TYPES.Revisions_Env)
-
-        const sqsConfig: SQSClientConfig = {
-          region: env.get('SQS_AWS_REGION'),
-        }
-        if (env.get('SQS_ENDPOINT', true)) {
-          sqsConfig.endpoint = env.get('SQS_ENDPOINT', true)
-        }
-        if (env.get('SQS_ACCESS_KEY_ID', true) && env.get('SQS_SECRET_ACCESS_KEY', true)) {
-          sqsConfig.credentials = {
-            accessKeyId: env.get('SQS_ACCESS_KEY_ID', true),
-            secretAccessKey: env.get('SQS_SECRET_ACCESS_KEY', true),
-          }
-        }
-
-        return new SQSClient(sqsConfig)
-      })
-
-      container.bind<S3Client | undefined>(TYPES.Revisions_S3).toDynamicValue((context: interfaces.Context) => {
-        const env: Env = context.container.get(TYPES.Revisions_Env)
-
-        let s3Client = undefined
-        if (env.get('S3_AWS_REGION', true)) {
-          s3Client = new S3Client({
-            apiVersion: 'latest',
-            region: env.get('S3_AWS_REGION', true),
-          })
-        }
-
-        return s3Client
-      })
-    }
-
     container
       .bind<DumpRepositoryInterface>(TYPES.Revisions_DumpRepository)
       .toConstantValue(
@@ -341,11 +397,24 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Revisions_Logger),
         ),
       )
+    container
+      .bind<TransitionStatusUpdatedEventHandler>(TYPES.Revisions_TransitionStatusUpdatedEventHandler)
+      .toConstantValue(
+        new TransitionStatusUpdatedEventHandler(
+          container.get<TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser>(
+            TYPES.Revisions_TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser,
+          ),
+          container.get<DomainEventPublisherInterface>(TYPES.Revisions_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Revisions_DomainEventFactory),
+          container.get<winston.Logger>(TYPES.Revisions_Logger),
+        ),
+      )
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['ITEM_DUMPED', container.get(TYPES.Revisions_ItemDumpedEventHandler)],
       ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Revisions_AccountDeletionRequestedEventHandler)],
       ['REVISIONS_COPY_REQUESTED', container.get(TYPES.Revisions_RevisionsCopyRequestedEventHandler)],
+      ['TRANSITION_STATUS_UPDATED', container.get(TYPES.Revisions_TransitionStatusUpdatedEventHandler)],
     ])
 
     if (isConfiguredForHomeServer) {
@@ -388,6 +457,9 @@ export class ContainerConfigLoader {
             container.get<DeleteRevision>(TYPES.Revisions_DeleteRevision),
             container.get<RevisionHttpMapper>(TYPES.Revisions_RevisionHttpMapper),
             container.get<RevisionMetadataHttpMapper>(TYPES.Revisions_RevisionMetadataHttpMapper),
+            container.get<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
+              TYPES.Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
+            ),
             container.get<ControllerContainerInterface>(TYPES.Revisions_ControllerContainer),
           ),
         )

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

@@ -2,6 +2,7 @@ const TYPES = {
   Revisions_DBConnection: Symbol.for('Revisions_DBConnection'),
   Revisions_Logger: Symbol.for('Revisions_Logger'),
   Revisions_SQS: Symbol.for('Revisions_SQS'),
+  Revisions_SNS: Symbol.for('Revisions_SNS'),
   Revisions_S3: Symbol.for('Revisions_S3'),
   Revisions_Env: Symbol.for('Revisions_Env'),
   // Map
@@ -27,6 +28,8 @@ const TYPES = {
   Revisions_SQS_AWS_REGION: Symbol.for('Revisions_SQS_AWS_REGION'),
   Revisions_S3_AWS_REGION: Symbol.for('Revisions_S3_AWS_REGION'),
   Revisions_S3_BACKUP_BUCKET_NAME: Symbol.for('Revisions_S3_BACKUP_BUCKET_NAME'),
+  Revisions_SNS_TOPIC_ARN: Symbol.for('Revisions_SNS_TOPIC_ARN'),
+  Revisions_SNS_AWS_REGION: Symbol.for('Revisions_SNS_AWS_REGION'),
   Revisions_NEW_RELIC_ENABLED: Symbol.for('Revisions_NEW_RELIC_ENABLED'),
   Revisions_VERSION: Symbol.for('Revisions_VERSION'),
   // use cases
@@ -38,6 +41,9 @@ const TYPES = {
   Revisions_TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
     'Revisions_TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser',
   ),
+  Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
+    'Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser',
+  ),
   // Controller
   Revisions_ControllerContainer: Symbol.for('Revisions_ControllerContainer'),
   Revisions_RevisionsController: Symbol.for('Revisions_RevisionsController'),
@@ -46,10 +52,13 @@ const TYPES = {
   Revisions_ItemDumpedEventHandler: Symbol.for('Revisions_ItemDumpedEventHandler'),
   Revisions_AccountDeletionRequestedEventHandler: Symbol.for('Revisions_AccountDeletionRequestedEventHandler'),
   Revisions_RevisionsCopyRequestedEventHandler: Symbol.for('Revisions_RevisionsCopyRequestedEventHandler'),
+  Revisions_TransitionStatusUpdatedEventHandler: Symbol.for('Revisions_TransitionStatusUpdatedEventHandler'),
   // Services
   Revisions_CrossServiceTokenDecoder: Symbol.for('Revisions_CrossServiceTokenDecoder'),
   Revisions_DomainEventSubscriberFactory: Symbol.for('Revisions_DomainEventSubscriberFactory'),
   Revisions_DomainEventMessageHandler: Symbol.for('Revisions_DomainEventMessageHandler'),
+  Revisions_DomainEventPublisher: Symbol.for('Revisions_DomainEventPublisher'),
+  Revisions_DomainEventFactory: Symbol.for('Revisions_DomainEventFactory'),
   Revisions_Timer: Symbol.for('Revisions_Timer'),
   // Inversify Express Controllers
   Revisions_BaseRevisionsController: Symbol.for('Revisions_BaseRevisionsController'),

+ 27 - 0
packages/revisions/src/Domain/Event/DomainEventFactory.ts

@@ -0,0 +1,27 @@
+/* istanbul ignore file */
+import { DomainEventService, TransitionStatusUpdatedEvent } from '@standardnotes/domain-events'
+import { TimerInterface } from '@standardnotes/time'
+import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
+
+export class DomainEventFactory implements DomainEventFactoryInterface {
+  constructor(private timer: TimerInterface) {}
+
+  createTransitionStatusUpdatedEvent(dto: {
+    userUuid: string
+    transitionType: 'items' | 'revisions'
+    status: 'STARTED' | 'FAILED' | 'FINISHED'
+  }): TransitionStatusUpdatedEvent {
+    return {
+      type: 'TRANSITION_STATUS_UPDATED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.SyncingServer,
+      },
+      payload: dto,
+    }
+  }
+}

+ 9 - 0
packages/revisions/src/Domain/Event/DomainEventFactoryInterface.ts

@@ -0,0 +1,9 @@
+import { TransitionStatusUpdatedEvent } from '@standardnotes/domain-events'
+
+export interface DomainEventFactoryInterface {
+  createTransitionStatusUpdatedEvent(dto: {
+    userUuid: string
+    transitionType: 'items' | 'revisions'
+    status: 'STARTED' | 'FAILED' | 'FINISHED'
+  }): TransitionStatusUpdatedEvent
+}

+ 61 - 0
packages/revisions/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts

@@ -0,0 +1,61 @@
+import {
+  DomainEventHandlerInterface,
+  DomainEventPublisherInterface,
+  TransitionStatusUpdatedEvent,
+} from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
+import { TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser } from '../UseCase/Transition/TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser/TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser'
+
+export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private transitionRevisionsFromPrimaryToSecondaryDatabaseForUser: TransitionRevisionsFromPrimaryToSecondaryDatabaseForUser,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: TransitionStatusUpdatedEvent): Promise<void> {
+    if (event.payload.status === 'STARTED' && event.payload.transitionType === 'items') {
+      await this.domainEventPublisher.publish(
+        this.domainEventFactory.createTransitionStatusUpdatedEvent({
+          userUuid: event.payload.userUuid,
+          status: 'STARTED',
+          transitionType: 'revisions',
+        }),
+      )
+
+      return
+    }
+
+    if (event.payload.status === 'STARTED' && event.payload.transitionType === 'revisions') {
+      const result = await this.transitionRevisionsFromPrimaryToSecondaryDatabaseForUser.execute({
+        userUuid: event.payload.userUuid,
+      })
+
+      if (result.isFailed()) {
+        this.logger.error(`Failed to transition items for user ${event.payload.userUuid}: ${result.getError()}`)
+
+        await this.domainEventPublisher.publish(
+          this.domainEventFactory.createTransitionStatusUpdatedEvent({
+            userUuid: event.payload.userUuid,
+            status: 'FAILED',
+            transitionType: 'revisions',
+          }),
+        )
+
+        return
+      }
+
+      await this.domainEventPublisher.publish(
+        this.domainEventFactory.createTransitionStatusUpdatedEvent({
+          userUuid: event.payload.userUuid,
+          status: 'FINISHED',
+          transitionType: 'revisions',
+        }),
+      )
+
+      return
+    }
+  }
+}

+ 30 - 0
packages/revisions/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.spec.ts

@@ -0,0 +1,30 @@
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from './TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
+import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
+
+describe('TriggerTransitionFromPrimaryToSecondaryDatabaseForUser', () => {
+  let domainEventPubliser: DomainEventPublisherInterface
+  let domainEventFactory: DomainEventFactoryInterface
+
+  const createUseCase = () =>
+    new TriggerTransitionFromPrimaryToSecondaryDatabaseForUser(domainEventPubliser, domainEventFactory)
+
+  beforeEach(() => {
+    domainEventPubliser = {} as jest.Mocked<DomainEventPublisherInterface>
+    domainEventPubliser.publish = jest.fn()
+
+    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
+    domainEventFactory.createTransitionStatusUpdatedEvent = jest.fn()
+  })
+
+  it('should publish transition status updated event', async () => {
+    const useCase = createUseCase()
+
+    await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(domainEventPubliser.publish).toHaveBeenCalled()
+  })
+})

+ 24 - 0
packages/revisions/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.ts

@@ -0,0 +1,24 @@
+import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO } from './TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO'
+import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
+
+export class TriggerTransitionFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
+  constructor(
+    private domainEventPubliser: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+  ) {}
+
+  async execute(dto: TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO): Promise<Result<void>> {
+    const event = this.domainEventFactory.createTransitionStatusUpdatedEvent({
+      userUuid: dto.userUuid,
+      status: 'STARTED',
+      transitionType: 'revisions',
+    })
+
+    await this.domainEventPubliser.publish(event)
+
+    return Result.ok()
+  }
+}

+ 3 - 0
packages/revisions/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO.ts

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

+ 21 - 6
packages/revisions/src/Infra/InversifyExpress/AnnotatedRevisionsController.ts

@@ -1,5 +1,5 @@
 import { Request, Response } from 'express'
-import { controller, httpDelete, httpGet, results } from 'inversify-express-utils'
+import { controller, httpDelete, httpGet, httpPost, results } from 'inversify-express-utils'
 import { inject } from 'inversify'
 
 import TYPES from '../../Bootstrap/Types'
@@ -12,8 +12,9 @@ import { Revision } from '../../Domain/Revision/Revision'
 import { RevisionMetadata } from '../../Domain/Revision/RevisionMetadata'
 import { RevisionHttpRepresentation } from '../../Mapping/Http/RevisionHttpRepresentation'
 import { RevisionMetadataHttpRepresentation } from '../../Mapping/Http/RevisionMetadataHttpRepresentation'
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
 
-@controller('/items/:itemUuid/revisions', TYPES.Revisions_ApiGatewayAuthMiddleware)
+@controller('', TYPES.Revisions_ApiGatewayAuthMiddleware)
 export class AnnotatedRevisionsController extends BaseRevisionsController {
   constructor(
     @inject(TYPES.Revisions_GetRevisionsMetada) override getRevisionsMetadata: GetRevisionsMetada,
@@ -23,22 +24,36 @@ export class AnnotatedRevisionsController extends BaseRevisionsController {
     override revisionHttpMapper: MapperInterface<Revision, RevisionHttpRepresentation>,
     @inject(TYPES.Revisions_RevisionMetadataHttpMapper)
     override revisionMetadataHttpMapper: MapperInterface<RevisionMetadata, RevisionMetadataHttpRepresentation>,
+    @inject(TYPES.Revisions_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser)
+    override triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
   ) {
-    super(getRevisionsMetadata, doGetRevision, doDeleteRevision, revisionHttpMapper, revisionMetadataHttpMapper)
+    super(
+      getRevisionsMetadata,
+      doGetRevision,
+      doDeleteRevision,
+      revisionHttpMapper,
+      revisionMetadataHttpMapper,
+      triggerTransitionFromPrimaryToSecondaryDatabaseForUser,
+    )
   }
 
-  @httpGet('/')
+  @httpGet('/items/:itemUuid/revisions')
   override async getRevisions(request: Request, response: Response): Promise<results.JsonResult> {
     return super.getRevisions(request, response)
   }
 
-  @httpGet('/:uuid')
+  @httpGet('/items/:itemUuid/revisions/:uuid')
   override async getRevision(request: Request, response: Response): Promise<results.JsonResult> {
     return super.getRevision(request, response)
   }
 
-  @httpDelete('/:uuid')
+  @httpDelete('/items/:itemUuid/revisions/:uuid')
   override async deleteRevision(request: Request, response: Response): Promise<results.JsonResult> {
     return super.deleteRevision(request, response)
   }
+
+  @httpPost('/revisions/transition')
+  override async transition(request: Request, response: Response): Promise<results.JsonResult> {
+    return super.transition(request, response)
+  }
 }

+ 22 - 0
packages/revisions/src/Infra/InversifyExpress/Base/BaseRevisionsController.ts

@@ -11,6 +11,7 @@ import { GetRevision } from '../../../Domain/UseCase/GetRevision/GetRevision'
 import { GetRevisionsMetada } from '../../../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada'
 import { RevisionHttpRepresentation } from '../../../Mapping/Http/RevisionHttpRepresentation'
 import { RevisionMetadataHttpRepresentation } from '../../../Mapping/Http/RevisionMetadataHttpRepresentation'
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
 
 export class BaseRevisionsController extends BaseHttpController {
   constructor(
@@ -19,6 +20,7 @@ export class BaseRevisionsController extends BaseHttpController {
     protected doDeleteRevision: DeleteRevision,
     protected revisionHttpMapper: MapperInterface<Revision, RevisionHttpRepresentation>,
     protected revisionMetadataHttpMapper: MapperInterface<RevisionMetadata, RevisionMetadataHttpRepresentation>,
+    protected triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
     private controllerContainer?: ControllerContainerInterface,
   ) {
     super()
@@ -27,6 +29,7 @@ export class BaseRevisionsController extends BaseHttpController {
       this.controllerContainer.register('revisions.revisions.getRevisions', this.getRevisions.bind(this))
       this.controllerContainer.register('revisions.revisions.getRevision', this.getRevision.bind(this))
       this.controllerContainer.register('revisions.revisions.deleteRevision', this.deleteRevision.bind(this))
+      this.controllerContainer.register('revisions.revisions.transition', this.transition.bind(this))
     }
   }
 
@@ -99,4 +102,23 @@ export class BaseRevisionsController extends BaseHttpController {
       message: revisionOrError.getValue(),
     })
   }
+
+  async transition(_request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.triggerTransitionFromPrimaryToSecondaryDatabaseForUser.execute({
+      userUuid: response.locals.user.uuid,
+    })
+
+    if (result.isFailed()) {
+      return this.json(
+        {
+          error: { message: result.getError() },
+        },
+        400,
+      )
+    }
+
+    response.setHeader('x-invalidate-cache', response.locals.user.uuid)
+
+    return this.json({ success: true })
+  }
 }

+ 1 - 0
yarn.lock

@@ -4738,6 +4738,7 @@ __metadata:
   resolution: "@standardnotes/revisions-server@workspace:packages/revisions"
   dependencies:
     "@aws-sdk/client-s3": "npm:^3.332.0"
+    "@aws-sdk/client-sns": "npm:^3.332.0"
     "@aws-sdk/client-sqs": "npm:^3.332.0"
     "@newrelic/winston-enricher": "npm:^4.0.1"
     "@standardnotes/api": "npm:^1.26.26"