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

feat: add trigerring items transition and checking status of it (#707)

Karol Sójko преди 1 година
родител
ревизия
05bb12c978
променени са 64 файла, в които са добавени 884 реда и са изтрити 105 реда
  1. 3 2
      docker/localstack_bootstrap.sh
  2. 15 3
      packages/api-gateway/src/Controller/AuthMiddleware.ts
  3. 10 0
      packages/api-gateway/src/Controller/v1/ItemsController.ts
  4. 9 0
      packages/api-gateway/src/Controller/v1/UsersController.ts
  5. 3 1
      packages/api-gateway/src/Service/Resolver/EndpointResolver.ts
  6. 46 8
      packages/auth/src/Bootstrap/Container.ts
  7. 4 0
      packages/auth/src/Bootstrap/Types.ts
  8. 4 4
      packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts
  9. 1 1
      packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts
  10. 4 4
      packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts
  11. 1 1
      packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts
  12. 4 4
      packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts
  13. 1 1
      packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts
  14. 4 4
      packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts
  15. 1 1
      packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts
  16. 5 5
      packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts
  17. 1 1
      packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts
  18. 4 4
      packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts
  19. 1 1
      packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts
  20. 18 0
      packages/auth/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts
  21. 45 10
      packages/auth/src/Domain/Role/RoleService.spec.ts
  22. 37 21
      packages/auth/src/Domain/Role/RoleService.ts
  23. 4 2
      packages/auth/src/Domain/Role/RoleServiceInterface.ts
  24. 5 0
      packages/auth/src/Domain/Transition/TransitionStatusRepositoryInterface.ts
  25. 8 8
      packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts
  26. 1 1
      packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts
  27. 2 2
      packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts
  28. 1 1
      packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts
  29. 4 4
      packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts
  30. 4 1
      packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts
  31. 39 0
      packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts
  32. 6 0
      packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts
  33. 108 0
      packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.spec.ts
  34. 39 0
      packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.ts
  35. 3 0
      packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatusDTO.ts
  36. 1 1
      packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts
  37. 64 0
      packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.spec.ts
  38. 31 0
      packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.ts
  39. 4 0
      packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatusDTO.ts
  40. 19 0
      packages/auth/src/Infra/InMemory/InMemoryTransitionStatusRepository.ts
  41. 6 0
      packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts
  42. 8 0
      packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts
  43. 26 0
      packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts
  44. 23 0
      packages/auth/src/Infra/Redis/RedisTransitionStatusRepository.ts
  45. 8 0
      packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEvent.ts
  46. 4 0
      packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEventPayload.ts
  47. 2 0
      packages/domain-events/src/Domain/index.ts
  48. 1 0
      packages/security/src/Domain/Token/CrossServiceTokenData.ts
  49. 41 7
      packages/syncing-server/src/Bootstrap/Container.ts
  50. 4 0
      packages/syncing-server/src/Bootstrap/Types.ts
  51. 19 0
      packages/syncing-server/src/Domain/Event/DomainEventFactory.ts
  52. 5 0
      packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts
  53. 39 0
      packages/syncing-server/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts
  54. 19 1
      packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.spec.ts
  55. 13 0
      packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts
  56. 30 0
      packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.spec.ts
  57. 20 0
      packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.ts
  58. 3 0
      packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO.ts
  59. 16 1
      packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedItemsController.ts
  60. 26 0
      packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts
  61. 1 0
      packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts
  62. 1 0
      packages/time/src/Domain/Time/TimeStructure.ts
  63. 1 0
      packages/time/src/Domain/Time/Timer.spec.ts
  64. 4 0
      packages/time/src/Domain/Time/Timer.ts

+ 3 - 2
docker/localstack_bootstrap.sh

@@ -152,10 +152,11 @@ LINKING_RESULT=$(link_queue_and_topic $FILES_TOPIC_ARN $SYNCING_SERVER_QUEUE_ARN
 echo "linking done:"
 echo "linking done:"
 echo "$LINKING_RESULT"
 echo "$LINKING_RESULT"
 
 
-echo "linking topic $SYNCING_SERVER_TOPIC_ARN to queue $SYNCING_SERVER_QUEUE_ARN"
-LINKING_RESULT=$(link_queue_and_topic $SYNCING_SERVER_TOPIC_ARN $SYNCING_SERVER_QUEUE_ARN)
+echo "linking topic $SYNCING_SERVER_TOPIC_ARN to queue $AUTH_QUEUE_ARN"
+LINKING_RESULT=$(link_queue_and_topic $SYNCING_SERVER_TOPIC_ARN $AUTH_QUEUE_ARN)
 echo "linking done:"
 echo "linking done:"
 echo "$LINKING_RESULT"
 echo "$LINKING_RESULT"
+
 echo "linking topic $AUTH_TOPIC_ARN to queue $SYNCING_SERVER_QUEUE_ARN"
 echo "linking topic $AUTH_TOPIC_ARN to queue $SYNCING_SERVER_QUEUE_ARN"
 LINKING_RESULT=$(link_queue_and_topic $AUTH_TOPIC_ARN $SYNCING_SERVER_QUEUE_ARN)
 LINKING_RESULT=$(link_queue_and_topic $AUTH_TOPIC_ARN $SYNCING_SERVER_QUEUE_ARN)
 echo "linking done:"
 echo "linking done:"

+ 15 - 3
packages/api-gateway/src/Controller/AuthMiddleware.ts

@@ -39,7 +39,7 @@ export abstract class AuthMiddleware extends BaseMiddleware {
         crossServiceToken = await this.crossServiceTokenCache.get(cacheKey)
         crossServiceToken = await this.crossServiceTokenCache.get(cacheKey)
       }
       }
 
 
-      if (crossServiceToken === null) {
+      if (this.crossServiceTokenIsEmptyOrRequiresRevalidation(crossServiceToken)) {
         const authResponse = await this.serviceProxy.validateSession({
         const authResponse = await this.serviceProxy.validateSession({
           authorization: authHeaderValue,
           authorization: authHeaderValue,
           sharedVaultOwnerContext: sharedVaultOwnerContextHeaderValue,
           sharedVaultOwnerContext: sharedVaultOwnerContextHeaderValue,
@@ -55,12 +55,14 @@ export abstract class AuthMiddleware extends BaseMiddleware {
 
 
       response.locals.authToken = crossServiceToken
       response.locals.authToken = crossServiceToken
 
 
-      const decodedToken = <CrossServiceTokenData>verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] })
+      const decodedToken = <CrossServiceTokenData>(
+        verify(response.locals.authToken, this.jwtSecret, { algorithms: ['HS256'] })
+      )
 
 
       if (this.crossServiceTokenCacheTTL && !crossServiceTokenFetchedFromCache) {
       if (this.crossServiceTokenCacheTTL && !crossServiceTokenFetchedFromCache) {
         await this.crossServiceTokenCache.set({
         await this.crossServiceTokenCache.set({
           key: cacheKey,
           key: cacheKey,
-          encodedCrossServiceToken: crossServiceToken,
+          encodedCrossServiceToken: response.locals.authToken,
           expiresAtInSeconds: this.getCrossServiceTokenCacheExpireTimestamp(decodedToken),
           expiresAtInSeconds: this.getCrossServiceTokenCacheExpireTimestamp(decodedToken),
           userUuid: decodedToken.user.uuid,
           userUuid: decodedToken.user.uuid,
         })
         })
@@ -126,4 +128,14 @@ export abstract class AuthMiddleware extends BaseMiddleware {
 
 
     return Math.min(crossServiceTokenDefaultCacheExpiration, sessionAccessExpiration, sessionRefreshExpiration)
     return Math.min(crossServiceTokenDefaultCacheExpiration, sessionAccessExpiration, sessionRefreshExpiration)
   }
   }
+
+  private crossServiceTokenIsEmptyOrRequiresRevalidation(crossServiceToken: string | null) {
+    if (crossServiceToken === null) {
+      return true
+    }
+
+    const decodedToken = <CrossServiceTokenData>verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] })
+
+    return decodedToken.ongoing_transition === true
+  }
 }
 }

+ 10 - 0
packages/api-gateway/src/Controller/v1/ItemsController.ts

@@ -34,6 +34,16 @@ export class ItemsController extends BaseHttpController {
     )
     )
   }
   }
 
 
+  @httpPost('/transition')
+  async transition(request: Request, response: Response): Promise<void> {
+    await this.serviceProxy.callSyncingServer(
+      request,
+      response,
+      this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'items/transition'),
+      request.body,
+    )
+  }
+
   @httpGet('/:uuid')
   @httpGet('/:uuid')
   async getItem(request: Request, response: Response): Promise<void> {
   async getItem(request: Request, response: Response): Promise<void> {
     await this.serviceProxy.callSyncingServer(
     await this.serviceProxy.callSyncingServer(

+ 9 - 0
packages/api-gateway/src/Controller/v1/UsersController.ts

@@ -80,6 +80,15 @@ export class UsersController extends BaseHttpController {
     )
     )
   }
   }
 
 
+  @httpGet('/transition-status', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
+  async getTransitionStatus(request: Request, response: Response): Promise<void> {
+    await this.httpService.callAuthServer(
+      request,
+      response,
+      this.endpointResolver.resolveEndpointOrMethodIdentifier('GET', 'users/transition-status'),
+    )
+  }
+
   @httpGet('/:userId/params', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
   @httpGet('/:userId/params', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
   async getKeyParams(request: Request, response: Response): Promise<void> {
   async getKeyParams(request: Request, response: Response): Promise<void> {
     await this.httpService.callAuthServer(
     await this.httpService.callAuthServer(

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

@@ -42,7 +42,8 @@ export class EndpointResolver implements EndpointResolverInterface {
     // Users Controller
     // Users Controller
     ['[PATCH]:users/:userId', 'auth.users.update'],
     ['[PATCH]:users/:userId', 'auth.users.update'],
     ['[PUT]:users/:userUuid/attributes/credentials', 'auth.users.updateCredentials'],
     ['[PUT]:users/:userUuid/attributes/credentials', 'auth.users.updateCredentials'],
-    ['[PUT]:auth/params', 'auth.users.getKeyParams'],
+    ['[GET]:users/params', 'auth.users.getKeyParams'],
+    ['[GET]:users/transition-status', 'auth.users.transition-status'],
     ['[DELETE]:users/:userUuid', 'auth.users.delete'],
     ['[DELETE]:users/:userUuid', 'auth.users.delete'],
     ['[POST]:listed', 'auth.users.createListedAccount'],
     ['[POST]:listed', 'auth.users.createListedAccount'],
     ['[POST]:auth', 'auth.users.register'],
     ['[POST]:auth', 'auth.users.register'],
@@ -58,6 +59,7 @@ export class EndpointResolver implements EndpointResolverInterface {
     // Syncing Server
     // Syncing Server
     ['[POST]:items/sync', 'sync.items.sync'],
     ['[POST]:items/sync', 'sync.items.sync'],
     ['[POST]:items/check-integrity', 'sync.items.check_integrity'],
     ['[POST]:items/check-integrity', 'sync.items.check_integrity'],
+    ['[POST]:items/transition', 'sync.items.transition'],
     ['[GET]:items/:uuid', 'sync.items.get_item'],
     ['[GET]:items/:uuid', 'sync.items.get_item'],
     // Revisions Controller V2
     // Revisions Controller V2
     ['[GET]:items/:itemUuid/revisions', 'revisions.revisions.getRevisions'],
     ['[GET]:items/:itemUuid/revisions', 'revisions.revisions.getRevisions'],

+ 46 - 8
packages/auth/src/Bootstrap/Container.ts

@@ -257,6 +257,12 @@ import { UpdateStorageQuotaUsedForUser } from '../Domain/UseCase/UpdateStorageQu
 import { SharedVaultFileUploadedEventHandler } from '../Domain/Handler/SharedVaultFileUploadedEventHandler'
 import { SharedVaultFileUploadedEventHandler } from '../Domain/Handler/SharedVaultFileUploadedEventHandler'
 import { SharedVaultFileRemovedEventHandler } from '../Domain/Handler/SharedVaultFileRemovedEventHandler'
 import { SharedVaultFileRemovedEventHandler } from '../Domain/Handler/SharedVaultFileRemovedEventHandler'
 import { SharedVaultFileMovedEventHandler } from '../Domain/Handler/SharedVaultFileMovedEventHandler'
 import { SharedVaultFileMovedEventHandler } from '../Domain/Handler/SharedVaultFileMovedEventHandler'
+import { TransitionStatusRepositoryInterface } from '../Domain/Transition/TransitionStatusRepositoryInterface'
+import { RedisTransitionStatusRepository } from '../Infra/Redis/RedisTransitionStatusRepository'
+import { InMemoryTransitionStatusRepository } from '../Infra/InMemory/InMemoryTransitionStatusRepository'
+import { TransitionStatusUpdatedEventHandler } from '../Domain/Handler/TransitionStatusUpdatedEventHandler'
+import { UpdateTransitionStatus } from '../Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus'
+import { GetTransitionStatus } from '../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
 
 
 export class ContainerConfigLoader {
 export class ContainerConfigLoader {
   async load(configuration?: {
   async load(configuration?: {
@@ -610,6 +616,9 @@ export class ContainerConfigLoader {
             container.get(TYPES.Auth_Timer),
             container.get(TYPES.Auth_Timer),
           ),
           ),
         )
         )
+      container
+        .bind<TransitionStatusRepositoryInterface>(TYPES.Auth_TransitionStatusRepository)
+        .toConstantValue(new InMemoryTransitionStatusRepository())
     } else {
     } else {
       container.bind<PKCERepositoryInterface>(TYPES.Auth_PKCERepository).to(RedisPKCERepository)
       container.bind<PKCERepositoryInterface>(TYPES.Auth_PKCERepository).to(RedisPKCERepository)
       container.bind<LockRepositoryInterface>(TYPES.Auth_LockRepository).to(LockRepository)
       container.bind<LockRepositoryInterface>(TYPES.Auth_LockRepository).to(LockRepository)
@@ -622,6 +631,9 @@ export class ContainerConfigLoader {
       container
       container
         .bind<SubscriptionTokenRepositoryInterface>(TYPES.Auth_SubscriptionTokenRepository)
         .bind<SubscriptionTokenRepositoryInterface>(TYPES.Auth_SubscriptionTokenRepository)
         .to(RedisSubscriptionTokenRepository)
         .to(RedisSubscriptionTokenRepository)
+      container
+        .bind<TransitionStatusRepositoryInterface>(TYPES.Auth_TransitionStatusRepository)
+        .toConstantValue(new RedisTransitionStatusRepository(container.get<Redis>(TYPES.Auth_Redis)))
     }
     }
 
 
     // Services
     // Services
@@ -898,6 +910,22 @@ export class ContainerConfigLoader {
           container.get(TYPES.Auth_SubscriptionSettingService),
           container.get(TYPES.Auth_SubscriptionSettingService),
         ),
         ),
       )
       )
+    container
+      .bind<UpdateTransitionStatus>(TYPES.Auth_UpdateTransitionStatus)
+      .toConstantValue(
+        new UpdateTransitionStatus(
+          container.get<TransitionStatusRepositoryInterface>(TYPES.Auth_TransitionStatusRepository),
+          container.get<RoleServiceInterface>(TYPES.Auth_RoleService),
+        ),
+      )
+    container
+      .bind<GetTransitionStatus>(TYPES.Auth_GetTransitionStatus)
+      .toConstantValue(
+        new GetTransitionStatus(
+          container.get<TransitionStatusRepositoryInterface>(TYPES.Auth_TransitionStatusRepository),
+          container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
+        ),
+      )
 
 
     // Controller
     // Controller
     container
     container
@@ -1039,6 +1067,14 @@ export class ContainerConfigLoader {
           container.get(TYPES.Auth_Logger),
           container.get(TYPES.Auth_Logger),
         ),
         ),
       )
       )
+    container
+      .bind<TransitionStatusUpdatedEventHandler>(TYPES.Auth_TransitionStatusUpdatedEventHandler)
+      .toConstantValue(
+        new TransitionStatusUpdatedEventHandler(
+          container.get<UpdateTransitionStatus>(TYPES.Auth_UpdateTransitionStatus),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
 
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['USER_REGISTERED', container.get(TYPES.Auth_UserRegisteredEventHandler)],
       ['USER_REGISTERED', container.get(TYPES.Auth_UserRegisteredEventHandler)],
@@ -1070,6 +1106,7 @@ export class ContainerConfigLoader {
       ['PREDICATE_VERIFICATION_REQUESTED', container.get(TYPES.Auth_PredicateVerificationRequestedEventHandler)],
       ['PREDICATE_VERIFICATION_REQUESTED', container.get(TYPES.Auth_PredicateVerificationRequestedEventHandler)],
       ['EMAIL_SUBSCRIPTION_UNSUBSCRIBED', container.get(TYPES.Auth_EmailSubscriptionUnsubscribedEventHandler)],
       ['EMAIL_SUBSCRIPTION_UNSUBSCRIBED', container.get(TYPES.Auth_EmailSubscriptionUnsubscribedEventHandler)],
       ['PAYMENTS_ACCOUNT_DELETED', container.get(TYPES.Auth_PaymentsAccountDeletedEventHandler)],
       ['PAYMENTS_ACCOUNT_DELETED', container.get(TYPES.Auth_PaymentsAccountDeletedEventHandler)],
+      ['TRANSITION_STATUS_UPDATED', container.get(TYPES.Auth_TransitionStatusUpdatedEventHandler)],
     ])
     ])
 
 
     if (isConfiguredForHomeServer) {
     if (isConfiguredForHomeServer) {
@@ -1174,14 +1211,15 @@ export class ContainerConfigLoader {
         .bind<BaseUsersController>(TYPES.Auth_BaseUsersController)
         .bind<BaseUsersController>(TYPES.Auth_BaseUsersController)
         .toConstantValue(
         .toConstantValue(
           new BaseUsersController(
           new BaseUsersController(
-            container.get(TYPES.Auth_UpdateUser),
-            container.get(TYPES.Auth_GetUserKeyParams),
-            container.get(TYPES.Auth_DeleteAccount),
-            container.get(TYPES.Auth_GetUserSubscription),
-            container.get(TYPES.Auth_ClearLoginAttempts),
-            container.get(TYPES.Auth_IncreaseLoginAttempts),
-            container.get(TYPES.Auth_ChangeCredentials),
-            container.get(TYPES.Auth_ControllerContainer),
+            container.get<UpdateUser>(TYPES.Auth_UpdateUser),
+            container.get<GetUserKeyParams>(TYPES.Auth_GetUserKeyParams),
+            container.get<DeleteAccount>(TYPES.Auth_DeleteAccount),
+            container.get<GetUserSubscription>(TYPES.Auth_GetUserSubscription),
+            container.get<ClearLoginAttempts>(TYPES.Auth_ClearLoginAttempts),
+            container.get<IncreaseLoginAttempts>(TYPES.Auth_IncreaseLoginAttempts),
+            container.get<ChangeCredentials>(TYPES.Auth_ChangeCredentials),
+            container.get<GetTransitionStatus>(TYPES.Auth_GetTransitionStatus),
+            container.get<ControllerContainerInterface>(TYPES.Auth_ControllerContainer),
           ),
           ),
         )
         )
       container
       container

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

@@ -35,6 +35,7 @@ const TYPES = {
   Auth_AuthenticatorRepository: Symbol.for('Auth_AuthenticatorRepository'),
   Auth_AuthenticatorRepository: Symbol.for('Auth_AuthenticatorRepository'),
   Auth_AuthenticatorChallengeRepository: Symbol.for('Auth_AuthenticatorChallengeRepository'),
   Auth_AuthenticatorChallengeRepository: Symbol.for('Auth_AuthenticatorChallengeRepository'),
   Auth_CacheEntryRepository: Symbol.for('Auth_CacheEntryRepository'),
   Auth_CacheEntryRepository: Symbol.for('Auth_CacheEntryRepository'),
+  Auth_TransitionStatusRepository: Symbol.for('Auth_TransitionStatusRepository'),
   // ORM
   // ORM
   Auth_ORMOfflineSettingRepository: Symbol.for('Auth_ORMOfflineSettingRepository'),
   Auth_ORMOfflineSettingRepository: Symbol.for('Auth_ORMOfflineSettingRepository'),
   Auth_ORMOfflineUserSubscriptionRepository: Symbol.for('Auth_ORMOfflineUserSubscriptionRepository'),
   Auth_ORMOfflineUserSubscriptionRepository: Symbol.for('Auth_ORMOfflineUserSubscriptionRepository'),
@@ -154,6 +155,8 @@ const TYPES = {
   Auth_SignInWithRecoveryCodes: Symbol.for('Auth_SignInWithRecoveryCodes'),
   Auth_SignInWithRecoveryCodes: Symbol.for('Auth_SignInWithRecoveryCodes'),
   Auth_GetUserKeyParamsRecovery: Symbol.for('Auth_GetUserKeyParamsRecovery'),
   Auth_GetUserKeyParamsRecovery: Symbol.for('Auth_GetUserKeyParamsRecovery'),
   Auth_UpdateStorageQuotaUsedForUser: Symbol.for('Auth_UpdateStorageQuotaUsedForUser'),
   Auth_UpdateStorageQuotaUsedForUser: Symbol.for('Auth_UpdateStorageQuotaUsedForUser'),
+  Auth_UpdateTransitionStatus: Symbol.for('Auth_UpdateTransitionStatus'),
+  Auth_GetTransitionStatus: Symbol.for('Auth_GetTransitionStatus'),
   // Handlers
   // Handlers
   Auth_UserRegisteredEventHandler: Symbol.for('Auth_UserRegisteredEventHandler'),
   Auth_UserRegisteredEventHandler: Symbol.for('Auth_UserRegisteredEventHandler'),
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
@@ -182,6 +185,7 @@ const TYPES = {
   Auth_PredicateVerificationRequestedEventHandler: Symbol.for('Auth_PredicateVerificationRequestedEventHandler'),
   Auth_PredicateVerificationRequestedEventHandler: Symbol.for('Auth_PredicateVerificationRequestedEventHandler'),
   Auth_EmailSubscriptionUnsubscribedEventHandler: Symbol.for('Auth_EmailSubscriptionUnsubscribedEventHandler'),
   Auth_EmailSubscriptionUnsubscribedEventHandler: Symbol.for('Auth_EmailSubscriptionUnsubscribedEventHandler'),
   Auth_PaymentsAccountDeletedEventHandler: Symbol.for('Auth_PaymentsAccountDeletedEventHandler'),
   Auth_PaymentsAccountDeletedEventHandler: Symbol.for('Auth_PaymentsAccountDeletedEventHandler'),
+  Auth_TransitionStatusUpdatedEventHandler: Symbol.for('Auth_TransitionStatusUpdatedEventHandler'),
   // Services
   // Services
   Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'),
   Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'),
   Auth_SessionService: Symbol.for('Auth_SessionService'),
   Auth_SessionService: Symbol.for('Auth_SessionService'),

+ 4 - 4
packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts

@@ -60,7 +60,7 @@ describe('SubscriptionExpiredEventHandler', () => {
     offlineUserSubscriptionRepository.updateEndsAt = jest.fn()
     offlineUserSubscriptionRepository.updateEndsAt = jest.fn()
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.removeUserRole = jest.fn()
+    roleService.removeUserRoleBasedOnSubscription = jest.fn()
 
 
     timestamp = dayjs.utc().valueOf()
     timestamp = dayjs.utc().valueOf()
 
 
@@ -86,7 +86,7 @@ describe('SubscriptionExpiredEventHandler', () => {
   it('should update the user role', async () => {
   it('should update the user role', async () => {
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.removeUserRole).toHaveBeenCalledWith(user, SubscriptionName.PlusPlan)
+    expect(roleService.removeUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.PlusPlan)
   })
   })
 
 
   it('should update subscription ends at', async () => {
   it('should update subscription ends at', async () => {
@@ -108,7 +108,7 @@ describe('SubscriptionExpiredEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.removeUserRole).not.toHaveBeenCalled()
+    expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
   })
   })
 
 
@@ -117,7 +117,7 @@ describe('SubscriptionExpiredEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.removeUserRole).not.toHaveBeenCalled()
+    expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts

@@ -48,7 +48,7 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
   private async removeRoleFromSubscriptionUsers(subscriptionId: number, subscriptionName: string): Promise<void> {
   private async removeRoleFromSubscriptionUsers(subscriptionId: number, subscriptionName: string): Promise<void> {
     const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId)
     const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId)
     for (const userSubscription of userSubscriptions) {
     for (const userSubscription of userSubscriptions) {
-      await this.roleService.removeUserRole(await userSubscription.user, subscriptionName)
+      await this.roleService.removeUserRoleBasedOnSubscription(await userSubscription.user, subscriptionName)
     }
     }
   }
   }
 
 

+ 4 - 4
packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts

@@ -72,7 +72,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
     offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription)
     offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription)
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
     roleService.setOfflineUserRole = jest.fn()
     roleService.setOfflineUserRole = jest.fn()
 
 
     subscriptionExpiresAt = timestamp + 365 * 1000
     subscriptionExpiresAt = timestamp + 365 * 1000
@@ -106,7 +106,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
   it('should update the user role', async () => {
   it('should update the user role', async () => {
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
   })
   })
 
 
   it('should update user default settings', async () => {
   it('should update user default settings', async () => {
@@ -162,7 +162,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 
 
@@ -171,7 +171,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts

@@ -70,7 +70,7 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
   }
   }
 
 
   private async addUserRole(user: User, subscriptionName: string): Promise<void> {
   private async addUserRole(user: User, subscriptionName: string): Promise<void> {
-    await this.roleService.addUserRole(user, subscriptionName)
+    await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName)
   }
   }
 
 
   private async createSubscription(
   private async createSubscription(

+ 4 - 4
packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts

@@ -62,7 +62,7 @@ describe('SubscriptionReassignedEventHandler', () => {
     userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription)
     userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription)
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
 
 
     subscriptionExpiresAt = timestamp + 365 * 1000
     subscriptionExpiresAt = timestamp + 365 * 1000
 
 
@@ -100,7 +100,7 @@ describe('SubscriptionReassignedEventHandler', () => {
   it('should update the user role', async () => {
   it('should update the user role', async () => {
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
   })
   })
 
 
   it('should create subscription', async () => {
   it('should create subscription', async () => {
@@ -146,7 +146,7 @@ describe('SubscriptionReassignedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 
 
@@ -155,7 +155,7 @@ describe('SubscriptionReassignedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts

@@ -67,7 +67,7 @@ export class SubscriptionReassignedEventHandler implements DomainEventHandlerInt
   }
   }
 
 
   private async addUserRole(user: User, subscriptionName: string): Promise<void> {
   private async addUserRole(user: User, subscriptionName: string): Promise<void> {
-    await this.roleService.addUserRole(user, subscriptionName)
+    await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName)
   }
   }
 
 
   private async createSubscription(
   private async createSubscription(

+ 4 - 4
packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts

@@ -61,7 +61,7 @@ describe('SubscriptionRefundedEventHandler', () => {
     offlineUserSubscriptionRepository.updateEndsAt = jest.fn()
     offlineUserSubscriptionRepository.updateEndsAt = jest.fn()
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.removeUserRole = jest.fn()
+    roleService.removeUserRoleBasedOnSubscription = jest.fn()
 
 
     timestamp = dayjs.utc().valueOf()
     timestamp = dayjs.utc().valueOf()
 
 
@@ -87,7 +87,7 @@ describe('SubscriptionRefundedEventHandler', () => {
   it('should update the user role', async () => {
   it('should update the user role', async () => {
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.removeUserRole).toHaveBeenCalledWith(user, SubscriptionName.PlusPlan)
+    expect(roleService.removeUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.PlusPlan)
   })
   })
 
 
   it('should update subscription ends at', async () => {
   it('should update subscription ends at', async () => {
@@ -109,7 +109,7 @@ describe('SubscriptionRefundedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.removeUserRole).not.toHaveBeenCalled()
+    expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
   })
   })
 
 
@@ -118,7 +118,7 @@ describe('SubscriptionRefundedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.removeUserRole).not.toHaveBeenCalled()
+    expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts

@@ -48,7 +48,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
   private async removeRoleFromSubscriptionUsers(subscriptionId: number, subscriptionName: string): Promise<void> {
   private async removeRoleFromSubscriptionUsers(subscriptionId: number, subscriptionName: string): Promise<void> {
     const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId)
     const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId)
     for (const userSubscription of userSubscriptions) {
     for (const userSubscription of userSubscriptions) {
-      await this.roleService.removeUserRole(await userSubscription.user, subscriptionName)
+      await this.roleService.removeUserRoleBasedOnSubscription(await userSubscription.user, subscriptionName)
     }
     }
   }
   }
 
 

+ 5 - 5
packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts

@@ -67,7 +67,7 @@ describe('SubscriptionRenewedEventHandler', () => {
     offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription)
     offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription)
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
     roleService.setOfflineUserRole = jest.fn()
     roleService.setOfflineUserRole = jest.fn()
 
 
     timestamp = dayjs.utc().valueOf()
     timestamp = dayjs.utc().valueOf()
@@ -107,7 +107,7 @@ describe('SubscriptionRenewedEventHandler', () => {
   it('should update the user role', async () => {
   it('should update the user role', async () => {
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
   })
   })
 
 
   it('should update the offline user role', async () => {
   it('should update the offline user role', async () => {
@@ -123,7 +123,7 @@ describe('SubscriptionRenewedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 
 
@@ -132,7 +132,7 @@ describe('SubscriptionRenewedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 
 
@@ -143,7 +143,7 @@ describe('SubscriptionRenewedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts

@@ -73,7 +73,7 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
     for (const userSubscription of userSubscriptions) {
     for (const userSubscription of userSubscriptions) {
       const user = await userSubscription.user
       const user = await userSubscription.user
 
 
-      await this.roleService.addUserRole(user, subscriptionName)
+      await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName)
     }
     }
   }
   }
 
 

+ 4 - 4
packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts

@@ -88,7 +88,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
     })
     })
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
     roleService.setOfflineUserRole = jest.fn()
     roleService.setOfflineUserRole = jest.fn()
 
 
     subscriptionExpiresAt = timestamp + 365 * 1000
     subscriptionExpiresAt = timestamp + 365 * 1000
@@ -121,7 +121,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
   it('should update the user role', async () => {
   it('should update the user role', async () => {
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.ProPlan)
   })
   })
 
 
   it('should update user default settings', async () => {
   it('should update user default settings', async () => {
@@ -243,7 +243,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 
 
@@ -252,7 +252,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
 
 
     await createHandler().handle(event)
     await createHandler().handle(event)
 
 
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts

@@ -93,7 +93,7 @@ export class SubscriptionSyncRequestedEventHandler implements DomainEventHandler
       event.payload.timestamp,
       event.payload.timestamp,
     )
     )
 
 
-    await this.roleService.addUserRole(user, event.payload.subscriptionName)
+    await this.roleService.addUserRoleBasedOnSubscription(user, event.payload.subscriptionName)
 
 
     await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(userSubscription)
     await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(userSubscription)
 
 

+ 18 - 0
packages/auth/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts

@@ -0,0 +1,18 @@
+import { DomainEventHandlerInterface, TransitionStatusUpdatedEvent } from '@standardnotes/domain-events'
+import { UpdateTransitionStatus } from '../UseCase/UpdateTransitionStatus/UpdateTransitionStatus'
+import { Logger } from 'winston'
+
+export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerInterface {
+  constructor(private updateTransitionStatusUseCase: UpdateTransitionStatus, private logger: Logger) {}
+
+  async handle(event: TransitionStatusUpdatedEvent): Promise<void> {
+    const result = await this.updateTransitionStatusUseCase.execute({
+      status: event.payload.status,
+      userUuid: event.payload.userUuid,
+    })
+
+    if (result.isFailed()) {
+      this.logger.error(`Failed to update transition status for user ${event.payload.userUuid}`)
+    }
+  }
+}

+ 45 - 10
packages/auth/src/Domain/Role/RoleService.spec.ts

@@ -5,7 +5,7 @@ import { User } from '../User/User'
 import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
 import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
 import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface'
 import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface'
 import { SubscriptionName } from '@standardnotes/common'
 import { SubscriptionName } from '@standardnotes/common'
-import { RoleName } from '@standardnotes/domain-core'
+import { RoleName, Uuid } from '@standardnotes/domain-core'
 import { Role } from '../Role/Role'
 import { Role } from '../Role/Role'
 
 
 import { ClientServiceInterface } from '../Client/ClientServiceInterface'
 import { ClientServiceInterface } from '../Client/ClientServiceInterface'
@@ -81,9 +81,44 @@ describe('RoleService', () => {
     logger = {} as jest.Mocked<Logger>
     logger = {} as jest.Mocked<Logger>
     logger.info = jest.fn()
     logger.info = jest.fn()
     logger.warn = jest.fn()
     logger.warn = jest.fn()
+    logger.error = jest.fn()
   })
   })
 
 
   describe('adding roles', () => {
   describe('adding roles', () => {
+    beforeEach(() => {
+      user = {
+        uuid: '123',
+        email: 'test@test.com',
+        roles: Promise.resolve([basicRole]),
+      } as jest.Mocked<User>
+
+      userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
+      userRepository.save = jest.fn().mockReturnValue(user)
+    })
+
+    it('should add a role to a user', async () => {
+      await createService().addRoleToUser(
+        Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        RoleName.create(RoleName.NAMES.ProUser).getValue(),
+      )
+
+      user.roles = Promise.resolve([basicRole, proRole])
+      expect(userRepository.save).toHaveBeenCalledWith(user)
+    })
+
+    it('should not add a role to a user if the user could not be found', async () => {
+      userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
+
+      await createService().addRoleToUser(
+        Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        RoleName.create(RoleName.NAMES.ProUser).getValue(),
+      )
+
+      expect(userRepository.save).not.toHaveBeenCalled()
+    })
+  })
+
+  describe('adding roles based on subscription', () => {
     beforeEach(() => {
     beforeEach(() => {
       user = {
       user = {
         uuid: '123',
         uuid: '123',
@@ -96,7 +131,7 @@ describe('RoleService', () => {
     })
     })
 
 
     it('should add role to user', async () => {
     it('should add role to user', async () => {
-      await createService().addUserRole(user, SubscriptionName.ProPlan)
+      await createService().addUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan)
 
 
       expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.NAMES.ProUser)
       expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.NAMES.ProUser)
       user.roles = Promise.resolve([basicRole, proRole])
       user.roles = Promise.resolve([basicRole, proRole])
@@ -112,7 +147,7 @@ describe('RoleService', () => {
 
 
       userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
       userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
 
 
-      await createService().addUserRole(user, SubscriptionName.ProPlan)
+      await createService().addUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan)
 
 
       expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.NAMES.ProUser)
       expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.NAMES.ProUser)
       expect(userRepository.save).toHaveBeenCalledWith(user)
       expect(userRepository.save).toHaveBeenCalledWith(user)
@@ -120,7 +155,7 @@ describe('RoleService', () => {
     })
     })
 
 
     it('should send websockets event', async () => {
     it('should send websockets event', async () => {
-      await createService().addUserRole(user, SubscriptionName.ProPlan)
+      await createService().addUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan)
 
 
       expect(webSocketsClientService.sendUserRolesChangedEvent).toHaveBeenCalledWith(user)
       expect(webSocketsClientService.sendUserRolesChangedEvent).toHaveBeenCalledWith(user)
     })
     })
@@ -128,14 +163,14 @@ describe('RoleService', () => {
     it('should not add role if no role name exists for subscription name', async () => {
     it('should not add role if no role name exists for subscription name', async () => {
       roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined)
       roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined)
 
 
-      await createService().addUserRole(user, 'test' as SubscriptionName)
+      await createService().addUserRoleBasedOnSubscription(user, 'test' as SubscriptionName)
 
 
       expect(userRepository.save).not.toHaveBeenCalled()
       expect(userRepository.save).not.toHaveBeenCalled()
     })
     })
 
 
     it('should not add role if no role exists for role name', async () => {
     it('should not add role if no role exists for role name', async () => {
       roleRepository.findOneByName = jest.fn().mockReturnValue(null)
       roleRepository.findOneByName = jest.fn().mockReturnValue(null)
-      await createService().addUserRole(user, SubscriptionName.ProPlan)
+      await createService().addUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan)
 
 
       expect(userRepository.save).not.toHaveBeenCalled()
       expect(userRepository.save).not.toHaveBeenCalled()
     })
     })
@@ -169,7 +204,7 @@ describe('RoleService', () => {
     })
     })
   })
   })
 
 
-  describe('removing roles', () => {
+  describe('removing roles based on subscription', () => {
     beforeEach(() => {
     beforeEach(() => {
       user = {
       user = {
         uuid: '123',
         uuid: '123',
@@ -182,13 +217,13 @@ describe('RoleService', () => {
     })
     })
 
 
     it('should remove role from user', async () => {
     it('should remove role from user', async () => {
-      await createService().removeUserRole(user, SubscriptionName.ProPlan)
+      await createService().removeUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan)
 
 
       expect(userRepository.save).toHaveBeenCalledWith(user)
       expect(userRepository.save).toHaveBeenCalledWith(user)
     })
     })
 
 
     it('should send websockets event', async () => {
     it('should send websockets event', async () => {
-      await createService().removeUserRole(user, SubscriptionName.ProPlan)
+      await createService().removeUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan)
 
 
       expect(webSocketsClientService.sendUserRolesChangedEvent).toHaveBeenCalledWith(user)
       expect(webSocketsClientService.sendUserRolesChangedEvent).toHaveBeenCalledWith(user)
     })
     })
@@ -196,7 +231,7 @@ describe('RoleService', () => {
     it('should not remove role if role name does not exist for subscription name', async () => {
     it('should not remove role if role name does not exist for subscription name', async () => {
       roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined)
       roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined)
 
 
-      await createService().removeUserRole(user, 'test' as SubscriptionName)
+      await createService().removeUserRoleBasedOnSubscription(user, 'test' as SubscriptionName)
 
 
       expect(userRepository.save).not.toHaveBeenCalled()
       expect(userRepository.save).not.toHaveBeenCalled()
     })
     })

+ 37 - 21
packages/auth/src/Domain/Role/RoleService.ts

@@ -13,7 +13,7 @@ import { RoleToSubscriptionMapInterface } from './RoleToSubscriptionMapInterface
 import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
 import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
 import { Role } from './Role'
 import { Role } from './Role'
 import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription'
 import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription'
-import { Uuid } from '@standardnotes/domain-core'
+import { RoleName, Uuid } from '@standardnotes/domain-core'
 
 
 @injectable()
 @injectable()
 export class RoleService implements RoleServiceInterface {
 export class RoleService implements RoleServiceInterface {
@@ -54,33 +54,26 @@ export class RoleService implements RoleServiceInterface {
     return false
     return false
   }
   }
 
 
-  async addUserRole(user: User, subscriptionName: SubscriptionName): Promise<void> {
-    const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName)
+  async addRoleToUser(userUuid: Uuid, roleName: RoleName): Promise<void> {
+    const user = await this.userRepository.findOneByUuid(userUuid)
+    if (user === null) {
+      this.logger.error(`Could not find user with uuid ${userUuid.value} to add role ${roleName.value}`)
 
 
-    if (roleName === undefined) {
-      this.logger.warn(`Could not find role name for subscription name: ${subscriptionName}`)
       return
       return
     }
     }
 
 
-    const role = await this.roleRepository.findOneByName(roleName)
+    await this.addToExistingRoles(user, roleName.value)
+  }
 
 
-    if (role === null) {
-      this.logger.warn(`Could not find role for role name: ${roleName}`)
-      return
-    }
+  async addUserRoleBasedOnSubscription(user: User, subscriptionName: SubscriptionName): Promise<void> {
+    const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName)
 
 
-    const rolesMap = new Map<string, Role>()
-    const currentRoles = await user.roles
-    for (const currentRole of currentRoles) {
-      rolesMap.set(currentRole.name, currentRole)
-    }
-    if (!rolesMap.has(role.name)) {
-      rolesMap.set(role.name, role)
+    if (roleName === undefined) {
+      this.logger.warn(`Could not find role name for subscription name: ${subscriptionName}`)
+      return
     }
     }
 
 
-    user.roles = Promise.resolve([...rolesMap.values()])
-    await this.userRepository.save(user)
-    await this.webSocketsClientService.sendUserRolesChangedEvent(user)
+    await this.addToExistingRoles(user, roleName)
   }
   }
 
 
   async setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise<void> {
   async setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise<void> {
@@ -107,7 +100,7 @@ export class RoleService implements RoleServiceInterface {
     await this.offlineUserSubscriptionRepository.save(offlineUserSubscription)
     await this.offlineUserSubscriptionRepository.save(offlineUserSubscription)
   }
   }
 
 
-  async removeUserRole(user: User, subscriptionName: SubscriptionName): Promise<void> {
+  async removeUserRoleBasedOnSubscription(user: User, subscriptionName: SubscriptionName): Promise<void> {
     const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName)
     const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName)
 
 
     if (roleName === undefined) {
     if (roleName === undefined) {
@@ -120,4 +113,27 @@ export class RoleService implements RoleServiceInterface {
     await this.userRepository.save(user)
     await this.userRepository.save(user)
     await this.webSocketsClientService.sendUserRolesChangedEvent(user)
     await this.webSocketsClientService.sendUserRolesChangedEvent(user)
   }
   }
+
+  private async addToExistingRoles(user: User, roleNameString: string): Promise<void> {
+    const role = await this.roleRepository.findOneByName(roleNameString)
+
+    if (role === null) {
+      this.logger.warn(`Could not find role for role name: ${roleNameString}`)
+
+      return
+    }
+
+    const rolesMap = new Map<string, Role>()
+    const currentRoles = await user.roles
+    for (const currentRole of currentRoles) {
+      rolesMap.set(currentRole.name, currentRole)
+    }
+    if (!rolesMap.has(role.name)) {
+      rolesMap.set(role.name, role)
+    }
+
+    user.roles = Promise.resolve([...rolesMap.values()])
+    await this.userRepository.save(user)
+    await this.webSocketsClientService.sendUserRolesChangedEvent(user)
+  }
 }
 }

+ 4 - 2
packages/auth/src/Domain/Role/RoleServiceInterface.ts

@@ -1,10 +1,12 @@
 import { PermissionName } from '@standardnotes/features'
 import { PermissionName } from '@standardnotes/features'
+import { RoleName, Uuid } from '@standardnotes/domain-core'
 import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription'
 import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription'
 import { User } from '../User/User'
 import { User } from '../User/User'
 
 
 export interface RoleServiceInterface {
 export interface RoleServiceInterface {
-  addUserRole(user: User, subscriptionName: string): Promise<void>
+  addRoleToUser(userUuid: Uuid, roleName: RoleName): Promise<void>
+  addUserRoleBasedOnSubscription(user: User, subscriptionName: string): Promise<void>
   setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise<void>
   setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise<void>
-  removeUserRole(user: User, subscriptionName: string): Promise<void>
+  removeUserRoleBasedOnSubscription(user: User, subscriptionName: string): Promise<void>
   userHasPermission(userUuid: string, permissionName: PermissionName): Promise<boolean>
   userHasPermission(userUuid: string, permissionName: PermissionName): Promise<boolean>
 }
 }

+ 5 - 0
packages/auth/src/Domain/Transition/TransitionStatusRepositoryInterface.ts

@@ -0,0 +1,5 @@
+export interface TransitionStatusRepositoryInterface {
+  updateStatus(userUuid: string, status: 'STARTED' | 'FAILED'): Promise<void>
+  removeStatus(userUuid: string): Promise<void>
+  getStatus(userUuid: string): Promise<'STARTED' | 'FAILED' | null>
+}

+ 8 - 8
packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts

@@ -69,7 +69,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
     userSubscriptionRepository.save = jest.fn().mockReturnValue(inviteeSubscription)
     userSubscriptionRepository.save = jest.fn().mockReturnValue(inviteeSubscription)
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
 
 
     subscriptionSettingService = {} as jest.Mocked<SubscriptionSettingServiceInterface>
     subscriptionSettingService = {} as jest.Mocked<SubscriptionSettingServiceInterface>
     subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn()
     subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn()
@@ -103,7 +103,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
       updatedAt: 1,
       updatedAt: 1,
       user: Promise.resolve(invitee),
       user: Promise.resolve(invitee),
     })
     })
-    expect(roleService.addUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
       inviteeSubscription,
       inviteeSubscription,
     )
     )
@@ -143,7 +143,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
       updatedAt: 3,
       updatedAt: 3,
       user: Promise.resolve(invitee),
       user: Promise.resolve(invitee),
     })
     })
-    expect(roleService.addUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
       inviteeSubscription,
       inviteeSubscription,
     )
     )
@@ -162,7 +162,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
 
 
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
   })
   })
 
 
@@ -180,7 +180,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
 
 
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
   })
   })
 
 
@@ -202,7 +202,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
 
 
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
   })
   })
 
 
@@ -219,7 +219,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
 
 
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
   })
   })
 
 
@@ -244,7 +244,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
 
 
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.addUserRole).not.toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
     expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled()
   })
   })
 })
 })

+ 1 - 1
packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts

@@ -100,7 +100,7 @@ export class AcceptSharedSubscriptionInvitation implements UseCaseInterface {
   }
   }
 
 
   private async addUserRole(user: User, subscriptionName: SubscriptionName): Promise<void> {
   private async addUserRole(user: User, subscriptionName: SubscriptionName): Promise<void> {
-    await this.roleService.addUserRole(user, subscriptionName)
+    await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName)
   }
   }
 
 
   private async createSharedSubscription(
   private async createSharedSubscription(

+ 2 - 2
packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts

@@ -34,7 +34,7 @@ describe('ActivatePremiumFeatures', () => {
     userSubscriptionRepository.save = jest.fn()
     userSubscriptionRepository.save = jest.fn()
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
 
 
     timer = {} as jest.Mocked<TimerInterface>
     timer = {} as jest.Mocked<TimerInterface>
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
@@ -73,7 +73,7 @@ describe('ActivatePremiumFeatures', () => {
     expect(result.isFailed()).toBe(false)
     expect(result.isFailed()).toBe(false)
 
 
     expect(userSubscriptionRepository.save).toHaveBeenCalled()
     expect(userSubscriptionRepository.save).toHaveBeenCalled()
-    expect(roleService.addUserRole).toHaveBeenCalled()
+    expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalled()
   })
   })
 
 
   it('should save a subscription with custom plan name and endsAt', async () => {
   it('should save a subscription with custom plan name and endsAt', async () => {

+ 1 - 1
packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts

@@ -53,7 +53,7 @@ export class ActivatePremiumFeatures implements UseCaseInterface<string> {
 
 
     await this.userSubscriptionRepository.save(subscription)
     await this.userSubscriptionRepository.save(subscription)
 
 
-    await this.roleService.addUserRole(user, subscriptionPlanName.value)
+    await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionPlanName.value)
 
 
     await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
     await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
       subscription,
       subscription,

+ 4 - 4
packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts

@@ -84,7 +84,7 @@ describe('CancelSharedSubscriptionInvitation', () => {
     userSubscriptionRepository.save = jest.fn()
     userSubscriptionRepository.save = jest.fn()
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.removeUserRole = jest.fn()
+    roleService.removeUserRoleBasedOnSubscription = jest.fn()
 
 
     timer = {} as jest.Mocked<TimerInterface>
     timer = {} as jest.Mocked<TimerInterface>
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
@@ -122,7 +122,7 @@ describe('CancelSharedSubscriptionInvitation', () => {
       endsAt: 1,
       endsAt: 1,
       user: Promise.resolve(invitee),
       user: Promise.resolve(invitee),
     })
     })
-    expect(roleService.removeUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
+    expect(roleService.removeUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
     expect(domainEventPublisher.publish).toHaveBeenCalled()
     expect(domainEventPublisher.publish).toHaveBeenCalled()
     expect(domainEventFactory.createSharedSubscriptionInvitationCanceledEvent).toHaveBeenCalledWith({
     expect(domainEventFactory.createSharedSubscriptionInvitationCanceledEvent).toHaveBeenCalledWith({
       inviteeIdentifier: '123',
       inviteeIdentifier: '123',
@@ -156,7 +156,7 @@ describe('CancelSharedSubscriptionInvitation', () => {
       inviteeIdentifierType: 'email',
       inviteeIdentifierType: 'email',
     })
     })
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.removeUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
+    expect(roleService.removeUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN')
   })
   })
 
 
   it('should not cancel a shared subscription invitation if it is not found', async () => {
   it('should not cancel a shared subscription invitation if it is not found', async () => {
@@ -204,7 +204,7 @@ describe('CancelSharedSubscriptionInvitation', () => {
       inviteeIdentifierType: 'email',
       inviteeIdentifierType: 'email',
     })
     })
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
     expect(userSubscriptionRepository.save).not.toHaveBeenCalled()
-    expect(roleService.removeUserRole).not.toHaveBeenCalled()
+    expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled()
   })
   })
 
 
   it('should not cancel a shared subscription invitation if inviter subscription is not found', async () => {
   it('should not cancel a shared subscription invitation if inviter subscription is not found', async () => {

+ 4 - 1
packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts

@@ -90,7 +90,10 @@ export class CancelSharedSubscriptionInvitation implements UseCaseInterface {
     if (invitee !== null) {
     if (invitee !== null) {
       await this.removeSharedSubscription(sharedSubscriptionInvitation.subscriptionId, invitee)
       await this.removeSharedSubscription(sharedSubscriptionInvitation.subscriptionId, invitee)
 
 
-      await this.roleService.removeUserRole(invitee, inviterUserSubscription.planName as SubscriptionName)
+      await this.roleService.removeUserRoleBasedOnSubscription(
+        invitee,
+        inviterUserSubscription.planName as SubscriptionName,
+      )
 
 
       await this.domainEventPublisher.publish(
       await this.domainEventPublisher.publish(
         this.domainEventFactory.createSharedSubscriptionInvitationCanceledEvent({
         this.domainEventFactory.createSharedSubscriptionInvitationCanceledEvent({

+ 39 - 0
packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts

@@ -10,6 +10,7 @@ import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
 import { CreateCrossServiceToken } from './CreateCrossServiceToken'
 import { CreateCrossServiceToken } from './CreateCrossServiceToken'
 import { GetSetting } from '../GetSetting/GetSetting'
 import { GetSetting } from '../GetSetting/GetSetting'
 import { Result } from '@standardnotes/domain-core'
 import { Result } from '@standardnotes/domain-core'
+import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
 
 
 describe('CreateCrossServiceToken', () => {
 describe('CreateCrossServiceToken', () => {
   let userProjector: ProjectorInterface<User>
   let userProjector: ProjectorInterface<User>
@@ -18,6 +19,7 @@ describe('CreateCrossServiceToken', () => {
   let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
   let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
   let userRepository: UserRepositoryInterface
   let userRepository: UserRepositoryInterface
   let getSettingUseCase: GetSetting
   let getSettingUseCase: GetSetting
+  let transitionStatusRepository: TransitionStatusRepositoryInterface
   const jwtTTL = 60
   const jwtTTL = 60
 
 
   let session: Session
   let session: Session
@@ -33,6 +35,7 @@ describe('CreateCrossServiceToken', () => {
       userRepository,
       userRepository,
       jwtTTL,
       jwtTTL,
       getSettingUseCase,
       getSettingUseCase,
+      transitionStatusRepository,
     )
     )
 
 
   beforeEach(() => {
   beforeEach(() => {
@@ -64,6 +67,9 @@ describe('CreateCrossServiceToken', () => {
 
 
     getSettingUseCase = {} as jest.Mocked<GetSetting>
     getSettingUseCase = {} as jest.Mocked<GetSetting>
     getSettingUseCase.execute = jest.fn().mockReturnValue(Result.ok({ setting: { value: '100' } }))
     getSettingUseCase.execute = jest.fn().mockReturnValue(Result.ok({ setting: { value: '100' } }))
+
+    transitionStatusRepository = {} as jest.Mocked<TransitionStatusRepositoryInterface>
+    transitionStatusRepository.getStatus = jest.fn().mockReturnValue('TO-DO')
   })
   })
 
 
   it('should create a cross service token for user', async () => {
   it('should create a cross service token for user', async () => {
@@ -87,6 +93,36 @@ describe('CreateCrossServiceToken', () => {
           email: 'test@test.te',
           email: 'test@test.te',
           uuid: '00000000-0000-0000-0000-000000000000',
           uuid: '00000000-0000-0000-0000-000000000000',
         },
         },
+        ongoing_transition: false,
+      },
+      60,
+    )
+  })
+
+  it('should create a cross service token for user that has an ongoing transaction', async () => {
+    transitionStatusRepository.getStatus = jest.fn().mockReturnValue('STARTED')
+
+    await createUseCase().execute({
+      user,
+      session,
+    })
+
+    expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
+      {
+        roles: [
+          {
+            name: 'role1',
+            uuid: '1-3-4',
+          },
+        ],
+        session: {
+          test: 'test',
+        },
+        user: {
+          email: 'test@test.te',
+          uuid: '00000000-0000-0000-0000-000000000000',
+        },
+        ongoing_transition: true,
       },
       },
       60,
       60,
     )
     )
@@ -109,6 +145,7 @@ describe('CreateCrossServiceToken', () => {
           email: 'test@test.te',
           email: 'test@test.te',
           uuid: '00000000-0000-0000-0000-000000000000',
           uuid: '00000000-0000-0000-0000-000000000000',
         },
         },
+        ongoing_transition: false,
       },
       },
       60,
       60,
     )
     )
@@ -131,6 +168,7 @@ describe('CreateCrossServiceToken', () => {
           email: 'test@test.te',
           email: 'test@test.te',
           uuid: '00000000-0000-0000-0000-000000000000',
           uuid: '00000000-0000-0000-0000-000000000000',
         },
         },
+        ongoing_transition: false,
       },
       },
       60,
       60,
     )
     )
@@ -180,6 +218,7 @@ describe('CreateCrossServiceToken', () => {
             email: 'test@test.te',
             email: 'test@test.te',
             uuid: '00000000-0000-0000-0000-000000000000',
             uuid: '00000000-0000-0000-0000-000000000000',
           },
           },
+          ongoing_transition: false,
         },
         },
         60,
         60,
       )
       )

+ 6 - 0
packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts

@@ -12,6 +12,7 @@ import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
 import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO'
 import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO'
 import { GetSetting } from '../GetSetting/GetSetting'
 import { GetSetting } from '../GetSetting/GetSetting'
 import { SettingName } from '@standardnotes/settings'
 import { SettingName } from '@standardnotes/settings'
+import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
 
 
 @injectable()
 @injectable()
 export class CreateCrossServiceToken implements UseCaseInterface<string> {
 export class CreateCrossServiceToken implements UseCaseInterface<string> {
@@ -24,6 +25,8 @@ export class CreateCrossServiceToken implements UseCaseInterface<string> {
     @inject(TYPES.Auth_AUTH_JWT_TTL) private jwtTTL: number,
     @inject(TYPES.Auth_AUTH_JWT_TTL) private jwtTTL: number,
     @inject(TYPES.Auth_GetSetting)
     @inject(TYPES.Auth_GetSetting)
     private getSettingUseCase: GetSetting,
     private getSettingUseCase: GetSetting,
+    @inject(TYPES.Auth_TransitionStatusRepository)
+    private transitionStatusRepository: TransitionStatusRepositoryInterface,
   ) {}
   ) {}
 
 
   async execute(dto: CreateCrossServiceTokenDTO): Promise<Result<string>> {
   async execute(dto: CreateCrossServiceTokenDTO): Promise<Result<string>> {
@@ -42,12 +45,15 @@ export class CreateCrossServiceToken implements UseCaseInterface<string> {
       return Result.fail(`Could not find user with uuid ${dto.userUuid}`)
       return Result.fail(`Could not find user with uuid ${dto.userUuid}`)
     }
     }
 
 
+    const transitionStatus = await this.transitionStatusRepository.getStatus(user.uuid)
+
     const roles = await user.roles
     const roles = await user.roles
 
 
     const authTokenData: CrossServiceTokenData = {
     const authTokenData: CrossServiceTokenData = {
       user: this.projectUser(user),
       user: this.projectUser(user),
       roles: this.projectRoles(roles),
       roles: this.projectRoles(roles),
       shared_vault_owner_context: undefined,
       shared_vault_owner_context: undefined,
+      ongoing_transition: transitionStatus === 'STARTED',
     }
     }
 
 
     if (dto.sharedVaultOwnerContext !== undefined) {
     if (dto.sharedVaultOwnerContext !== undefined) {

+ 108 - 0
packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.spec.ts

@@ -0,0 +1,108 @@
+import { RoleName } from '@standardnotes/domain-core'
+import { Role } from '../../Role/Role'
+import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
+import { User } from '../../User/User'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { GetTransitionStatus } from './GetTransitionStatus'
+
+describe('GetTransitionStatus', () => {
+  let transitionStatusRepository: TransitionStatusRepositoryInterface
+  let userRepository: UserRepositoryInterface
+  let user: User
+  let role: Role
+
+  const createUseCase = () => new GetTransitionStatus(transitionStatusRepository, userRepository)
+
+  beforeEach(() => {
+    transitionStatusRepository = {} as jest.Mocked<TransitionStatusRepositoryInterface>
+    transitionStatusRepository.getStatus = jest.fn().mockReturnValue(null)
+
+    role = {} as jest.Mocked<Role>
+    role.name = RoleName.NAMES.CoreUser
+
+    user = {
+      uuid: '00000000-0000-0000-0000-000000000000',
+      email: 'test@test.te',
+    } as jest.Mocked<User>
+    user.roles = Promise.resolve([role])
+
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
+  })
+
+  it('returns transition status FINISHED', async () => {
+    role.name = RoleName.NAMES.TransitionUser
+    user.roles = Promise.resolve([role])
+    userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual('FINISHED')
+  })
+
+  it('returns transition status STARTED', async () => {
+    const useCase = createUseCase()
+
+    transitionStatusRepository.getStatus = jest.fn().mockReturnValue('STARTED')
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual('STARTED')
+  })
+
+  it('returns transition status TO-DO', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual('TO-DO')
+  })
+
+  it('returns transition status FAILED', async () => {
+    const useCase = createUseCase()
+
+    transitionStatusRepository.getStatus = jest.fn().mockReturnValue('FAILED')
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual('FAILED')
+  })
+
+  it('return error if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'invalid',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Given value is not a valid uuid: invalid')
+  })
+
+  it('return error if user not found', async () => {
+    const useCase = createUseCase()
+
+    userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('User not found.')
+  })
+})

+ 39 - 0
packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.ts

@@ -0,0 +1,39 @@
+import { Result, RoleName, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
+import { GetTransitionStatusDTO } from './GetTransitionStatusDTO'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
+
+export class GetTransitionStatus implements UseCaseInterface<'TO-DO' | 'STARTED' | 'FINISHED' | 'FAILED'> {
+  constructor(
+    private transitionStatusRepository: TransitionStatusRepositoryInterface,
+    private userRepository: UserRepositoryInterface,
+  ) {}
+
+  async execute(dto: GetTransitionStatusDTO): Promise<Result<'TO-DO' | 'STARTED' | 'FINISHED' | 'FAILED'>> {
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const user = await this.userRepository.findOneByUuid(userUuid)
+    if (user === null) {
+      return Result.fail('User not found.')
+    }
+
+    const roles = await user.roles
+    for (const role of roles) {
+      if (role.name === RoleName.NAMES.TransitionUser) {
+        return Result.ok('FINISHED')
+      }
+    }
+
+    const transitionStatus = await this.transitionStatusRepository.getStatus(userUuid.value)
+    if (transitionStatus === null) {
+      return Result.ok('TO-DO')
+    }
+
+    return Result.ok(transitionStatus)
+  }
+}

+ 3 - 0
packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatusDTO.ts

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

+ 1 - 1
packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts

@@ -93,7 +93,7 @@ describe('UpdateSetting', () => {
     settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(true)
     settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(true)
 
 
     roleService = {} as jest.Mocked<RoleServiceInterface>
     roleService = {} as jest.Mocked<RoleServiceInterface>
-    roleService.addUserRole = jest.fn()
+    roleService.addUserRoleBasedOnSubscription = jest.fn()
 
 
     logger = {} as jest.Mocked<Logger>
     logger = {} as jest.Mocked<Logger>
     logger.debug = jest.fn()
     logger.debug = jest.fn()

+ 64 - 0
packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.spec.ts

@@ -0,0 +1,64 @@
+import { RoleName, Uuid } from '@standardnotes/domain-core'
+
+import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
+import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
+import { UpdateTransitionStatus } from './UpdateTransitionStatus'
+
+describe('UpdateTransitionStatus', () => {
+  let transitionStatusRepository: TransitionStatusRepositoryInterface
+  let roleService: RoleServiceInterface
+
+  const createUseCase = () => new UpdateTransitionStatus(transitionStatusRepository, roleService)
+
+  beforeEach(() => {
+    transitionStatusRepository = {} as jest.Mocked<TransitionStatusRepositoryInterface>
+    transitionStatusRepository.removeStatus = jest.fn()
+    transitionStatusRepository.updateStatus = jest.fn()
+
+    roleService = {} as jest.Mocked<RoleServiceInterface>
+    roleService.addRoleToUser = jest.fn()
+  })
+
+  it('should remove transition status and add TransitionUser role', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      status: 'FINISHED',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(transitionStatusRepository.removeStatus).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000000')
+    expect(roleService.addRoleToUser).toHaveBeenCalledWith(
+      Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      RoleName.create(RoleName.NAMES.TransitionUser).getValue(),
+    )
+  })
+
+  it('should update transition status', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      status: 'STARTED',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(transitionStatusRepository.updateStatus).toHaveBeenCalledWith(
+      '00000000-0000-0000-0000-000000000000',
+      'STARTED',
+    )
+  })
+
+  it('should return error when user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'invalid',
+      status: 'STARTED',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Given value is not a valid uuid: invalid')
+  })
+})

+ 31 - 0
packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.ts

@@ -0,0 +1,31 @@
+import { Result, RoleName, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface'
+import { UpdateTransitionStatusDTO } from './UpdateTransitionStatusDTO'
+import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
+
+export class UpdateTransitionStatus implements UseCaseInterface<void> {
+  constructor(
+    private transitionStatusRepository: TransitionStatusRepositoryInterface,
+    private roleService: RoleServiceInterface,
+  ) {}
+
+  async execute(dto: UpdateTransitionStatusDTO): Promise<Result<void>> {
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    if (dto.status === 'FINISHED') {
+      await this.transitionStatusRepository.removeStatus(dto.userUuid)
+
+      await this.roleService.addRoleToUser(userUuid, RoleName.create(RoleName.NAMES.TransitionUser).getValue())
+
+      return Result.ok()
+    }
+
+    await this.transitionStatusRepository.updateStatus(dto.userUuid, dto.status)
+
+    return Result.ok()
+  }
+}

+ 4 - 0
packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatusDTO.ts

@@ -0,0 +1,4 @@
+export interface UpdateTransitionStatusDTO {
+  userUuid: string
+  status: 'STARTED' | 'FINISHED' | 'FAILED'
+}

+ 19 - 0
packages/auth/src/Infra/InMemory/InMemoryTransitionStatusRepository.ts

@@ -0,0 +1,19 @@
+import { TransitionStatusRepositoryInterface } from '../../Domain/Transition/TransitionStatusRepositoryInterface'
+
+export class InMemoryTransitionStatusRepository implements TransitionStatusRepositoryInterface {
+  private statuses: Map<string, 'STARTED' | 'FAILED'> = new Map()
+
+  async updateStatus(userUuid: string, status: 'STARTED' | 'FAILED'): Promise<void> {
+    this.statuses.set(userUuid, status)
+  }
+
+  async removeStatus(userUuid: string): Promise<void> {
+    this.statuses.delete(userUuid)
+  }
+
+  async getStatus(userUuid: string): Promise<'STARTED' | 'FAILED' | null> {
+    const status = this.statuses.get(userUuid) || null
+
+    return status
+  }
+}

+ 6 - 0
packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts

@@ -14,6 +14,7 @@ import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempt
 import { InviteToSharedSubscription } from '../../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription'
 import { InviteToSharedSubscription } from '../../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription'
 import { UpdateUser } from '../../Domain/UseCase/UpdateUser'
 import { UpdateUser } from '../../Domain/UseCase/UpdateUser'
 import { User } from '../../Domain/User/User'
 import { User } from '../../Domain/User/User'
+import { GetTransitionStatus } from '../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
 
 
 describe('AnnotatedUsersController', () => {
 describe('AnnotatedUsersController', () => {
   let updateUser: UpdateUser
   let updateUser: UpdateUser
@@ -24,6 +25,7 @@ describe('AnnotatedUsersController', () => {
   let increaseLoginAttempts: IncreaseLoginAttempts
   let increaseLoginAttempts: IncreaseLoginAttempts
   let changeCredentials: ChangeCredentials
   let changeCredentials: ChangeCredentials
   let inviteToSharedSubscription: InviteToSharedSubscription
   let inviteToSharedSubscription: InviteToSharedSubscription
+  let getTransitionStatus: GetTransitionStatus
 
 
   let request: express.Request
   let request: express.Request
   let response: express.Response
   let response: express.Response
@@ -38,6 +40,7 @@ describe('AnnotatedUsersController', () => {
       clearLoginAttempts,
       clearLoginAttempts,
       increaseLoginAttempts,
       increaseLoginAttempts,
       changeCredentials,
       changeCredentials,
+      getTransitionStatus,
     )
     )
 
 
   beforeEach(() => {
   beforeEach(() => {
@@ -69,6 +72,9 @@ describe('AnnotatedUsersController', () => {
     inviteToSharedSubscription = {} as jest.Mocked<InviteToSharedSubscription>
     inviteToSharedSubscription = {} as jest.Mocked<InviteToSharedSubscription>
     inviteToSharedSubscription.execute = jest.fn()
     inviteToSharedSubscription.execute = jest.fn()
 
 
+    getTransitionStatus = {} as jest.Mocked<GetTransitionStatus>
+    getTransitionStatus.execute = jest.fn()
+
     request = {
     request = {
       headers: {},
       headers: {},
       body: {},
       body: {},

+ 8 - 0
packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts

@@ -18,6 +18,7 @@ import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts'
 import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts'
 import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts'
 import { ChangeCredentials } from '../../Domain/UseCase/ChangeCredentials/ChangeCredentials'
 import { ChangeCredentials } from '../../Domain/UseCase/ChangeCredentials/ChangeCredentials'
 import { BaseUsersController } from './Base/BaseUsersController'
 import { BaseUsersController } from './Base/BaseUsersController'
+import { GetTransitionStatus } from '../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
 
 
 @controller('/users')
 @controller('/users')
 export class AnnotatedUsersController extends BaseUsersController {
 export class AnnotatedUsersController extends BaseUsersController {
@@ -29,6 +30,7 @@ export class AnnotatedUsersController extends BaseUsersController {
     @inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts,
     @inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts,
     @inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts,
     @inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts,
     @inject(TYPES.Auth_ChangeCredentials) override changeCredentialsUseCase: ChangeCredentials,
     @inject(TYPES.Auth_ChangeCredentials) override changeCredentialsUseCase: ChangeCredentials,
+    @inject(TYPES.Auth_GetTransitionStatus) override getTransitionStatusUseCase: GetTransitionStatus,
   ) {
   ) {
     super(
     super(
       updateUser,
       updateUser,
@@ -38,6 +40,7 @@ export class AnnotatedUsersController extends BaseUsersController {
       clearLoginAttempts,
       clearLoginAttempts,
       increaseLoginAttempts,
       increaseLoginAttempts,
       changeCredentialsUseCase,
       changeCredentialsUseCase,
+      getTransitionStatusUseCase,
     )
     )
   }
   }
 
 
@@ -51,6 +54,11 @@ export class AnnotatedUsersController extends BaseUsersController {
     return super.keyParams(request)
     return super.keyParams(request)
   }
   }
 
 
+  @httpGet('/transition-status', TYPES.Auth_RequiredCrossServiceTokenMiddleware)
+  override async transitionStatus(request: Request, response: Response): Promise<results.JsonResult> {
+    return super.transitionStatus(request, response)
+  }
+
   @httpDelete('/:userUuid', TYPES.Auth_RequiredCrossServiceTokenMiddleware)
   @httpDelete('/:userUuid', TYPES.Auth_RequiredCrossServiceTokenMiddleware)
   override async deleteAccount(request: Request, response: Response): Promise<results.JsonResult> {
   override async deleteAccount(request: Request, response: Response): Promise<results.JsonResult> {
     return super.deleteAccount(request, response)
     return super.deleteAccount(request, response)

+ 26 - 0
packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts

@@ -10,6 +10,7 @@ import { GetUserSubscription } from '../../../Domain/UseCase/GetUserSubscription
 import { IncreaseLoginAttempts } from '../../../Domain/UseCase/IncreaseLoginAttempts'
 import { IncreaseLoginAttempts } from '../../../Domain/UseCase/IncreaseLoginAttempts'
 import { UpdateUser } from '../../../Domain/UseCase/UpdateUser'
 import { UpdateUser } from '../../../Domain/UseCase/UpdateUser'
 import { ErrorTag } from '@standardnotes/responses'
 import { ErrorTag } from '@standardnotes/responses'
+import { GetTransitionStatus } from '../../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus'
 
 
 export class BaseUsersController extends BaseHttpController {
 export class BaseUsersController extends BaseHttpController {
   constructor(
   constructor(
@@ -20,6 +21,7 @@ export class BaseUsersController extends BaseHttpController {
     protected clearLoginAttempts: ClearLoginAttempts,
     protected clearLoginAttempts: ClearLoginAttempts,
     protected increaseLoginAttempts: IncreaseLoginAttempts,
     protected increaseLoginAttempts: IncreaseLoginAttempts,
     protected changeCredentialsUseCase: ChangeCredentials,
     protected changeCredentialsUseCase: ChangeCredentials,
+    protected getTransitionStatusUseCase: GetTransitionStatus,
     private controllerContainer?: ControllerContainerInterface,
     private controllerContainer?: ControllerContainerInterface,
   ) {
   ) {
     super()
     super()
@@ -30,6 +32,7 @@ export class BaseUsersController extends BaseHttpController {
       this.controllerContainer.register('auth.users.getSubscription', this.getSubscription.bind(this))
       this.controllerContainer.register('auth.users.getSubscription', this.getSubscription.bind(this))
       this.controllerContainer.register('auth.users.updateCredentials', this.changeCredentials.bind(this))
       this.controllerContainer.register('auth.users.updateCredentials', this.changeCredentials.bind(this))
       this.controllerContainer.register('auth.users.delete', this.deleteAccount.bind(this))
       this.controllerContainer.register('auth.users.delete', this.deleteAccount.bind(this))
+      this.controllerContainer.register('auth.users.transition-status', this.transitionStatus.bind(this))
     }
     }
   }
   }
 
 
@@ -103,6 +106,29 @@ export class BaseUsersController extends BaseHttpController {
     return this.json(result.keyParams)
     return this.json(result.keyParams)
   }
   }
 
 
+  async transitionStatus(_request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.getTransitionStatusUseCase.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({
+      status: result.getValue(),
+    })
+  }
+
   async deleteAccount(request: Request, response: Response): Promise<results.JsonResult> {
   async deleteAccount(request: Request, response: Response): Promise<results.JsonResult> {
     if (request.params.userUuid !== response.locals.user.uuid) {
     if (request.params.userUuid !== response.locals.user.uuid) {
       return this.json(
       return this.json(

+ 23 - 0
packages/auth/src/Infra/Redis/RedisTransitionStatusRepository.ts

@@ -0,0 +1,23 @@
+import * as IORedis from 'ioredis'
+
+import { TransitionStatusRepositoryInterface } from '../../Domain/Transition/TransitionStatusRepositoryInterface'
+
+export class RedisTransitionStatusRepository implements TransitionStatusRepositoryInterface {
+  private readonly PREFIX = 'transition'
+
+  constructor(private redisClient: IORedis.Redis) {}
+
+  async updateStatus(userUuid: string, status: 'STARTED' | 'FAILED'): Promise<void> {
+    await this.redisClient.set(`${this.PREFIX}:${userUuid}`, status)
+  }
+
+  async removeStatus(userUuid: string): Promise<void> {
+    await this.redisClient.del(`${this.PREFIX}:${userUuid}`)
+  }
+
+  async getStatus(userUuid: string): Promise<'STARTED' | 'FAILED' | null> {
+    const status = (await this.redisClient.get(`${this.PREFIX}:${userUuid}`)) as 'STARTED' | 'FAILED' | null
+
+    return status
+  }
+}

+ 8 - 0
packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEvent.ts

@@ -0,0 +1,8 @@
+import { DomainEventInterface } from './DomainEventInterface'
+
+import { TransitionStatusUpdatedEventPayload } from './TransitionStatusUpdatedEventPayload'
+
+export interface TransitionStatusUpdatedEvent extends DomainEventInterface {
+  type: 'TRANSITION_STATUS_UPDATED'
+  payload: TransitionStatusUpdatedEventPayload
+}

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

@@ -0,0 +1,4 @@
+export interface TransitionStatusUpdatedEventPayload {
+  userUuid: string
+  status: 'STARTED' | 'FINISHED' | 'FAILED'
+}

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

@@ -90,6 +90,8 @@ export * from './Event/SubscriptionRevertRequestedEvent'
 export * from './Event/SubscriptionRevertRequestedEventPayload'
 export * from './Event/SubscriptionRevertRequestedEventPayload'
 export * from './Event/SubscriptionSyncRequestedEvent'
 export * from './Event/SubscriptionSyncRequestedEvent'
 export * from './Event/SubscriptionSyncRequestedEventPayload'
 export * from './Event/SubscriptionSyncRequestedEventPayload'
+export * from './Event/TransitionStatusUpdatedEvent'
+export * from './Event/TransitionStatusUpdatedEventPayload'
 export * from './Event/UserDisabledSessionUserAgentLoggingEvent'
 export * from './Event/UserDisabledSessionUserAgentLoggingEvent'
 export * from './Event/UserDisabledSessionUserAgentLoggingEventPayload'
 export * from './Event/UserDisabledSessionUserAgentLoggingEventPayload'
 export * from './Event/UserEmailChangedEvent'
 export * from './Event/UserEmailChangedEvent'

+ 1 - 0
packages/security/src/Domain/Token/CrossServiceTokenData.ts

@@ -20,4 +20,5 @@ export type CrossServiceTokenData = {
     refresh_expiration: string
     refresh_expiration: string
   }
   }
   extensionKey?: string
   extensionKey?: string
+  ongoing_transition?: boolean
 }
 }

+ 41 - 7
packages/syncing-server/src/Bootstrap/Container.ts

@@ -156,6 +156,8 @@ import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryRe
 import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
 import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
 import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
 import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
 import { SharedVaultFileMovedEventHandler } from '../Domain/Handler/SharedVaultFileMovedEventHandler'
 import { SharedVaultFileMovedEventHandler } from '../Domain/Handler/SharedVaultFileMovedEventHandler'
+import { TransitionStatusUpdatedEventHandler } from '../Domain/Handler/TransitionStatusUpdatedEventHandler'
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
 
 
 export class ContainerConfigLoader {
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -770,14 +772,27 @@ export class ContainerConfigLoader {
         ),
         ),
       )
       )
     container
     container
-      .bind(TransitionItemsFromPrimaryToSecondaryDatabaseForUser)
+      .bind<TransitionItemsFromPrimaryToSecondaryDatabaseForUser>(
+        TYPES.Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser,
+      )
       .toConstantValue(
       .toConstantValue(
         new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
         new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
           container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
           container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
           isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
           isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
+          container.get<TimerInterface>(TYPES.Sync_Timer),
           container.get<Logger>(TYPES.Sync_Logger),
           container.get<Logger>(TYPES.Sync_Logger),
         ),
         ),
       )
       )
+    container
+      .bind<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
+        TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
+      )
+      .toConstantValue(
+        new TriggerTransitionFromPrimaryToSecondaryDatabaseForUser(
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+        ),
+      )
 
 
     // Services
     // Services
     container
     container
@@ -882,6 +897,18 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Sync_Logger),
           container.get<winston.Logger>(TYPES.Sync_Logger),
         ),
         ),
       )
       )
+    container
+      .bind<TransitionStatusUpdatedEventHandler>(TYPES.Sync_TransitionStatusUpdatedEventHandler)
+      .toConstantValue(
+        new TransitionStatusUpdatedEventHandler(
+          container.get<TransitionItemsFromPrimaryToSecondaryDatabaseForUser>(
+            TYPES.Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser,
+          ),
+          container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
+          container.get<Logger>(TYPES.Sync_Logger),
+        ),
+      )
 
 
     // Services
     // Services
     container.bind<ContentDecoder>(TYPES.Sync_ContentDecoder).toDynamicValue(() => new ContentDecoder())
     container.bind<ContentDecoder>(TYPES.Sync_ContentDecoder).toDynamicValue(() => new ContentDecoder())
@@ -916,6 +943,10 @@ export class ContainerConfigLoader {
         'SHARED_VAULT_FILE_MOVED',
         'SHARED_VAULT_FILE_MOVED',
         container.get<SharedVaultFileMovedEventHandler>(TYPES.Sync_SharedVaultFileMovedEventHandler),
         container.get<SharedVaultFileMovedEventHandler>(TYPES.Sync_SharedVaultFileMovedEventHandler),
       ],
       ],
+      [
+        'TRANSITION_STATUS_UPDATED',
+        container.get<TransitionStatusUpdatedEventHandler>(TYPES.Sync_TransitionStatusUpdatedEventHandler),
+      ],
     ])
     ])
     if (!isConfiguredForHomeServer) {
     if (!isConfiguredForHomeServer) {
       container.bind(TYPES.Sync_AUTH_SERVER_URL).toConstantValue(env.get('AUTH_SERVER_URL'))
       container.bind(TYPES.Sync_AUTH_SERVER_URL).toConstantValue(env.get('AUTH_SERVER_URL'))
@@ -990,12 +1021,15 @@ export class ContainerConfigLoader {
         .bind<BaseItemsController>(TYPES.Sync_BaseItemsController)
         .bind<BaseItemsController>(TYPES.Sync_BaseItemsController)
         .toConstantValue(
         .toConstantValue(
           new BaseItemsController(
           new BaseItemsController(
-            container.get(TYPES.Sync_SyncItems),
-            container.get(TYPES.Sync_CheckIntegrity),
-            container.get(TYPES.Sync_GetItem),
-            container.get(TYPES.Sync_ItemHttpMapper),
-            container.get(TYPES.Sync_SyncResponseFactoryResolver),
-            container.get(TYPES.Sync_ControllerContainer),
+            container.get<SyncItems>(TYPES.Sync_SyncItems),
+            container.get<CheckIntegrity>(TYPES.Sync_CheckIntegrity),
+            container.get<GetItem>(TYPES.Sync_GetItem),
+            container.get<TriggerTransitionFromPrimaryToSecondaryDatabaseForUser>(
+              TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
+            ),
+            container.get<MapperInterface<Item, ItemHttpRepresentation>>(TYPES.Sync_ItemHttpMapper),
+            container.get<SyncResponseFactoryResolverInterface>(TYPES.Sync_SyncResponseFactoryResolver),
+            container.get<ControllerContainerInterface>(TYPES.Sync_ControllerContainer),
           ),
           ),
         )
         )
       container
       container

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

@@ -83,6 +83,9 @@ const TYPES = {
   Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
   Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
     'Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser',
     'Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser',
   ),
   ),
+  Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
+    'Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser',
+  ),
   // Handlers
   // Handlers
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
@@ -91,6 +94,7 @@ const TYPES = {
   Sync_SharedVaultFileRemovedEventHandler: Symbol.for('Sync_SharedVaultFileRemovedEventHandler'),
   Sync_SharedVaultFileRemovedEventHandler: Symbol.for('Sync_SharedVaultFileRemovedEventHandler'),
   Sync_SharedVaultFileUploadedEventHandler: Symbol.for('Sync_SharedVaultFileUploadedEventHandler'),
   Sync_SharedVaultFileUploadedEventHandler: Symbol.for('Sync_SharedVaultFileUploadedEventHandler'),
   Sync_SharedVaultFileMovedEventHandler: Symbol.for('Sync_SharedVaultFileMovedEventHandler'),
   Sync_SharedVaultFileMovedEventHandler: Symbol.for('Sync_SharedVaultFileMovedEventHandler'),
+  Sync_TransitionStatusUpdatedEventHandler: Symbol.for('Sync_TransitionStatusUpdatedEventHandler'),
   // Services
   // Services
   Sync_ContentDecoder: Symbol.for('Sync_ContentDecoder'),
   Sync_ContentDecoder: Symbol.for('Sync_ContentDecoder'),
   Sync_DomainEventPublisher: Symbol.for('Sync_DomainEventPublisher'),
   Sync_DomainEventPublisher: Symbol.for('Sync_DomainEventPublisher'),

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

@@ -6,6 +6,7 @@ import {
   ItemDumpedEvent,
   ItemDumpedEvent,
   ItemRevisionCreationRequestedEvent,
   ItemRevisionCreationRequestedEvent,
   RevisionsCopyRequestedEvent,
   RevisionsCopyRequestedEvent,
+  TransitionStatusUpdatedEvent,
 } from '@standardnotes/domain-events'
 } from '@standardnotes/domain-events'
 import { TimerInterface } from '@standardnotes/time'
 import { TimerInterface } from '@standardnotes/time'
 import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
 import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
@@ -13,6 +14,24 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
 export class DomainEventFactory implements DomainEventFactoryInterface {
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(private timer: TimerInterface) {}
   constructor(private timer: TimerInterface) {}
 
 
+  createTransitionStatusUpdatedEvent(userUuid: string, status: 'FINISHED' | 'FAILED'): TransitionStatusUpdatedEvent {
+    return {
+      type: 'TRANSITION_STATUS_UPDATED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.SyncingServer,
+      },
+      payload: {
+        userUuid,
+        status,
+      },
+    }
+  }
+
   createRevisionsCopyRequestedEvent(
   createRevisionsCopyRequestedEvent(
     userUuid: string,
     userUuid: string,
     dto: {
     dto: {

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

@@ -4,9 +4,14 @@ import {
   ItemDumpedEvent,
   ItemDumpedEvent,
   ItemRevisionCreationRequestedEvent,
   ItemRevisionCreationRequestedEvent,
   RevisionsCopyRequestedEvent,
   RevisionsCopyRequestedEvent,
+  TransitionStatusUpdatedEvent,
 } from '@standardnotes/domain-events'
 } from '@standardnotes/domain-events'
 
 
 export interface DomainEventFactoryInterface {
 export interface DomainEventFactoryInterface {
+  createTransitionStatusUpdatedEvent(
+    userUuid: string,
+    status: 'STARTED' | 'FAILED' | 'FINISHED',
+  ): TransitionStatusUpdatedEvent
   createEmailRequestedEvent(dto: {
   createEmailRequestedEvent(dto: {
     userEmail: string
     userEmail: string
     messageIdentifier: string
     messageIdentifier: string

+ 39 - 0
packages/syncing-server/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts

@@ -0,0 +1,39 @@
+import {
+  DomainEventHandlerInterface,
+  DomainEventPublisherInterface,
+  TransitionStatusUpdatedEvent,
+} from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
+import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
+
+export class TransitionStatusUpdatedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private transitionItemsFromPrimaryToSecondaryDatabaseForUser: TransitionItemsFromPrimaryToSecondaryDatabaseForUser,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: TransitionStatusUpdatedEvent): Promise<void> {
+    if (event.payload.status === 'STARTED') {
+      const result = await this.transitionItemsFromPrimaryToSecondaryDatabaseForUser.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(event.payload.userUuid, 'FAILED'),
+        )
+
+        return
+      }
+
+      await this.domainEventPublisher.publish(
+        this.domainEventFactory.createTransitionStatusUpdatedEvent(event.payload.userUuid, 'FINISHED'),
+      )
+    }
+  }
+}

+ 19 - 1
packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.spec.ts

@@ -4,6 +4,7 @@ import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
 import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
 import { Item } from '../../../Item/Item'
 import { Item } from '../../../Item/Item'
 import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
 import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { TimerInterface } from '@standardnotes/time'
 
 
 describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
 describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
   let primaryItemRepository: ItemRepositoryInterface
   let primaryItemRepository: ItemRepositoryInterface
@@ -13,9 +14,15 @@ describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
   let primaryItem2: Item
   let primaryItem2: Item
   let secondaryItem1: Item
   let secondaryItem1: Item
   let secondaryItem2: Item
   let secondaryItem2: Item
+  let timer: TimerInterface
 
 
   const createUseCase = () =>
   const createUseCase = () =>
-    new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(primaryItemRepository, secondaryItemRepository, logger)
+    new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
+      primaryItemRepository,
+      secondaryItemRepository,
+      timer,
+      logger,
+    )
 
 
   beforeEach(() => {
   beforeEach(() => {
     primaryItem1 = Item.create(
     primaryItem1 = Item.create(
@@ -107,6 +114,17 @@ describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
 
 
     logger = {} as jest.Mocked<Logger>
     logger = {} as jest.Mocked<Logger>
     logger.error = jest.fn()
     logger.error = jest.fn()
+    logger.info = jest.fn()
+
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
+    timer.convertMicrosecondsToTimeStructure = jest.fn().mockReturnValue({
+      days: 0,
+      hours: 0,
+      minutes: 0,
+      seconds: 0,
+      milliseconds: 0,
+    })
   })
   })
 
 
   describe('successfull transition', () => {
   describe('successfull transition', () => {

+ 13 - 0
packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts

@@ -4,11 +4,13 @@ import { Logger } from 'winston'
 import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO'
 import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { ItemQuery } from '../../../Item/ItemQuery'
 import { ItemQuery } from '../../../Item/ItemQuery'
+import { TimerInterface } from '@standardnotes/time'
 
 
 export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
 export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
   constructor(
   constructor(
     private primaryItemRepository: ItemRepositoryInterface,
     private primaryItemRepository: ItemRepositoryInterface,
     private secondaryItemRepository: ItemRepositoryInterface | null,
     private secondaryItemRepository: ItemRepositoryInterface | null,
+    private timer: TimerInterface,
     private logger: Logger,
     private logger: Logger,
   ) {}
   ) {}
 
 
@@ -23,6 +25,8 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
     }
     }
     const userUuid = userUuidOrError.getValue()
     const userUuid = userUuidOrError.getValue()
 
 
+    const migrationTimeStart = this.timer.getTimestampInMicroseconds()
+
     const migrationResult = await this.migrateItemsForUser(userUuid)
     const migrationResult = await this.migrateItemsForUser(userUuid)
     if (migrationResult.isFailed()) {
     if (migrationResult.isFailed()) {
       const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
       const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
@@ -54,6 +58,15 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use
       )
       )
     }
     }
 
 
+    const migrationTimeEnd = this.timer.getTimestampInMicroseconds()
+
+    const migrationDuration = migrationTimeEnd - migrationTimeStart
+    const migrationDurationTimeStructure = this.timer.convertMicrosecondsToTimeStructure(migrationDuration)
+
+    this.logger.info(
+      `Transitioned items for user ${userUuid.value} in ${migrationDurationTimeStructure.hours}h ${migrationDurationTimeStructure.minutes}m ${migrationDurationTimeStructure.seconds}s ${migrationDurationTimeStructure.milliseconds}ms`,
+    )
+
     return Result.ok()
     return Result.ok()
   }
   }
 
 

+ 30 - 0
packages/syncing-server/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()
+  })
+})

+ 20 - 0
packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.ts

@@ -0,0 +1,20 @@
+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(dto.userUuid, 'STARTED')
+
+    await this.domainEventPubliser.publish(event)
+
+    return Result.ok()
+  }
+}

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

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

+ 16 - 1
packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedItemsController.ts

@@ -11,6 +11,7 @@ import { SyncItems } from '../../Domain/UseCase/Syncing/SyncItems/SyncItems'
 import { BaseItemsController } from './Base/BaseItemsController'
 import { BaseItemsController } from './Base/BaseItemsController'
 import { MapperInterface } from '@standardnotes/domain-core'
 import { MapperInterface } from '@standardnotes/domain-core'
 import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
 import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation'
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
 
 
 @controller('/items', TYPES.Sync_AuthMiddleware)
 @controller('/items', TYPES.Sync_AuthMiddleware)
 export class AnnotatedItemsController extends BaseItemsController {
 export class AnnotatedItemsController extends BaseItemsController {
@@ -18,11 +19,20 @@ export class AnnotatedItemsController extends BaseItemsController {
     @inject(TYPES.Sync_SyncItems) override syncItems: SyncItems,
     @inject(TYPES.Sync_SyncItems) override syncItems: SyncItems,
     @inject(TYPES.Sync_CheckIntegrity) override checkIntegrity: CheckIntegrity,
     @inject(TYPES.Sync_CheckIntegrity) override checkIntegrity: CheckIntegrity,
     @inject(TYPES.Sync_GetItem) override getItem: GetItem,
     @inject(TYPES.Sync_GetItem) override getItem: GetItem,
+    @inject(TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser)
+    override triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
     @inject(TYPES.Sync_ItemHttpMapper) override itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
     @inject(TYPES.Sync_ItemHttpMapper) override itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
     @inject(TYPES.Sync_SyncResponseFactoryResolver)
     @inject(TYPES.Sync_SyncResponseFactoryResolver)
     override syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
     override syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
   ) {
   ) {
-    super(syncItems, checkIntegrity, getItem, itemHttpMapper, syncResponseFactoryResolver)
+    super(
+      syncItems,
+      checkIntegrity,
+      getItem,
+      triggerTransitionFromPrimaryToSecondaryDatabaseForUser,
+      itemHttpMapper,
+      syncResponseFactoryResolver,
+    )
   }
   }
 
 
   @httpPost('/sync')
   @httpPost('/sync')
@@ -35,6 +45,11 @@ export class AnnotatedItemsController extends BaseItemsController {
     return super.checkItemsIntegrity(request, response)
     return super.checkItemsIntegrity(request, response)
   }
   }
 
 
+  @httpPost('/transition')
+  override async transition(request: Request, response: Response): Promise<results.JsonResult> {
+    return super.transition(request, response)
+  }
+
   @httpGet('/:uuid')
   @httpGet('/:uuid')
   override async getSingleItem(request: Request, response: Response): Promise<results.JsonResult> {
   override async getSingleItem(request: Request, response: Response): Promise<results.JsonResult> {
     return super.getSingleItem(request, response)
     return super.getSingleItem(request, response)

+ 26 - 0
packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts

@@ -12,12 +12,14 @@ import { ApiVersion } from '../../../Domain/Api/ApiVersion'
 import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
 import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
 import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
 import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
 import { ItemHash } from '../../../Domain/Item/ItemHash'
 import { ItemHash } from '../../../Domain/Item/ItemHash'
+import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser'
 
 
 export class BaseItemsController extends BaseHttpController {
 export class BaseItemsController extends BaseHttpController {
   constructor(
   constructor(
     protected syncItems: SyncItems,
     protected syncItems: SyncItems,
     protected checkIntegrity: CheckIntegrity,
     protected checkIntegrity: CheckIntegrity,
     protected getItem: GetItem,
     protected getItem: GetItem,
+    protected triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser,
     protected itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
     protected itemHttpMapper: MapperInterface<Item, ItemHttpRepresentation>,
     protected syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
     protected syncResponseFactoryResolver: SyncResponseFactoryResolverInterface,
     private controllerContainer?: ControllerContainerInterface,
     private controllerContainer?: ControllerContainerInterface,
@@ -28,10 +30,15 @@ export class BaseItemsController extends BaseHttpController {
       this.controllerContainer.register('sync.items.sync', this.sync.bind(this))
       this.controllerContainer.register('sync.items.sync', this.sync.bind(this))
       this.controllerContainer.register('sync.items.check_integrity', this.checkItemsIntegrity.bind(this))
       this.controllerContainer.register('sync.items.check_integrity', this.checkItemsIntegrity.bind(this))
       this.controllerContainer.register('sync.items.get_item', this.getSingleItem.bind(this))
       this.controllerContainer.register('sync.items.get_item', this.getSingleItem.bind(this))
+      this.controllerContainer.register('sync.items.transition', this.transition.bind(this))
     }
     }
   }
   }
 
 
   async sync(request: Request, response: Response): Promise<results.JsonResult> {
   async sync(request: Request, response: Response): Promise<results.JsonResult> {
+    if (response.locals.ongoingTransition === true) {
+      throw new Error('Cannot sync during transition')
+    }
+
     const itemHashes: ItemHash[] = []
     const itemHashes: ItemHash[] = []
     if ('items' in request.body) {
     if ('items' in request.body) {
       for (const itemHashInput of request.body.items) {
       for (const itemHashInput of request.body.items) {
@@ -105,6 +112,25 @@ export class BaseItemsController extends BaseHttpController {
     })
     })
   }
   }
 
 
+  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 })
+  }
+
   async getSingleItem(request: Request, response: Response): Promise<results.JsonResult> {
   async getSingleItem(request: Request, response: Response): Promise<results.JsonResult> {
     const result = await this.getItem.execute({
     const result = await this.getItem.execute({
       userUuid: response.locals.user.uuid,
       userUuid: response.locals.user.uuid,

+ 1 - 0
packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts

@@ -26,6 +26,7 @@ export class InversifyExpressAuthMiddleware extends BaseMiddleware {
       response.locals.session = decodedToken.session
       response.locals.session = decodedToken.session
       response.locals.readOnlyAccess = decodedToken.session?.readonly_access ?? false
       response.locals.readOnlyAccess = decodedToken.session?.readonly_access ?? false
       response.locals.sharedVaultOwnerContext = decodedToken.shared_vault_owner_context
       response.locals.sharedVaultOwnerContext = decodedToken.shared_vault_owner_context
+      response.locals.ongoingTransition = decodedToken.ongoing_transition
 
 
       return next()
       return next()
     } catch (error) {
     } catch (error) {

+ 1 - 0
packages/time/src/Domain/Time/TimeStructure.ts

@@ -3,4 +3,5 @@ export type TimeStructure = {
   hours: number
   hours: number
   minutes: number
   minutes: number
   seconds: number
   seconds: number
+  milliseconds: number
 }
 }

+ 1 - 0
packages/time/src/Domain/Time/Timer.spec.ts

@@ -122,6 +122,7 @@ describe('Timer', () => {
       hours: 1,
       hours: 1,
       minutes: 50,
       minutes: 50,
       seconds: 50,
       seconds: 50,
+      milliseconds: 982,
     })
     })
   })
   })
 })
 })

+ 4 - 0
packages/time/src/Domain/Time/Timer.ts

@@ -26,11 +26,15 @@ export class Timer implements TimerInterface {
     const secondsLeftOver = microseconds % Time.MicrosecondsInAMinute
     const secondsLeftOver = microseconds % Time.MicrosecondsInAMinute
     const seconds = Math.floor(secondsLeftOver / Time.MicrosecondsInASecond)
     const seconds = Math.floor(secondsLeftOver / Time.MicrosecondsInASecond)
 
 
+    const millisecondsLeftOver = microseconds % Time.MicrosecondsInASecond
+    const milliseconds = Math.floor(millisecondsLeftOver / Time.MicrosecondsInAMillisecond)
+
     return {
     return {
       days,
       days,
       hours,
       hours,
       minutes,
       minutes,
       seconds,
       seconds,
+      milliseconds,
     }
     }
   }
   }