diff --git a/docker/localstack_bootstrap.sh b/docker/localstack_bootstrap.sh index b070b314b..bfb95d767 100755 --- a/docker/localstack_bootstrap.sh +++ b/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_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_RESULT" + 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) echo "linking done:" diff --git a/packages/api-gateway/src/Controller/AuthMiddleware.ts b/packages/api-gateway/src/Controller/AuthMiddleware.ts index c6d1baa9b..224b2152d 100644 --- a/packages/api-gateway/src/Controller/AuthMiddleware.ts +++ b/packages/api-gateway/src/Controller/AuthMiddleware.ts @@ -39,7 +39,7 @@ export abstract class AuthMiddleware extends BaseMiddleware { crossServiceToken = await this.crossServiceTokenCache.get(cacheKey) } - if (crossServiceToken === null) { + if (this.crossServiceTokenIsEmptyOrRequiresRevalidation(crossServiceToken)) { const authResponse = await this.serviceProxy.validateSession({ authorization: authHeaderValue, sharedVaultOwnerContext: sharedVaultOwnerContextHeaderValue, @@ -55,12 +55,14 @@ export abstract class AuthMiddleware extends BaseMiddleware { response.locals.authToken = crossServiceToken - const decodedToken = verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] }) + const decodedToken = ( + verify(response.locals.authToken, this.jwtSecret, { algorithms: ['HS256'] }) + ) if (this.crossServiceTokenCacheTTL && !crossServiceTokenFetchedFromCache) { await this.crossServiceTokenCache.set({ key: cacheKey, - encodedCrossServiceToken: crossServiceToken, + encodedCrossServiceToken: response.locals.authToken, expiresAtInSeconds: this.getCrossServiceTokenCacheExpireTimestamp(decodedToken), userUuid: decodedToken.user.uuid, }) @@ -126,4 +128,14 @@ export abstract class AuthMiddleware extends BaseMiddleware { return Math.min(crossServiceTokenDefaultCacheExpiration, sessionAccessExpiration, sessionRefreshExpiration) } + + private crossServiceTokenIsEmptyOrRequiresRevalidation(crossServiceToken: string | null) { + if (crossServiceToken === null) { + return true + } + + const decodedToken = verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] }) + + return decodedToken.ongoing_transition === true + } } diff --git a/packages/api-gateway/src/Controller/v1/ItemsController.ts b/packages/api-gateway/src/Controller/v1/ItemsController.ts index 68dc05da2..a10f100a9 100644 --- a/packages/api-gateway/src/Controller/v1/ItemsController.ts +++ b/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 { + await this.serviceProxy.callSyncingServer( + request, + response, + this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'items/transition'), + request.body, + ) + } + @httpGet('/:uuid') async getItem(request: Request, response: Response): Promise { await this.serviceProxy.callSyncingServer( diff --git a/packages/api-gateway/src/Controller/v1/UsersController.ts b/packages/api-gateway/src/Controller/v1/UsersController.ts index f96d7a540..d5a8f0068 100644 --- a/packages/api-gateway/src/Controller/v1/UsersController.ts +++ b/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 { + await this.httpService.callAuthServer( + request, + response, + this.endpointResolver.resolveEndpointOrMethodIdentifier('GET', 'users/transition-status'), + ) + } + @httpGet('/:userId/params', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware) async getKeyParams(request: Request, response: Response): Promise { await this.httpService.callAuthServer( diff --git a/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts b/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts index e1709fc8f..7b5991fec 100644 --- a/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts +++ b/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts @@ -42,7 +42,8 @@ export class EndpointResolver implements EndpointResolverInterface { // Users Controller ['[PATCH]:users/:userId', 'auth.users.update'], ['[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'], ['[POST]:listed', 'auth.users.createListedAccount'], ['[POST]:auth', 'auth.users.register'], @@ -58,6 +59,7 @@ export class EndpointResolver implements EndpointResolverInterface { // Syncing Server ['[POST]:items/sync', 'sync.items.sync'], ['[POST]:items/check-integrity', 'sync.items.check_integrity'], + ['[POST]:items/transition', 'sync.items.transition'], ['[GET]:items/:uuid', 'sync.items.get_item'], // Revisions Controller V2 ['[GET]:items/:itemUuid/revisions', 'revisions.revisions.getRevisions'], diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index ce0dbdb2f..5a0a9234b 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -257,6 +257,12 @@ import { UpdateStorageQuotaUsedForUser } from '../Domain/UseCase/UpdateStorageQu import { SharedVaultFileUploadedEventHandler } from '../Domain/Handler/SharedVaultFileUploadedEventHandler' import { SharedVaultFileRemovedEventHandler } from '../Domain/Handler/SharedVaultFileRemovedEventHandler' 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 { async load(configuration?: { @@ -610,6 +616,9 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_Timer), ), ) + container + .bind(TYPES.Auth_TransitionStatusRepository) + .toConstantValue(new InMemoryTransitionStatusRepository()) } else { container.bind(TYPES.Auth_PKCERepository).to(RedisPKCERepository) container.bind(TYPES.Auth_LockRepository).to(LockRepository) @@ -622,6 +631,9 @@ export class ContainerConfigLoader { container .bind(TYPES.Auth_SubscriptionTokenRepository) .to(RedisSubscriptionTokenRepository) + container + .bind(TYPES.Auth_TransitionStatusRepository) + .toConstantValue(new RedisTransitionStatusRepository(container.get(TYPES.Auth_Redis))) } // Services @@ -898,6 +910,22 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_SubscriptionSettingService), ), ) + container + .bind(TYPES.Auth_UpdateTransitionStatus) + .toConstantValue( + new UpdateTransitionStatus( + container.get(TYPES.Auth_TransitionStatusRepository), + container.get(TYPES.Auth_RoleService), + ), + ) + container + .bind(TYPES.Auth_GetTransitionStatus) + .toConstantValue( + new GetTransitionStatus( + container.get(TYPES.Auth_TransitionStatusRepository), + container.get(TYPES.Auth_UserRepository), + ), + ) // Controller container @@ -1039,6 +1067,14 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_Logger), ), ) + container + .bind(TYPES.Auth_TransitionStatusUpdatedEventHandler) + .toConstantValue( + new TransitionStatusUpdatedEventHandler( + container.get(TYPES.Auth_UpdateTransitionStatus), + container.get(TYPES.Auth_Logger), + ), + ) const eventHandlers: Map = new Map([ ['USER_REGISTERED', container.get(TYPES.Auth_UserRegisteredEventHandler)], @@ -1070,6 +1106,7 @@ export class ContainerConfigLoader { ['PREDICATE_VERIFICATION_REQUESTED', container.get(TYPES.Auth_PredicateVerificationRequestedEventHandler)], ['EMAIL_SUBSCRIPTION_UNSUBSCRIBED', container.get(TYPES.Auth_EmailSubscriptionUnsubscribedEventHandler)], ['PAYMENTS_ACCOUNT_DELETED', container.get(TYPES.Auth_PaymentsAccountDeletedEventHandler)], + ['TRANSITION_STATUS_UPDATED', container.get(TYPES.Auth_TransitionStatusUpdatedEventHandler)], ]) if (isConfiguredForHomeServer) { @@ -1174,14 +1211,15 @@ export class ContainerConfigLoader { .bind(TYPES.Auth_BaseUsersController) .toConstantValue( 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(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_GetTransitionStatus), + container.get(TYPES.Auth_ControllerContainer), ), ) container diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index 21b366479..c82f92445 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -35,6 +35,7 @@ const TYPES = { Auth_AuthenticatorRepository: Symbol.for('Auth_AuthenticatorRepository'), Auth_AuthenticatorChallengeRepository: Symbol.for('Auth_AuthenticatorChallengeRepository'), Auth_CacheEntryRepository: Symbol.for('Auth_CacheEntryRepository'), + Auth_TransitionStatusRepository: Symbol.for('Auth_TransitionStatusRepository'), // ORM Auth_ORMOfflineSettingRepository: Symbol.for('Auth_ORMOfflineSettingRepository'), Auth_ORMOfflineUserSubscriptionRepository: Symbol.for('Auth_ORMOfflineUserSubscriptionRepository'), @@ -154,6 +155,8 @@ const TYPES = { Auth_SignInWithRecoveryCodes: Symbol.for('Auth_SignInWithRecoveryCodes'), Auth_GetUserKeyParamsRecovery: Symbol.for('Auth_GetUserKeyParamsRecovery'), Auth_UpdateStorageQuotaUsedForUser: Symbol.for('Auth_UpdateStorageQuotaUsedForUser'), + Auth_UpdateTransitionStatus: Symbol.for('Auth_UpdateTransitionStatus'), + Auth_GetTransitionStatus: Symbol.for('Auth_GetTransitionStatus'), // Handlers Auth_UserRegisteredEventHandler: Symbol.for('Auth_UserRegisteredEventHandler'), Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'), @@ -182,6 +185,7 @@ const TYPES = { Auth_PredicateVerificationRequestedEventHandler: Symbol.for('Auth_PredicateVerificationRequestedEventHandler'), Auth_EmailSubscriptionUnsubscribedEventHandler: Symbol.for('Auth_EmailSubscriptionUnsubscribedEventHandler'), Auth_PaymentsAccountDeletedEventHandler: Symbol.for('Auth_PaymentsAccountDeletedEventHandler'), + Auth_TransitionStatusUpdatedEventHandler: Symbol.for('Auth_TransitionStatusUpdatedEventHandler'), // Services Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'), Auth_SessionService: Symbol.for('Auth_SessionService'), diff --git a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts index 77553a460..6d1a50c55 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts @@ -60,7 +60,7 @@ describe('SubscriptionExpiredEventHandler', () => { offlineUserSubscriptionRepository.updateEndsAt = jest.fn() roleService = {} as jest.Mocked - roleService.removeUserRole = jest.fn() + roleService.removeUserRoleBasedOnSubscription = jest.fn() timestamp = dayjs.utc().valueOf() @@ -86,7 +86,7 @@ describe('SubscriptionExpiredEventHandler', () => { it('should update the user role', async () => { 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 () => { @@ -108,7 +108,7 @@ describe('SubscriptionExpiredEventHandler', () => { await createHandler().handle(event) - expect(roleService.removeUserRole).not.toHaveBeenCalled() + expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled() }) @@ -117,7 +117,7 @@ describe('SubscriptionExpiredEventHandler', () => { await createHandler().handle(event) - expect(roleService.removeUserRole).not.toHaveBeenCalled() + expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts index d737ac458..12ade71e4 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts @@ -48,7 +48,7 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf private async removeRoleFromSubscriptionUsers(subscriptionId: number, subscriptionName: string): Promise { const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId) for (const userSubscription of userSubscriptions) { - await this.roleService.removeUserRole(await userSubscription.user, subscriptionName) + await this.roleService.removeUserRoleBasedOnSubscription(await userSubscription.user, subscriptionName) } } diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts index 43c0f9e8e..cbadabd14 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts @@ -72,7 +72,7 @@ describe('SubscriptionPurchasedEventHandler', () => { offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription) roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() roleService.setOfflineUserRole = jest.fn() subscriptionExpiresAt = timestamp + 365 * 1000 @@ -106,7 +106,7 @@ describe('SubscriptionPurchasedEventHandler', () => { it('should update the user role', async () => { 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 () => { @@ -162,7 +162,7 @@ describe('SubscriptionPurchasedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) @@ -171,7 +171,7 @@ describe('SubscriptionPurchasedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts index c923c8158..7b7fb8090 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts @@ -70,7 +70,7 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte } private async addUserRole(user: User, subscriptionName: string): Promise { - await this.roleService.addUserRole(user, subscriptionName) + await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName) } private async createSubscription( diff --git a/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts index ac77a14cd..fc9f6195d 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts @@ -62,7 +62,7 @@ describe('SubscriptionReassignedEventHandler', () => { userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() subscriptionExpiresAt = timestamp + 365 * 1000 @@ -100,7 +100,7 @@ describe('SubscriptionReassignedEventHandler', () => { it('should update the user role', async () => { await createHandler().handle(event) - expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan) + expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(user, SubscriptionName.ProPlan) }) it('should create subscription', async () => { @@ -146,7 +146,7 @@ describe('SubscriptionReassignedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) @@ -155,7 +155,7 @@ describe('SubscriptionReassignedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts index cfecc7615..07ac0020c 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts @@ -67,7 +67,7 @@ export class SubscriptionReassignedEventHandler implements DomainEventHandlerInt } private async addUserRole(user: User, subscriptionName: string): Promise { - await this.roleService.addUserRole(user, subscriptionName) + await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName) } private async createSubscription( diff --git a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts index ed18ae24c..8cb77177d 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts @@ -61,7 +61,7 @@ describe('SubscriptionRefundedEventHandler', () => { offlineUserSubscriptionRepository.updateEndsAt = jest.fn() roleService = {} as jest.Mocked - roleService.removeUserRole = jest.fn() + roleService.removeUserRoleBasedOnSubscription = jest.fn() timestamp = dayjs.utc().valueOf() @@ -87,7 +87,7 @@ describe('SubscriptionRefundedEventHandler', () => { it('should update the user role', async () => { 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 () => { @@ -109,7 +109,7 @@ describe('SubscriptionRefundedEventHandler', () => { await createHandler().handle(event) - expect(roleService.removeUserRole).not.toHaveBeenCalled() + expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled() }) @@ -118,7 +118,7 @@ describe('SubscriptionRefundedEventHandler', () => { await createHandler().handle(event) - expect(roleService.removeUserRole).not.toHaveBeenCalled() + expect(roleService.removeUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts index f541381d1..deacd22fb 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts @@ -48,7 +48,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter private async removeRoleFromSubscriptionUsers(subscriptionId: number, subscriptionName: string): Promise { const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId) for (const userSubscription of userSubscriptions) { - await this.roleService.removeUserRole(await userSubscription.user, subscriptionName) + await this.roleService.removeUserRoleBasedOnSubscription(await userSubscription.user, subscriptionName) } } diff --git a/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts index fc0378e66..4f638f8a7 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts @@ -67,7 +67,7 @@ describe('SubscriptionRenewedEventHandler', () => { offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription) roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() roleService.setOfflineUserRole = jest.fn() timestamp = dayjs.utc().valueOf() @@ -107,7 +107,7 @@ describe('SubscriptionRenewedEventHandler', () => { it('should update the user role', async () => { 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 () => { @@ -123,7 +123,7 @@ describe('SubscriptionRenewedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) @@ -132,7 +132,7 @@ describe('SubscriptionRenewedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) @@ -143,7 +143,7 @@ describe('SubscriptionRenewedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts index 2b8e78d32..edff0b617 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts @@ -73,7 +73,7 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf for (const userSubscription of userSubscriptions) { const user = await userSubscription.user - await this.roleService.addUserRole(user, subscriptionName) + await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName) } } diff --git a/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts index 590f7369a..8a2bb6d9b 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts @@ -88,7 +88,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => { }) roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() roleService.setOfflineUserRole = jest.fn() subscriptionExpiresAt = timestamp + 365 * 1000 @@ -121,7 +121,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => { it('should update the user role', async () => { 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 () => { @@ -243,7 +243,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) @@ -252,7 +252,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => { await createHandler().handle(event) - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts index aff013b7d..8a4b1ec60 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts @@ -93,7 +93,7 @@ export class SubscriptionSyncRequestedEventHandler implements DomainEventHandler event.payload.timestamp, ) - await this.roleService.addUserRole(user, event.payload.subscriptionName) + await this.roleService.addUserRoleBasedOnSubscription(user, event.payload.subscriptionName) await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(userSubscription) diff --git a/packages/auth/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts b/packages/auth/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts new file mode 100644 index 000000000..6f365b50c --- /dev/null +++ b/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 { + 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}`) + } + } +} diff --git a/packages/auth/src/Domain/Role/RoleService.spec.ts b/packages/auth/src/Domain/Role/RoleService.spec.ts index 84589ded0..e535afcc9 100644 --- a/packages/auth/src/Domain/Role/RoleService.spec.ts +++ b/packages/auth/src/Domain/Role/RoleService.spec.ts @@ -5,7 +5,7 @@ import { User } from '../User/User' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' import { SubscriptionName } from '@standardnotes/common' -import { RoleName } from '@standardnotes/domain-core' +import { RoleName, Uuid } from '@standardnotes/domain-core' import { Role } from '../Role/Role' import { ClientServiceInterface } from '../Client/ClientServiceInterface' @@ -81,9 +81,44 @@ describe('RoleService', () => { logger = {} as jest.Mocked logger.info = jest.fn() logger.warn = jest.fn() + logger.error = jest.fn() }) describe('adding roles', () => { + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([basicRole]), + } as jest.Mocked + + 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(() => { user = { uuid: '123', @@ -96,7 +131,7 @@ describe('RoleService', () => { }) 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) user.roles = Promise.resolve([basicRole, proRole]) @@ -112,7 +147,7 @@ describe('RoleService', () => { 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(userRepository.save).toHaveBeenCalledWith(user) @@ -120,7 +155,7 @@ describe('RoleService', () => { }) it('should send websockets event', async () => { - await createService().addUserRole(user, SubscriptionName.ProPlan) + await createService().addUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan) 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 () => { 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() }) it('should not add role if no role exists for role name', async () => { roleRepository.findOneByName = jest.fn().mockReturnValue(null) - await createService().addUserRole(user, SubscriptionName.ProPlan) + await createService().addUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan) expect(userRepository.save).not.toHaveBeenCalled() }) @@ -169,7 +204,7 @@ describe('RoleService', () => { }) }) - describe('removing roles', () => { + describe('removing roles based on subscription', () => { beforeEach(() => { user = { uuid: '123', @@ -182,13 +217,13 @@ describe('RoleService', () => { }) it('should remove role from user', async () => { - await createService().removeUserRole(user, SubscriptionName.ProPlan) + await createService().removeUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan) expect(userRepository.save).toHaveBeenCalledWith(user) }) it('should send websockets event', async () => { - await createService().removeUserRole(user, SubscriptionName.ProPlan) + await createService().removeUserRoleBasedOnSubscription(user, SubscriptionName.ProPlan) 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 () => { 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() }) diff --git a/packages/auth/src/Domain/Role/RoleService.ts b/packages/auth/src/Domain/Role/RoleService.ts index f7321a3ab..51699f600 100644 --- a/packages/auth/src/Domain/Role/RoleService.ts +++ b/packages/auth/src/Domain/Role/RoleService.ts @@ -13,7 +13,7 @@ import { RoleToSubscriptionMapInterface } from './RoleToSubscriptionMapInterface import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { Role } from './Role' import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' -import { Uuid } from '@standardnotes/domain-core' +import { RoleName, Uuid } from '@standardnotes/domain-core' @injectable() export class RoleService implements RoleServiceInterface { @@ -54,7 +54,18 @@ export class RoleService implements RoleServiceInterface { return false } - async addUserRole(user: User, subscriptionName: SubscriptionName): Promise { + async addRoleToUser(userUuid: Uuid, roleName: RoleName): Promise { + 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}`) + + return + } + + await this.addToExistingRoles(user, roleName.value) + } + + async addUserRoleBasedOnSubscription(user: User, subscriptionName: SubscriptionName): Promise { const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName) if (roleName === undefined) { @@ -62,25 +73,7 @@ export class RoleService implements RoleServiceInterface { return } - const role = await this.roleRepository.findOneByName(roleName) - - if (role === null) { - this.logger.warn(`Could not find role for role name: ${roleName}`) - return - } - - const rolesMap = new Map() - 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) + await this.addToExistingRoles(user, roleName) } async setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise { @@ -107,7 +100,7 @@ export class RoleService implements RoleServiceInterface { await this.offlineUserSubscriptionRepository.save(offlineUserSubscription) } - async removeUserRole(user: User, subscriptionName: SubscriptionName): Promise { + async removeUserRoleBasedOnSubscription(user: User, subscriptionName: SubscriptionName): Promise { const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName) if (roleName === undefined) { @@ -120,4 +113,27 @@ export class RoleService implements RoleServiceInterface { await this.userRepository.save(user) await this.webSocketsClientService.sendUserRolesChangedEvent(user) } + + private async addToExistingRoles(user: User, roleNameString: string): Promise { + 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() + 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) + } } diff --git a/packages/auth/src/Domain/Role/RoleServiceInterface.ts b/packages/auth/src/Domain/Role/RoleServiceInterface.ts index 9f2fb5514..05ef77f9e 100644 --- a/packages/auth/src/Domain/Role/RoleServiceInterface.ts +++ b/packages/auth/src/Domain/Role/RoleServiceInterface.ts @@ -1,10 +1,12 @@ import { PermissionName } from '@standardnotes/features' +import { RoleName, Uuid } from '@standardnotes/domain-core' import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' import { User } from '../User/User' export interface RoleServiceInterface { - addUserRole(user: User, subscriptionName: string): Promise + addRoleToUser(userUuid: Uuid, roleName: RoleName): Promise + addUserRoleBasedOnSubscription(user: User, subscriptionName: string): Promise setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise - removeUserRole(user: User, subscriptionName: string): Promise + removeUserRoleBasedOnSubscription(user: User, subscriptionName: string): Promise userHasPermission(userUuid: string, permissionName: PermissionName): Promise } diff --git a/packages/auth/src/Domain/Transition/TransitionStatusRepositoryInterface.ts b/packages/auth/src/Domain/Transition/TransitionStatusRepositoryInterface.ts new file mode 100644 index 000000000..cd09a7248 --- /dev/null +++ b/packages/auth/src/Domain/Transition/TransitionStatusRepositoryInterface.ts @@ -0,0 +1,5 @@ +export interface TransitionStatusRepositoryInterface { + updateStatus(userUuid: string, status: 'STARTED' | 'FAILED'): Promise + removeStatus(userUuid: string): Promise + getStatus(userUuid: string): Promise<'STARTED' | 'FAILED' | null> +} diff --git a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts index 97c185793..398fad22d 100644 --- a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts +++ b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts @@ -69,7 +69,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { userSubscriptionRepository.save = jest.fn().mockReturnValue(inviteeSubscription) roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() subscriptionSettingService = {} as jest.Mocked subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn() @@ -103,7 +103,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { updatedAt: 1, user: Promise.resolve(invitee), }) - expect(roleService.addUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') + expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( inviteeSubscription, ) @@ -143,7 +143,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { updatedAt: 3, user: Promise.resolve(invitee), }) - expect(roleService.addUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') + expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( inviteeSubscription, ) @@ -162,7 +162,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() }) @@ -180,7 +180,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() }) @@ -202,7 +202,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() }) @@ -219,7 +219,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() }) @@ -244,7 +244,7 @@ describe('AcceptSharedSubscriptionInvitation', () => { expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() expect(userSubscriptionRepository.save).not.toHaveBeenCalled() - expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).not.toHaveBeenCalled() expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() }) }) diff --git a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts index 67cbe9b1c..d84172d5e 100644 --- a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts +++ b/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 { - await this.roleService.addUserRole(user, subscriptionName) + await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionName) } private async createSharedSubscription( diff --git a/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts b/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts index 9f8d9f746..07fb3c0be 100644 --- a/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts +++ b/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts @@ -34,7 +34,7 @@ describe('ActivatePremiumFeatures', () => { userSubscriptionRepository.save = jest.fn() roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() timer = {} as jest.Mocked timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789) @@ -73,7 +73,7 @@ describe('ActivatePremiumFeatures', () => { expect(result.isFailed()).toBe(false) expect(userSubscriptionRepository.save).toHaveBeenCalled() - expect(roleService.addUserRole).toHaveBeenCalled() + expect(roleService.addUserRoleBasedOnSubscription).toHaveBeenCalled() }) it('should save a subscription with custom plan name and endsAt', async () => { diff --git a/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts b/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts index c21c9f134..cef44534c 100644 --- a/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts +++ b/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts @@ -53,7 +53,7 @@ export class ActivatePremiumFeatures implements UseCaseInterface { await this.userSubscriptionRepository.save(subscription) - await this.roleService.addUserRole(user, subscriptionPlanName.value) + await this.roleService.addUserRoleBasedOnSubscription(user, subscriptionPlanName.value) await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( subscription, diff --git a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts index a2126c7f4..3d6dfeb47 100644 --- a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts +++ b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts @@ -84,7 +84,7 @@ describe('CancelSharedSubscriptionInvitation', () => { userSubscriptionRepository.save = jest.fn() roleService = {} as jest.Mocked - roleService.removeUserRole = jest.fn() + roleService.removeUserRoleBasedOnSubscription = jest.fn() timer = {} as jest.Mocked timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) @@ -122,7 +122,7 @@ describe('CancelSharedSubscriptionInvitation', () => { endsAt: 1, user: Promise.resolve(invitee), }) - expect(roleService.removeUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') + expect(roleService.removeUserRoleBasedOnSubscription).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') expect(domainEventPublisher.publish).toHaveBeenCalled() expect(domainEventFactory.createSharedSubscriptionInvitationCanceledEvent).toHaveBeenCalledWith({ inviteeIdentifier: '123', @@ -156,7 +156,7 @@ describe('CancelSharedSubscriptionInvitation', () => { inviteeIdentifierType: 'email', }) 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 () => { @@ -204,7 +204,7 @@ describe('CancelSharedSubscriptionInvitation', () => { inviteeIdentifierType: 'email', }) 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 () => { diff --git a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts index 124654c08..e1b59c09f 100644 --- a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts +++ b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts @@ -90,7 +90,10 @@ export class CancelSharedSubscriptionInvitation implements UseCaseInterface { if (invitee !== null) { 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( this.domainEventFactory.createSharedSubscriptionInvitationCanceledEvent({ diff --git a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts index de28e172b..9e6fe80a8 100644 --- a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts +++ b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts @@ -10,6 +10,7 @@ import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { CreateCrossServiceToken } from './CreateCrossServiceToken' import { GetSetting } from '../GetSetting/GetSetting' import { Result } from '@standardnotes/domain-core' +import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface' describe('CreateCrossServiceToken', () => { let userProjector: ProjectorInterface @@ -18,6 +19,7 @@ describe('CreateCrossServiceToken', () => { let tokenEncoder: TokenEncoderInterface let userRepository: UserRepositoryInterface let getSettingUseCase: GetSetting + let transitionStatusRepository: TransitionStatusRepositoryInterface const jwtTTL = 60 let session: Session @@ -33,6 +35,7 @@ describe('CreateCrossServiceToken', () => { userRepository, jwtTTL, getSettingUseCase, + transitionStatusRepository, ) beforeEach(() => { @@ -64,6 +67,9 @@ describe('CreateCrossServiceToken', () => { getSettingUseCase = {} as jest.Mocked getSettingUseCase.execute = jest.fn().mockReturnValue(Result.ok({ setting: { value: '100' } })) + + transitionStatusRepository = {} as jest.Mocked + transitionStatusRepository.getStatus = jest.fn().mockReturnValue('TO-DO') }) it('should create a cross service token for user', async () => { @@ -87,6 +93,36 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', 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, ) @@ -109,6 +145,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + ongoing_transition: false, }, 60, ) @@ -131,6 +168,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + ongoing_transition: false, }, 60, ) @@ -180,6 +218,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + ongoing_transition: false, }, 60, ) diff --git a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts index 75a1a5ce7..dde33b02a 100644 --- a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts +++ b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts @@ -12,6 +12,7 @@ import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO' import { GetSetting } from '../GetSetting/GetSetting' import { SettingName } from '@standardnotes/settings' +import { TransitionStatusRepositoryInterface } from '../../Transition/TransitionStatusRepositoryInterface' @injectable() export class CreateCrossServiceToken implements UseCaseInterface { @@ -24,6 +25,8 @@ export class CreateCrossServiceToken implements UseCaseInterface { @inject(TYPES.Auth_AUTH_JWT_TTL) private jwtTTL: number, @inject(TYPES.Auth_GetSetting) private getSettingUseCase: GetSetting, + @inject(TYPES.Auth_TransitionStatusRepository) + private transitionStatusRepository: TransitionStatusRepositoryInterface, ) {} async execute(dto: CreateCrossServiceTokenDTO): Promise> { @@ -42,12 +45,15 @@ export class CreateCrossServiceToken implements UseCaseInterface { 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 authTokenData: CrossServiceTokenData = { user: this.projectUser(user), roles: this.projectRoles(roles), shared_vault_owner_context: undefined, + ongoing_transition: transitionStatus === 'STARTED', } if (dto.sharedVaultOwnerContext !== undefined) { diff --git a/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.spec.ts b/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.spec.ts new file mode 100644 index 000000000..67cc31e9c --- /dev/null +++ b/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 + transitionStatusRepository.getStatus = jest.fn().mockReturnValue(null) + + role = {} as jest.Mocked + role.name = RoleName.NAMES.CoreUser + + user = { + uuid: '00000000-0000-0000-0000-000000000000', + email: 'test@test.te', + } as jest.Mocked + user.roles = Promise.resolve([role]) + + userRepository = {} as jest.Mocked + 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.') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.ts b/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatus.ts new file mode 100644 index 000000000..a7c258865 --- /dev/null +++ b/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> { + 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) + } +} diff --git a/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatusDTO.ts b/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatusDTO.ts new file mode 100644 index 000000000..74b90f297 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetTransitionStatus/GetTransitionStatusDTO.ts @@ -0,0 +1,3 @@ +export interface GetTransitionStatusDTO { + userUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts index cfe6bb5e8..3a68417dd 100644 --- a/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts +++ b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts @@ -93,7 +93,7 @@ describe('UpdateSetting', () => { settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(true) roleService = {} as jest.Mocked - roleService.addUserRole = jest.fn() + roleService.addUserRoleBasedOnSubscription = jest.fn() logger = {} as jest.Mocked logger.debug = jest.fn() diff --git a/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.spec.ts b/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.spec.ts new file mode 100644 index 000000000..3a6cdd13d --- /dev/null +++ b/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 + transitionStatusRepository.removeStatus = jest.fn() + transitionStatusRepository.updateStatus = jest.fn() + + roleService = {} as jest.Mocked + 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') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.ts b/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatus.ts new file mode 100644 index 000000000..e68cedb06 --- /dev/null +++ b/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 { + constructor( + private transitionStatusRepository: TransitionStatusRepositoryInterface, + private roleService: RoleServiceInterface, + ) {} + + async execute(dto: UpdateTransitionStatusDTO): Promise> { + 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() + } +} diff --git a/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatusDTO.ts b/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatusDTO.ts new file mode 100644 index 000000000..a5b07df66 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateTransitionStatus/UpdateTransitionStatusDTO.ts @@ -0,0 +1,4 @@ +export interface UpdateTransitionStatusDTO { + userUuid: string + status: 'STARTED' | 'FINISHED' | 'FAILED' +} diff --git a/packages/auth/src/Infra/InMemory/InMemoryTransitionStatusRepository.ts b/packages/auth/src/Infra/InMemory/InMemoryTransitionStatusRepository.ts new file mode 100644 index 000000000..b0354ec10 --- /dev/null +++ b/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 = new Map() + + async updateStatus(userUuid: string, status: 'STARTED' | 'FAILED'): Promise { + this.statuses.set(userUuid, status) + } + + async removeStatus(userUuid: string): Promise { + this.statuses.delete(userUuid) + } + + async getStatus(userUuid: string): Promise<'STARTED' | 'FAILED' | null> { + const status = this.statuses.get(userUuid) || null + + return status + } +} diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts index 26544ca0d..06811232b 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.spec.ts +++ b/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 { UpdateUser } from '../../Domain/UseCase/UpdateUser' import { User } from '../../Domain/User/User' +import { GetTransitionStatus } from '../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus' describe('AnnotatedUsersController', () => { let updateUser: UpdateUser @@ -24,6 +25,7 @@ describe('AnnotatedUsersController', () => { let increaseLoginAttempts: IncreaseLoginAttempts let changeCredentials: ChangeCredentials let inviteToSharedSubscription: InviteToSharedSubscription + let getTransitionStatus: GetTransitionStatus let request: express.Request let response: express.Response @@ -38,6 +40,7 @@ describe('AnnotatedUsersController', () => { clearLoginAttempts, increaseLoginAttempts, changeCredentials, + getTransitionStatus, ) beforeEach(() => { @@ -69,6 +72,9 @@ describe('AnnotatedUsersController', () => { inviteToSharedSubscription = {} as jest.Mocked inviteToSharedSubscription.execute = jest.fn() + getTransitionStatus = {} as jest.Mocked + getTransitionStatus.execute = jest.fn() + request = { headers: {}, body: {}, diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts index 5eb931720..49bf8de42 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts @@ -18,6 +18,7 @@ import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts' import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts' import { ChangeCredentials } from '../../Domain/UseCase/ChangeCredentials/ChangeCredentials' import { BaseUsersController } from './Base/BaseUsersController' +import { GetTransitionStatus } from '../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus' @controller('/users') export class AnnotatedUsersController extends BaseUsersController { @@ -29,6 +30,7 @@ export class AnnotatedUsersController extends BaseUsersController { @inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts, @inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts, @inject(TYPES.Auth_ChangeCredentials) override changeCredentialsUseCase: ChangeCredentials, + @inject(TYPES.Auth_GetTransitionStatus) override getTransitionStatusUseCase: GetTransitionStatus, ) { super( updateUser, @@ -38,6 +40,7 @@ export class AnnotatedUsersController extends BaseUsersController { clearLoginAttempts, increaseLoginAttempts, changeCredentialsUseCase, + getTransitionStatusUseCase, ) } @@ -51,6 +54,11 @@ export class AnnotatedUsersController extends BaseUsersController { return super.keyParams(request) } + @httpGet('/transition-status', TYPES.Auth_RequiredCrossServiceTokenMiddleware) + override async transitionStatus(request: Request, response: Response): Promise { + return super.transitionStatus(request, response) + } + @httpDelete('/:userUuid', TYPES.Auth_RequiredCrossServiceTokenMiddleware) override async deleteAccount(request: Request, response: Response): Promise { return super.deleteAccount(request, response) diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts index f35e1b5ba..6decaea0b 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts +++ b/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 { UpdateUser } from '../../../Domain/UseCase/UpdateUser' import { ErrorTag } from '@standardnotes/responses' +import { GetTransitionStatus } from '../../../Domain/UseCase/GetTransitionStatus/GetTransitionStatus' export class BaseUsersController extends BaseHttpController { constructor( @@ -20,6 +21,7 @@ export class BaseUsersController extends BaseHttpController { protected clearLoginAttempts: ClearLoginAttempts, protected increaseLoginAttempts: IncreaseLoginAttempts, protected changeCredentialsUseCase: ChangeCredentials, + protected getTransitionStatusUseCase: GetTransitionStatus, private controllerContainer?: ControllerContainerInterface, ) { 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.updateCredentials', this.changeCredentials.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) } + async transitionStatus(_request: Request, response: Response): Promise { + 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 { if (request.params.userUuid !== response.locals.user.uuid) { return this.json( diff --git a/packages/auth/src/Infra/Redis/RedisTransitionStatusRepository.ts b/packages/auth/src/Infra/Redis/RedisTransitionStatusRepository.ts new file mode 100644 index 000000000..0bc8f661e --- /dev/null +++ b/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 { + await this.redisClient.set(`${this.PREFIX}:${userUuid}`, status) + } + + async removeStatus(userUuid: string): Promise { + 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 + } +} diff --git a/packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEvent.ts b/packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEvent.ts new file mode 100644 index 000000000..73efbbfe4 --- /dev/null +++ b/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 +} diff --git a/packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEventPayload.ts b/packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEventPayload.ts new file mode 100644 index 000000000..77968c692 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/TransitionStatusUpdatedEventPayload.ts @@ -0,0 +1,4 @@ +export interface TransitionStatusUpdatedEventPayload { + userUuid: string + status: 'STARTED' | 'FINISHED' | 'FAILED' +} diff --git a/packages/domain-events/src/Domain/index.ts b/packages/domain-events/src/Domain/index.ts index fc6461cd7..1727115e3 100644 --- a/packages/domain-events/src/Domain/index.ts +++ b/packages/domain-events/src/Domain/index.ts @@ -90,6 +90,8 @@ export * from './Event/SubscriptionRevertRequestedEvent' export * from './Event/SubscriptionRevertRequestedEventPayload' export * from './Event/SubscriptionSyncRequestedEvent' export * from './Event/SubscriptionSyncRequestedEventPayload' +export * from './Event/TransitionStatusUpdatedEvent' +export * from './Event/TransitionStatusUpdatedEventPayload' export * from './Event/UserDisabledSessionUserAgentLoggingEvent' export * from './Event/UserDisabledSessionUserAgentLoggingEventPayload' export * from './Event/UserEmailChangedEvent' diff --git a/packages/security/src/Domain/Token/CrossServiceTokenData.ts b/packages/security/src/Domain/Token/CrossServiceTokenData.ts index 0c307d9ed..758ad66d1 100644 --- a/packages/security/src/Domain/Token/CrossServiceTokenData.ts +++ b/packages/security/src/Domain/Token/CrossServiceTokenData.ts @@ -20,4 +20,5 @@ export type CrossServiceTokenData = { refresh_expiration: string } extensionKey?: string + ongoing_transition?: boolean } diff --git a/packages/syncing-server/src/Bootstrap/Container.ts b/packages/syncing-server/src/Bootstrap/Container.ts index 749b3ec07..a5a8f2751 100644 --- a/packages/syncing-server/src/Bootstrap/Container.ts +++ b/packages/syncing-server/src/Bootstrap/Container.ts @@ -156,6 +156,8 @@ import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryRe import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver' import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser' import { SharedVaultFileMovedEventHandler } from '../Domain/Handler/SharedVaultFileMovedEventHandler' +import { TransitionStatusUpdatedEventHandler } from '../Domain/Handler/TransitionStatusUpdatedEventHandler' +import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser' export class ContainerConfigLoader { private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000 @@ -770,14 +772,27 @@ export class ContainerConfigLoader { ), ) container - .bind(TransitionItemsFromPrimaryToSecondaryDatabaseForUser) + .bind( + TYPES.Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser, + ) .toConstantValue( new TransitionItemsFromPrimaryToSecondaryDatabaseForUser( container.get(TYPES.Sync_MySQLItemRepository), isSecondaryDatabaseEnabled ? container.get(TYPES.Sync_MongoDBItemRepository) : null, + container.get(TYPES.Sync_Timer), container.get(TYPES.Sync_Logger), ), ) + container + .bind( + TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser, + ) + .toConstantValue( + new TriggerTransitionFromPrimaryToSecondaryDatabaseForUser( + container.get(TYPES.Sync_DomainEventPublisher), + container.get(TYPES.Sync_DomainEventFactory), + ), + ) // Services container @@ -882,6 +897,18 @@ export class ContainerConfigLoader { container.get(TYPES.Sync_Logger), ), ) + container + .bind(TYPES.Sync_TransitionStatusUpdatedEventHandler) + .toConstantValue( + new TransitionStatusUpdatedEventHandler( + container.get( + TYPES.Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser, + ), + container.get(TYPES.Sync_DomainEventPublisher), + container.get(TYPES.Sync_DomainEventFactory), + container.get(TYPES.Sync_Logger), + ), + ) // Services container.bind(TYPES.Sync_ContentDecoder).toDynamicValue(() => new ContentDecoder()) @@ -916,6 +943,10 @@ export class ContainerConfigLoader { 'SHARED_VAULT_FILE_MOVED', container.get(TYPES.Sync_SharedVaultFileMovedEventHandler), ], + [ + 'TRANSITION_STATUS_UPDATED', + container.get(TYPES.Sync_TransitionStatusUpdatedEventHandler), + ], ]) if (!isConfiguredForHomeServer) { container.bind(TYPES.Sync_AUTH_SERVER_URL).toConstantValue(env.get('AUTH_SERVER_URL')) @@ -990,12 +1021,15 @@ export class ContainerConfigLoader { .bind(TYPES.Sync_BaseItemsController) .toConstantValue( 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(TYPES.Sync_SyncItems), + container.get(TYPES.Sync_CheckIntegrity), + container.get(TYPES.Sync_GetItem), + container.get( + TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser, + ), + container.get>(TYPES.Sync_ItemHttpMapper), + container.get(TYPES.Sync_SyncResponseFactoryResolver), + container.get(TYPES.Sync_ControllerContainer), ), ) container diff --git a/packages/syncing-server/src/Bootstrap/Types.ts b/packages/syncing-server/src/Bootstrap/Types.ts index 471acd327..da45fac07 100644 --- a/packages/syncing-server/src/Bootstrap/Types.ts +++ b/packages/syncing-server/src/Bootstrap/Types.ts @@ -83,6 +83,9 @@ const TYPES = { Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser: Symbol.for( 'Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser', ), + Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser: Symbol.for( + 'Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser', + ), // Handlers Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'), Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'), @@ -91,6 +94,7 @@ const TYPES = { Sync_SharedVaultFileRemovedEventHandler: Symbol.for('Sync_SharedVaultFileRemovedEventHandler'), Sync_SharedVaultFileUploadedEventHandler: Symbol.for('Sync_SharedVaultFileUploadedEventHandler'), Sync_SharedVaultFileMovedEventHandler: Symbol.for('Sync_SharedVaultFileMovedEventHandler'), + Sync_TransitionStatusUpdatedEventHandler: Symbol.for('Sync_TransitionStatusUpdatedEventHandler'), // Services Sync_ContentDecoder: Symbol.for('Sync_ContentDecoder'), Sync_DomainEventPublisher: Symbol.for('Sync_DomainEventPublisher'), diff --git a/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts b/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts index f95bac202..ebcf9ca59 100644 --- a/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts +++ b/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts @@ -6,6 +6,7 @@ import { ItemDumpedEvent, ItemRevisionCreationRequestedEvent, RevisionsCopyRequestedEvent, + TransitionStatusUpdatedEvent, } from '@standardnotes/domain-events' import { TimerInterface } from '@standardnotes/time' import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' @@ -13,6 +14,24 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' export class DomainEventFactory implements DomainEventFactoryInterface { 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( userUuid: string, dto: { diff --git a/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts index 90088874c..31313f644 100644 --- a/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts @@ -4,9 +4,14 @@ import { ItemDumpedEvent, ItemRevisionCreationRequestedEvent, RevisionsCopyRequestedEvent, + TransitionStatusUpdatedEvent, } from '@standardnotes/domain-events' export interface DomainEventFactoryInterface { + createTransitionStatusUpdatedEvent( + userUuid: string, + status: 'STARTED' | 'FAILED' | 'FINISHED', + ): TransitionStatusUpdatedEvent createEmailRequestedEvent(dto: { userEmail: string messageIdentifier: string diff --git a/packages/syncing-server/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts b/packages/syncing-server/src/Domain/Handler/TransitionStatusUpdatedEventHandler.ts new file mode 100644 index 000000000..544c96381 --- /dev/null +++ b/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 { + 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'), + ) + } + } +} diff --git a/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.spec.ts b/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.spec.ts index 1337ec4fe..9dd518085 100644 --- a/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.spec.ts +++ b/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 { Item } from '../../../Item/Item' import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core' +import { TimerInterface } from '@standardnotes/time' describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => { let primaryItemRepository: ItemRepositoryInterface @@ -13,9 +14,15 @@ describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => { let primaryItem2: Item let secondaryItem1: Item let secondaryItem2: Item + let timer: TimerInterface const createUseCase = () => - new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(primaryItemRepository, secondaryItemRepository, logger) + new TransitionItemsFromPrimaryToSecondaryDatabaseForUser( + primaryItemRepository, + secondaryItemRepository, + timer, + logger, + ) beforeEach(() => { primaryItem1 = Item.create( @@ -107,6 +114,17 @@ describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => { logger = {} as jest.Mocked logger.error = jest.fn() + logger.info = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123) + timer.convertMicrosecondsToTimeStructure = jest.fn().mockReturnValue({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }) }) describe('successfull transition', () => { diff --git a/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts b/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts index ec0118e0d..a846ed648 100644 --- a/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts +++ b/packages/syncing-server/src/Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser.ts @@ -4,11 +4,13 @@ import { Logger } from 'winston' import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO' import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface' import { ItemQuery } from '../../../Item/ItemQuery' +import { TimerInterface } from '@standardnotes/time' export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface { constructor( private primaryItemRepository: ItemRepositoryInterface, private secondaryItemRepository: ItemRepositoryInterface | null, + private timer: TimerInterface, private logger: Logger, ) {} @@ -23,6 +25,8 @@ export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements Use } const userUuid = userUuidOrError.getValue() + const migrationTimeStart = this.timer.getTimestampInMicroseconds() + const migrationResult = await this.migrateItemsForUser(userUuid) if (migrationResult.isFailed()) { 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() } diff --git a/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.spec.ts b/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.spec.ts new file mode 100644 index 000000000..6340bb1df --- /dev/null +++ b/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 + domainEventPubliser.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + 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() + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.ts b/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser.ts new file mode 100644 index 000000000..e32d3da99 --- /dev/null +++ b/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 { + constructor( + private domainEventPubliser: DomainEventPublisherInterface, + private domainEventFactory: DomainEventFactoryInterface, + ) {} + + async execute(dto: TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO): Promise> { + const event = this.domainEventFactory.createTransitionStatusUpdatedEvent(dto.userUuid, 'STARTED') + + await this.domainEventPubliser.publish(event) + + return Result.ok() + } +} diff --git a/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO.ts b/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO.ts new file mode 100644 index 000000000..94ca4ce18 --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO.ts @@ -0,0 +1,3 @@ +export interface TriggerTransitionFromPrimaryToSecondaryDatabaseForUserDTO { + userUuid: string +} diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedItemsController.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedItemsController.ts index ee86356d3..346002fd3 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedItemsController.ts +++ b/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 { MapperInterface } from '@standardnotes/domain-core' import { ItemHttpRepresentation } from '../../Mapping/Http/ItemHttpRepresentation' +import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser' @controller('/items', TYPES.Sync_AuthMiddleware) export class AnnotatedItemsController extends BaseItemsController { @@ -18,11 +19,20 @@ export class AnnotatedItemsController extends BaseItemsController { @inject(TYPES.Sync_SyncItems) override syncItems: SyncItems, @inject(TYPES.Sync_CheckIntegrity) override checkIntegrity: CheckIntegrity, @inject(TYPES.Sync_GetItem) override getItem: GetItem, + @inject(TYPES.Sync_TriggerTransitionFromPrimaryToSecondaryDatabaseForUser) + override triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser, @inject(TYPES.Sync_ItemHttpMapper) override itemHttpMapper: MapperInterface, @inject(TYPES.Sync_SyncResponseFactoryResolver) override syncResponseFactoryResolver: SyncResponseFactoryResolverInterface, ) { - super(syncItems, checkIntegrity, getItem, itemHttpMapper, syncResponseFactoryResolver) + super( + syncItems, + checkIntegrity, + getItem, + triggerTransitionFromPrimaryToSecondaryDatabaseForUser, + itemHttpMapper, + syncResponseFactoryResolver, + ) } @httpPost('/sync') @@ -35,6 +45,11 @@ export class AnnotatedItemsController extends BaseItemsController { return super.checkItemsIntegrity(request, response) } + @httpPost('/transition') + override async transition(request: Request, response: Response): Promise { + return super.transition(request, response) + } + @httpGet('/:uuid') override async getSingleItem(request: Request, response: Response): Promise { return super.getSingleItem(request, response) diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts index e37f26f4e..647fc6b4e 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts +++ b/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 { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation' import { ItemHash } from '../../../Domain/Item/ItemHash' +import { TriggerTransitionFromPrimaryToSecondaryDatabaseForUser } from '../../../Domain/UseCase/Transition/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser/TriggerTransitionFromPrimaryToSecondaryDatabaseForUser' export class BaseItemsController extends BaseHttpController { constructor( protected syncItems: SyncItems, protected checkIntegrity: CheckIntegrity, protected getItem: GetItem, + protected triggerTransitionFromPrimaryToSecondaryDatabaseForUser: TriggerTransitionFromPrimaryToSecondaryDatabaseForUser, protected itemHttpMapper: MapperInterface, protected syncResponseFactoryResolver: SyncResponseFactoryResolverInterface, 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.check_integrity', this.checkItemsIntegrity.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 { + if (response.locals.ongoingTransition === true) { + throw new Error('Cannot sync during transition') + } + const itemHashes: ItemHash[] = [] if ('items' in request.body) { for (const itemHashInput of request.body.items) { @@ -105,6 +112,25 @@ export class BaseItemsController extends BaseHttpController { }) } + async transition(_request: Request, response: Response): Promise { + 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 { const result = await this.getItem.execute({ userUuid: response.locals.user.uuid, diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts index 3b634de5f..b09ed7177 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts +++ b/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.readOnlyAccess = decodedToken.session?.readonly_access ?? false response.locals.sharedVaultOwnerContext = decodedToken.shared_vault_owner_context + response.locals.ongoingTransition = decodedToken.ongoing_transition return next() } catch (error) { diff --git a/packages/time/src/Domain/Time/TimeStructure.ts b/packages/time/src/Domain/Time/TimeStructure.ts index f1f7e03d8..1d1dad694 100644 --- a/packages/time/src/Domain/Time/TimeStructure.ts +++ b/packages/time/src/Domain/Time/TimeStructure.ts @@ -3,4 +3,5 @@ export type TimeStructure = { hours: number minutes: number seconds: number + milliseconds: number } diff --git a/packages/time/src/Domain/Time/Timer.spec.ts b/packages/time/src/Domain/Time/Timer.spec.ts index 1b19893ee..ea08f2c39 100644 --- a/packages/time/src/Domain/Time/Timer.spec.ts +++ b/packages/time/src/Domain/Time/Timer.spec.ts @@ -122,6 +122,7 @@ describe('Timer', () => { hours: 1, minutes: 50, seconds: 50, + milliseconds: 982, }) }) }) diff --git a/packages/time/src/Domain/Time/Timer.ts b/packages/time/src/Domain/Time/Timer.ts index b5ad18ef3..5ee20a5ae 100644 --- a/packages/time/src/Domain/Time/Timer.ts +++ b/packages/time/src/Domain/Time/Timer.ts @@ -26,11 +26,15 @@ export class Timer implements TimerInterface { const secondsLeftOver = microseconds % Time.MicrosecondsInAMinute const seconds = Math.floor(secondsLeftOver / Time.MicrosecondsInASecond) + const millisecondsLeftOver = microseconds % Time.MicrosecondsInASecond + const milliseconds = Math.floor(millisecondsLeftOver / Time.MicrosecondsInAMillisecond) + return { days, hours, minutes, seconds, + milliseconds, } }