feat: add sending user requests from UI (#1927)
* feat: add sending user requests from UI * fix(web): view controller manager user client references
This commit is contained in:
@@ -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<void>
|
||||
submitUserRequest(requestType: UserRequestType): Promise<boolean>
|
||||
}
|
||||
|
||||
92
packages/services/src/Domain/User/UserService.spec.ts
Normal file
92
packages/services/src/Domain/User/UserService.spec.ts
Normal file
@@ -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<SessionsClientInterface>
|
||||
sessionManager.getSureUser = jest.fn().mockReturnValue({ uuid: '1-2-3' } as jest.Mocked<User>)
|
||||
|
||||
syncService = {} as jest.Mocked<SyncServiceInterface>
|
||||
|
||||
storageService = {} as jest.Mocked<StorageServiceInterface>
|
||||
|
||||
itemManager = {} as jest.Mocked<ItemManagerInterface>
|
||||
|
||||
protocolService = {} as jest.Mocked<EncryptionProviderInterface>
|
||||
|
||||
alertService = {} as jest.Mocked<AlertService>
|
||||
|
||||
challengeService = {} as jest.Mocked<ChallengeServiceInterface>
|
||||
|
||||
protectionService = {} as jest.Mocked<ProtectionsClientInterface>
|
||||
|
||||
userApiService = {} as jest.Mocked<UserApiServiceInterface>
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<AccountEvent, AccountEventData>
|
||||
}
|
||||
}
|
||||
|
||||
async submitUserRequest(requestType: UserRequestType): Promise<boolean> {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserRequestType } from '@standardnotes/common'
|
||||
|
||||
export type UserRequestParams = {
|
||||
requestType: UserRequestType
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export enum RootQueryParam {
|
||||
Settings = 'settings',
|
||||
DemoToken = 'demo-token',
|
||||
AcceptSubscriptionInvite = 'accept-subscription-invite',
|
||||
UserRequest = 'user-request',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum RouteType {
|
||||
Settings = 'settings',
|
||||
Purchase = 'purchase',
|
||||
AcceptSubscriptionInvite = 'accept-subscription-invite',
|
||||
UserRequest = 'user-request',
|
||||
Demo = 'demo',
|
||||
None = 'none',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -167,6 +167,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
application.sessions,
|
||||
application.subscriptions,
|
||||
this.toastService,
|
||||
application.user,
|
||||
)
|
||||
|
||||
this.addAppEventObserver()
|
||||
|
||||
@@ -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<ToastServiceInterface>
|
||||
toastService.showToast = jest.fn()
|
||||
toastService.showToast = jest.fn().mockReturnValue('1')
|
||||
toastService.hideToast = jest.fn()
|
||||
|
||||
userService = {} as jest.Mocked<UserClientInterface>
|
||||
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<RouteParserInterface>)
|
||||
|
||||
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<RouteParserInterface>)
|
||||
|
||||
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<RouteParserInterface>)
|
||||
|
||||
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<RouteParserInterface>)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user