diff --git a/packages/services/src/Domain/User/UserClientInterface.ts b/packages/services/src/Domain/User/UserClientInterface.ts index 4901656b4..822b74dc5 100644 --- a/packages/services/src/Domain/User/UserClientInterface.ts +++ b/packages/services/src/Domain/User/UserClientInterface.ts @@ -1,3 +1,4 @@ +import { UserRequestType } from '@standardnotes/common' import { DeinitSource } from '../Application/DeinitSource' export interface UserClientInterface { @@ -6,4 +7,5 @@ export interface UserClientInterface { message?: string }> signOut(force?: boolean, source?: DeinitSource): Promise + submitUserRequest(requestType: UserRequestType): Promise } diff --git a/packages/services/src/Domain/User/UserService.spec.ts b/packages/services/src/Domain/User/UserService.spec.ts new file mode 100644 index 000000000..e46322180 --- /dev/null +++ b/packages/services/src/Domain/User/UserService.spec.ts @@ -0,0 +1,92 @@ +import { UserApiServiceInterface } from '@standardnotes/api' +import { UserRequestType } from '@standardnotes/common' +import { EncryptionProviderInterface } from '@standardnotes/encryption' +import { User } from '@standardnotes/responses' + +import { + AlertService, + ChallengeServiceInterface, + InternalEventBusInterface, + ItemManagerInterface, + ProtectionsClientInterface, +} from '..' +import { SessionsClientInterface } from '../Session/SessionsClientInterface' +import { StorageServiceInterface } from '../Storage/StorageServiceInterface' +import { SyncServiceInterface } from '../Sync/SyncServiceInterface' +import { UserService } from './UserService' + +describe('UserService', () => { + let sessionManager: SessionsClientInterface + let syncService: SyncServiceInterface + let storageService: StorageServiceInterface + let itemManager: ItemManagerInterface + let protocolService: EncryptionProviderInterface + let alertService: AlertService + let challengeService: ChallengeServiceInterface + let protectionService: ProtectionsClientInterface + let userApiService: UserApiServiceInterface + let internalEventBus: InternalEventBusInterface + + const createService = () => + new UserService( + sessionManager, + syncService, + storageService, + itemManager, + protocolService, + alertService, + challengeService, + protectionService, + userApiService, + internalEventBus, + ) + + beforeEach(() => { + sessionManager = {} as jest.Mocked + sessionManager.getSureUser = jest.fn().mockReturnValue({ uuid: '1-2-3' } as jest.Mocked) + + syncService = {} as jest.Mocked + + storageService = {} as jest.Mocked + + itemManager = {} as jest.Mocked + + protocolService = {} as jest.Mocked + + alertService = {} as jest.Mocked + + challengeService = {} as jest.Mocked + + protectionService = {} as jest.Mocked + + userApiService = {} as jest.Mocked + + internalEventBus = {} as jest.Mocked + }) + + it('should submit a user request to the server', async () => { + userApiService.submitUserRequest = jest.fn().mockReturnValue({ data: { success: true } }) + + expect(await createService().submitUserRequest(UserRequestType.ExitDiscount)).toBeTruthy() + }) + + it('should indicate error if submit a user request to the server fails', async () => { + userApiService.submitUserRequest = jest.fn().mockReturnValue({ data: { success: false } }) + + expect(await createService().submitUserRequest(UserRequestType.ExitDiscount)).toBeFalsy() + }) + + it('should indicate error if submit a user request to the server fails with an error on server side', async () => { + userApiService.submitUserRequest = jest.fn().mockReturnValue({ data: { error: { message: 'fail' } } }) + + expect(await createService().submitUserRequest(UserRequestType.ExitDiscount)).toBeFalsy() + }) + + it('should indicate error if submitting a user request throws an exception', async () => { + userApiService.submitUserRequest = jest.fn().mockImplementation(() => { + throw new Error('Oops') + }) + + expect(await createService().submitUserRequest(UserRequestType.ExitDiscount)).toBeFalsy() + }) +}) diff --git a/packages/services/src/Domain/User/UserService.ts b/packages/services/src/Domain/User/UserService.ts index 17d8f1a9f..9fee66740 100644 --- a/packages/services/src/Domain/User/UserService.ts +++ b/packages/services/src/Domain/User/UserService.ts @@ -1,6 +1,6 @@ import { EncryptionProviderInterface, SNRootKey, SNRootKeyParams } from '@standardnotes/encryption' import { HttpResponse, SignInResponse, User } from '@standardnotes/responses' -import { KeyParamsOrigination } from '@standardnotes/common' +import { KeyParamsOrigination, UserRequestType } from '@standardnotes/common' import { UuidGenerator } from '@standardnotes/utils' import { UserApiServiceInterface, UserRegistrationResponseBody } from '@standardnotes/api' @@ -233,6 +233,24 @@ export class UserService extends AbstractService } } + async submitUserRequest(requestType: UserRequestType): Promise { + const userUuid = this.sessionManager.getSureUser().uuid + try { + const result = await this.userApiService.submitUserRequest({ + userUuid, + requestType, + }) + + if (result.data.error) { + return false + } + + return result.data.success + } catch (error) { + return false + } + } + /** * A sign in request that occurs while the user was previously signed in, to correct * for missing keys or storage values. Unlike regular sign in, this doesn't worry about diff --git a/packages/ui-services/src/Route/Params/UserRequestParams.ts b/packages/ui-services/src/Route/Params/UserRequestParams.ts new file mode 100644 index 000000000..59bcaf7b0 --- /dev/null +++ b/packages/ui-services/src/Route/Params/UserRequestParams.ts @@ -0,0 +1,5 @@ +import { UserRequestType } from '@standardnotes/common' + +export type UserRequestParams = { + requestType: UserRequestType +} diff --git a/packages/ui-services/src/Route/RootQueryParam.ts b/packages/ui-services/src/Route/RootQueryParam.ts index 1b2c054da..216029a11 100644 --- a/packages/ui-services/src/Route/RootQueryParam.ts +++ b/packages/ui-services/src/Route/RootQueryParam.ts @@ -3,4 +3,5 @@ export enum RootQueryParam { Settings = 'settings', DemoToken = 'demo-token', AcceptSubscriptionInvite = 'accept-subscription-invite', + UserRequest = 'user-request', } diff --git a/packages/ui-services/src/Route/RouteParser.spec.ts b/packages/ui-services/src/Route/RouteParser.spec.ts index 011d63b8d..df3ed7b74 100644 --- a/packages/ui-services/src/Route/RouteParser.spec.ts +++ b/packages/ui-services/src/Route/RouteParser.spec.ts @@ -1,3 +1,5 @@ +import { UserRequestType } from '@standardnotes/common' + import { RouteParser } from './RouteParser' import { RouteType } from './RouteType' @@ -56,4 +58,12 @@ describe('route parser', () => { expect(parser.type).toEqual(RouteType.AcceptSubscriptionInvite) expect(parser.subscriptionInviteParams.inviteUuid).toEqual('1-2-3') }) + + it('routes to user request', () => { + const url = 'https://app.standardnotes.com/?user-request=exit-discount' + const parser = new RouteParser(url) + + expect(parser.type).toEqual(RouteType.UserRequest) + expect(parser.userRequestParams.requestType).toEqual(UserRequestType.ExitDiscount) + }) }) diff --git a/packages/ui-services/src/Route/RouteParser.ts b/packages/ui-services/src/Route/RouteParser.ts index 824b35e13..cd753e43a 100644 --- a/packages/ui-services/src/Route/RouteParser.ts +++ b/packages/ui-services/src/Route/RouteParser.ts @@ -1,10 +1,11 @@ -import { Uuid } from '@standardnotes/common' +import { UserRequestType, Uuid } from '@standardnotes/common' import { PreferenceId } from './../Preferences/PreferenceId' import { DemoParams } from './Params/DemoParams' import { OnboardingParams } from './Params/OnboardingParams' import { PurchaseParams } from './Params/PurchaseParams' import { SettingsParams } from './Params/SettingsParams' import { SubscriptionInviteParams } from './Params/SubscriptionInviteParams' +import { UserRequestParams } from './Params/UserRequestParams' import { RootQueryParam } from './RootQueryParam' import { RootRoutes } from './RootRoutes' @@ -28,6 +29,14 @@ export class RouteParser implements RouteParserInterface { return this.parsedType } + get userRequestParams(): UserRequestParams { + this.checkForProperRouteType(RouteType.UserRequest) + + return { + requestType: this.searchParams.get(RootQueryParam.UserRequest) as UserRequestType, + } + } + get subscriptionInviteParams(): SubscriptionInviteParams { this.checkForProperRouteType(RouteType.AcceptSubscriptionInvite) @@ -89,6 +98,7 @@ export class RouteParser implements RouteParserInterface { [RootQueryParam.Settings, RouteType.Settings], [RootQueryParam.DemoToken, RouteType.Demo], [RootQueryParam.AcceptSubscriptionInvite, RouteType.AcceptSubscriptionInvite], + [RootQueryParam.UserRequest, RouteType.UserRequest], ]) for (const rootQueryParam of rootQueryParametersMap.keys()) { diff --git a/packages/ui-services/src/Route/RouteParserInterface.ts b/packages/ui-services/src/Route/RouteParserInterface.ts index d28f7a6a2..ddc01ac81 100644 --- a/packages/ui-services/src/Route/RouteParserInterface.ts +++ b/packages/ui-services/src/Route/RouteParserInterface.ts @@ -3,6 +3,7 @@ import { OnboardingParams } from './Params/OnboardingParams' import { PurchaseParams } from './Params/PurchaseParams' import { SettingsParams } from './Params/SettingsParams' import { SubscriptionInviteParams } from './Params/SubscriptionInviteParams' +import { UserRequestParams } from './Params/UserRequestParams' import { RouteType } from './RouteType' export interface RouteParserInterface { @@ -11,5 +12,6 @@ export interface RouteParserInterface { get purchaseParams(): PurchaseParams get onboardingParams(): OnboardingParams get subscriptionInviteParams(): SubscriptionInviteParams + get userRequestParams(): UserRequestParams get type(): RouteType } diff --git a/packages/ui-services/src/Route/RouteType.ts b/packages/ui-services/src/Route/RouteType.ts index ec59751c3..5b2fe79a3 100644 --- a/packages/ui-services/src/Route/RouteType.ts +++ b/packages/ui-services/src/Route/RouteType.ts @@ -3,6 +3,7 @@ export enum RouteType { Settings = 'settings', Purchase = 'purchase', AcceptSubscriptionInvite = 'accept-subscription-invite', + UserRequest = 'user-request', Demo = 'demo', None = 'none', } diff --git a/packages/ui-services/src/Toast/ToastService.ts b/packages/ui-services/src/Toast/ToastService.ts index 20f1e4d87..b6a60696d 100644 --- a/packages/ui-services/src/Toast/ToastService.ts +++ b/packages/ui-services/src/Toast/ToastService.ts @@ -1,10 +1,14 @@ -import { addToast, ToastType } from '@standardnotes/toast' +import { addToast, dismissToast, ToastType } from '@standardnotes/toast' import { ToastServiceInterface } from './ToastServiceInterface' export class ToastService implements ToastServiceInterface { - showToast(type: ToastType, message: string): void { - addToast({ + hideToast(toastId: string): void { + dismissToast(toastId) + } + + showToast(type: ToastType, message: string): string { + return addToast({ type: type, message, }) diff --git a/packages/ui-services/src/Toast/ToastServiceInterface.ts b/packages/ui-services/src/Toast/ToastServiceInterface.ts index dbdf56464..666d113e7 100644 --- a/packages/ui-services/src/Toast/ToastServiceInterface.ts +++ b/packages/ui-services/src/Toast/ToastServiceInterface.ts @@ -1,5 +1,6 @@ import { ToastType } from '@standardnotes/toast' export interface ToastServiceInterface { - showToast(type: ToastType, message: string): void + showToast(type: ToastType, message: string): string + hideToast(toastId: string): void } diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index c89f8c0eb..50ed3a9e0 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -8,6 +8,7 @@ export * from './Route/Params/OnboardingParams' export * from './Route/Params/PurchaseParams' export * from './Route/Params/SettingsParams' export * from './Route/Params/SubscriptionInviteParams' +export * from './Route/Params/UserRequestParams' export * from './Route/RootQueryParam' export * from './Route/RouteParser' export * from './Route/RouteParserInterface' diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index eac968069..411c03388 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -167,6 +167,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface { application.sessions, application.subscriptions, this.toastService, + application.user, ) this.addAppEventObserver() diff --git a/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts b/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts index bc723cf3f..29abf5cfc 100644 --- a/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts +++ b/packages/web/src/javascripts/Event/ApplicationEventObserver.spec.ts @@ -17,6 +17,8 @@ import { SyncClientInterface, SyncOpStatus, User, + UserClientInterface, + UserRequestType, } from '@standardnotes/snjs' import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController' @@ -39,6 +41,7 @@ describe('ApplicationEventObserver', () => { let sessionManager: SessionsClientInterface let subscriptionManager: SubscriptionClientInterface let toastService: ToastServiceInterface + let userService: UserClientInterface const createObserver = () => new ApplicationEventObserver( @@ -52,6 +55,7 @@ describe('ApplicationEventObserver', () => { sessionManager, subscriptionManager, toastService, + userService, ) beforeEach(() => { @@ -87,7 +91,11 @@ describe('ApplicationEventObserver', () => { subscriptionManager.acceptInvitation = jest.fn() toastService = {} as jest.Mocked - toastService.showToast = jest.fn() + toastService.showToast = jest.fn().mockReturnValue('1') + toastService.hideToast = jest.fn() + + userService = {} as jest.Mocked + userService.submitUserRequest = jest.fn().mockReturnValue(true) }) describe('Upon Application Launched', () => { @@ -184,6 +192,63 @@ describe('ApplicationEventObserver', () => { expect(toastService.showToast).toHaveBeenCalledWith(ToastType.Error, 'Oops!') expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.AcceptSubscriptionInvite) }) + + it('should open up sign in if user is not logged in and tries to send request', async () => { + sessionManager.getUser = jest.fn().mockReturnValue(undefined) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.UserRequest, + userRequestParams: { + requestType: UserRequestType.ExitDiscount, + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.Launched) + + expect(accountMenuController.setShow).toHaveBeenCalledWith(true) + expect(accountMenuController.setCurrentPane).toHaveBeenCalledWith(AccountMenuPane.SignIn) + expect(userService.submitUserRequest).not.toHaveBeenCalled() + expect(toastService.showToast).not.toHaveBeenCalled() + }) + + it('should send user request if user is logged in', async () => { + userService.submitUserRequest = jest.fn().mockReturnValue(true) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.UserRequest, + userRequestParams: { + requestType: UserRequestType.ExitDiscount, + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.Launched) + + expect(userService.submitUserRequest).toHaveBeenCalledWith('exit-discount') + expect(toastService.showToast).toHaveBeenNthCalledWith( + 2, + ToastType.Success, + 'We have received your request. Please check your email for further instructions.', + ) + expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.UserRequest) + }) + + it('should show sending request failure if user is logged in and sending fails', async () => { + userService.submitUserRequest = jest.fn().mockReturnValue(false) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.UserRequest, + userRequestParams: { + requestType: UserRequestType.ExitDiscount, + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.Launched) + + expect(userService.submitUserRequest).toHaveBeenCalledWith('exit-discount') + expect(toastService.showToast).toHaveBeenNthCalledWith( + 2, + ToastType.Success, + 'We could not process your request. Please try again or contact support if the issue persists.', + ) + expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.UserRequest) + }) }) describe('Upon Signing In', () => { @@ -219,6 +284,26 @@ describe('ApplicationEventObserver', () => { ) expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.AcceptSubscriptionInvite) }) + + it('should send user request', async () => { + userService.submitUserRequest = jest.fn().mockReturnValue(true) + routeService.getRoute = jest.fn().mockReturnValue({ + type: RouteType.UserRequest, + userRequestParams: { + requestType: UserRequestType.ExitDiscount, + }, + } as jest.Mocked) + + await createObserver().handle(ApplicationEvent.SignedIn) + + expect(userService.submitUserRequest).toHaveBeenCalledWith('exit-discount') + expect(toastService.showToast).toHaveBeenNthCalledWith( + 2, + ToastType.Success, + 'We have received your request. Please check your email for further instructions.', + ) + expect(routeService.removeQueryParameterFromURL).toHaveBeenCalledWith(RootQueryParam.UserRequest) + }) }) describe('Upon Sync Status Changing', () => { diff --git a/packages/web/src/javascripts/Event/ApplicationEventObserver.ts b/packages/web/src/javascripts/Event/ApplicationEventObserver.ts index 667ccd530..52dbde1e9 100644 --- a/packages/web/src/javascripts/Event/ApplicationEventObserver.ts +++ b/packages/web/src/javascripts/Event/ApplicationEventObserver.ts @@ -10,6 +10,7 @@ import { SessionsClientInterface, SubscriptionClientInterface, SyncClientInterface, + UserClientInterface, } from '@standardnotes/snjs' import { ToastType } from '@standardnotes/toast' @@ -34,6 +35,7 @@ export class ApplicationEventObserver implements EventObserverInterface { private sessionManager: SessionsClientInterface, private subscriptionManager: SubscriptionClientInterface, private toastService: ToastServiceInterface, + private userService: UserClientInterface, ) {} async handle(event: ApplicationEvent): Promise { @@ -67,6 +69,17 @@ export class ApplicationEventObserver implements EventObserverInterface { } await this.acceptSubscriptionInvitation(route) + break + } + case RouteType.UserRequest: { + const user = this.sessionManager.getUser() + if (user === undefined) { + this.promptUserSignIn() + + break + } + await this.sendUserRequest(route) + break } } @@ -84,6 +97,10 @@ export class ApplicationEventObserver implements EventObserverInterface { case RouteType.AcceptSubscriptionInvite: await this.acceptSubscriptionInvitation(route) + break + case RouteType.UserRequest: + await this.sendUserRequest(route) + break } } @@ -105,8 +122,12 @@ export class ApplicationEventObserver implements EventObserverInterface { } private async acceptSubscriptionInvitation(route: RouteParserInterface): Promise { + const processingToastId = this.toastService.showToast(ToastType.Loading, 'Accepting invitation...') + const acceptResult = await this.subscriptionManager.acceptInvitation(route.subscriptionInviteParams.inviteUuid) + this.toastService.hideToast(processingToastId) + const toastType = acceptResult.success ? ToastType.Success : ToastType.Error const toastMessage = acceptResult.success ? 'Successfully joined a shared subscription' : acceptResult.message @@ -114,4 +135,21 @@ export class ApplicationEventObserver implements EventObserverInterface { this.routeService.removeQueryParameterFromURL(RootQueryParam.AcceptSubscriptionInvite) } + + private async sendUserRequest(route: RouteParserInterface): Promise { + const processingToastId = this.toastService.showToast(ToastType.Loading, 'Processing your request...') + + const requestSubmittedSuccessfully = await this.userService.submitUserRequest(route.userRequestParams.requestType) + + this.toastService.hideToast(processingToastId) + + const toastType = requestSubmittedSuccessfully ? ToastType.Success : ToastType.Error + const toastMessage = requestSubmittedSuccessfully + ? 'We have received your request. Please check your email for further instructions.' + : 'We could not process your request. Please try again or contact support if the issue persists.' + + this.toastService.showToast(toastType, toastMessage) + + this.routeService.removeQueryParameterFromURL(RootQueryParam.UserRequest) + } }