refactor: native feature management (#2350)
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { ContentType } from '@standardnotes/domain-core'
|
||||
import { FindNativeFeature, GetFeatures, ThirdPartyFeatureDescription } from '@standardnotes/features'
|
||||
import {
|
||||
ComponentContent,
|
||||
ComponentContentSpecialized,
|
||||
ComponentInterface,
|
||||
FillItemContentSpecialized,
|
||||
} from '@standardnotes/models'
|
||||
import {
|
||||
AlertService,
|
||||
API_MESSAGE_FAILED_DOWNLOADING_EXTENSION,
|
||||
ApiServiceInterface,
|
||||
ItemManagerInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { isString } from '@standardnotes/utils'
|
||||
|
||||
export class DownloadRemoteThirdPartyFeatureUseCase {
|
||||
constructor(private api: ApiServiceInterface, private items: ItemManagerInterface, private alerts: AlertService) {}
|
||||
|
||||
async execute(url: string): Promise<ComponentInterface | undefined> {
|
||||
const response = await this.api.downloadFeatureUrl(url)
|
||||
if (response.data?.error) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let rawFeature = response.data as ThirdPartyFeatureDescription
|
||||
|
||||
if (isString(rawFeature)) {
|
||||
try {
|
||||
rawFeature = JSON.parse(rawFeature)
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!rawFeature.content_type) {
|
||||
return
|
||||
}
|
||||
|
||||
const isValidContentType = [
|
||||
ContentType.TYPES.Component,
|
||||
ContentType.TYPES.Theme,
|
||||
ContentType.TYPES.ActionsExtension,
|
||||
ContentType.TYPES.ExtensionRepo,
|
||||
].includes(rawFeature.content_type)
|
||||
|
||||
if (!isValidContentType) {
|
||||
return
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(rawFeature.identifier)
|
||||
if (nativeFeature) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
|
||||
if (rawFeature.url) {
|
||||
for (const nativeFeature of GetFeatures()) {
|
||||
if (rawFeature.url.includes(nativeFeature.identifier)) {
|
||||
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = FillItemContentSpecialized<ComponentContentSpecialized, ComponentContent>({
|
||||
area: rawFeature.area,
|
||||
name: rawFeature.name ?? '',
|
||||
package_info: rawFeature,
|
||||
valid_until: new Date(rawFeature.expires_at || 0),
|
||||
hosted_url: rawFeature.url,
|
||||
})
|
||||
|
||||
const component = this.items.createTemplateItem<ComponentContent, ComponentInterface>(
|
||||
rawFeature.content_type,
|
||||
content,
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { FeatureStatus, ItemManagerInterface } from '@standardnotes/services'
|
||||
import { GetFeatureStatusUseCase } from './GetFeatureStatus'
|
||||
import { ComponentInterface } from '@standardnotes/models'
|
||||
|
||||
jest.mock('@standardnotes/features', () => ({
|
||||
FeatureIdentifier: {
|
||||
DarkTheme: 'darkTheme',
|
||||
},
|
||||
FindNativeFeature: jest.fn(),
|
||||
}))
|
||||
|
||||
import { FindNativeFeature } from '@standardnotes/features'
|
||||
import { Subscription } from '@standardnotes/security'
|
||||
|
||||
describe('GetFeatureStatusUseCase', () => {
|
||||
let items: jest.Mocked<ItemManagerInterface>
|
||||
let usecase: GetFeatureStatusUseCase
|
||||
|
||||
beforeEach(() => {
|
||||
items = {
|
||||
getDisplayableComponents: jest.fn(),
|
||||
} as unknown as jest.Mocked<ItemManagerInterface>
|
||||
usecase = new GetFeatureStatusUseCase(items)
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('free features', () => {
|
||||
it('should return entitled for free features', () => {
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: FeatureIdentifier.DarkTheme,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deprecated features', () => {
|
||||
it('should return entitled for deprecated paid features if any subscription is active', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: true })
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'deprecatedFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: true,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('should return NoUserSubscription for deprecated paid features if no subscription is active', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: true })
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'deprecatedFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native features', () => {
|
||||
it('should return NoUserSubscription for native features without subscription and roles', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({ deprecated: false })
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
|
||||
it('should return NotInCurrentPlan for native features with roles not in available roles', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({
|
||||
deprecated: false,
|
||||
availableInRoles: ['notInRole'],
|
||||
})
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: { online: ['inRole'] },
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NotInCurrentPlan)
|
||||
})
|
||||
|
||||
it('should return Entitled for native features with roles in available roles and active subscription', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({
|
||||
deprecated: false,
|
||||
availableInRoles: ['inRole'],
|
||||
})
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: {
|
||||
endsAt: new Date(Date.now() + 10000).getTime(),
|
||||
} as jest.Mocked<Subscription>,
|
||||
firstPartyRoles: { online: ['inRole'] },
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('should return InCurrentPlanButExpired for native features with roles in available roles and expired subscription', () => {
|
||||
;(FindNativeFeature as jest.Mock).mockReturnValue({
|
||||
deprecated: false,
|
||||
availableInRoles: ['inRole'],
|
||||
})
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nativeFeature',
|
||||
firstPartyOnlineSubscription: {
|
||||
endsAt: new Date(Date.now() - 10000).getTime(),
|
||||
} as jest.Mocked<Subscription>,
|
||||
firstPartyRoles: { online: ['inRole'] },
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
}),
|
||||
).toEqual(FeatureStatus.InCurrentPlanButExpired)
|
||||
})
|
||||
})
|
||||
|
||||
describe('third party features', () => {
|
||||
it('should return Entitled for third-party features', () => {
|
||||
const mockComponent = {
|
||||
identifier: 'thirdPartyFeature',
|
||||
isExpired: false,
|
||||
} as unknown as jest.Mocked<ComponentInterface>
|
||||
|
||||
items.getDisplayableComponents.mockReturnValue([mockComponent])
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'thirdPartyFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.Entitled)
|
||||
})
|
||||
|
||||
it('should return NoUserSubscription for non-existing third-party features', () => {
|
||||
;(items.getDisplayableComponents as jest.Mock).mockReturnValue([])
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'nonExistingThirdPartyFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.NoUserSubscription)
|
||||
})
|
||||
|
||||
it('should return InCurrentPlanButExpired for expired third-party features', () => {
|
||||
const mockComponent = {
|
||||
identifier: 'thirdPartyFeature',
|
||||
isExpired: true,
|
||||
} as unknown as jest.Mocked<ComponentInterface>
|
||||
|
||||
items.getDisplayableComponents.mockReturnValue([mockComponent])
|
||||
|
||||
expect(
|
||||
usecase.execute({
|
||||
featureId: 'thirdPartyFeature',
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: false,
|
||||
firstPartyOnlineSubscription: undefined,
|
||||
firstPartyRoles: undefined,
|
||||
}),
|
||||
).toEqual(FeatureStatus.InCurrentPlanButExpired)
|
||||
})
|
||||
})
|
||||
})
|
||||
104
packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts
Normal file
104
packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { AnyFeatureDescription, FeatureIdentifier, FindNativeFeature } from '@standardnotes/features'
|
||||
import { Subscription } from '@standardnotes/security'
|
||||
import { FeatureStatus, ItemManagerInterface } from '@standardnotes/services'
|
||||
import { convertTimestampToMilliseconds } from '@standardnotes/utils'
|
||||
|
||||
export class GetFeatureStatusUseCase {
|
||||
constructor(private items: ItemManagerInterface) {}
|
||||
|
||||
execute(dto: {
|
||||
featureId: FeatureIdentifier | string
|
||||
firstPartyOnlineSubscription: Subscription | undefined
|
||||
firstPartyRoles: { online: string[] } | { offline: string[] } | undefined
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: boolean
|
||||
}): FeatureStatus {
|
||||
if (this.isFreeFeature(dto.featureId as FeatureIdentifier)) {
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
const nativeFeature = FindNativeFeature(dto.featureId as FeatureIdentifier)
|
||||
|
||||
if (!nativeFeature) {
|
||||
return this.getThirdPartyFeatureStatus(dto.featureId as string)
|
||||
}
|
||||
|
||||
if (nativeFeature.deprecated) {
|
||||
return this.getDeprecatedNativeFeatureStatus({
|
||||
nativeFeature,
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: dto.hasPaidAnyPartyOnlineOrOfflineSubscription,
|
||||
})
|
||||
}
|
||||
|
||||
return this.getNativeFeatureFeatureStatus({
|
||||
nativeFeature,
|
||||
firstPartyOnlineSubscription: dto.firstPartyOnlineSubscription,
|
||||
firstPartyRoles: dto.firstPartyRoles,
|
||||
})
|
||||
}
|
||||
|
||||
private getDeprecatedNativeFeatureStatus(dto: {
|
||||
hasPaidAnyPartyOnlineOrOfflineSubscription: boolean
|
||||
nativeFeature: AnyFeatureDescription
|
||||
}): FeatureStatus {
|
||||
if (dto.hasPaidAnyPartyOnlineOrOfflineSubscription) {
|
||||
return FeatureStatus.Entitled
|
||||
} else {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
}
|
||||
|
||||
private getNativeFeatureFeatureStatus(dto: {
|
||||
nativeFeature: AnyFeatureDescription
|
||||
firstPartyOnlineSubscription: Subscription | undefined
|
||||
firstPartyRoles: { online: string[] } | { offline: string[] } | undefined
|
||||
}): FeatureStatus {
|
||||
if (!dto.firstPartyOnlineSubscription && !dto.firstPartyRoles) {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
|
||||
const roles = !dto.firstPartyRoles
|
||||
? undefined
|
||||
: 'online' in dto.firstPartyRoles
|
||||
? dto.firstPartyRoles.online
|
||||
: dto.firstPartyRoles.offline
|
||||
|
||||
if (dto.nativeFeature.availableInRoles && roles) {
|
||||
const hasRole = roles.some((role) => {
|
||||
return dto.nativeFeature.availableInRoles?.includes(role)
|
||||
})
|
||||
|
||||
if (!hasRole) {
|
||||
return FeatureStatus.NotInCurrentPlan
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.firstPartyOnlineSubscription) {
|
||||
const isSubscriptionExpired =
|
||||
new Date(convertTimestampToMilliseconds(dto.firstPartyOnlineSubscription.endsAt)) < new Date()
|
||||
|
||||
if (isSubscriptionExpired) {
|
||||
return FeatureStatus.InCurrentPlanButExpired
|
||||
}
|
||||
}
|
||||
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
private getThirdPartyFeatureStatus(featureId: string): FeatureStatus {
|
||||
const component = this.items.getDisplayableComponents().find((candidate) => candidate.identifier === featureId)
|
||||
|
||||
if (!component) {
|
||||
return FeatureStatus.NoUserSubscription
|
||||
}
|
||||
|
||||
if (component.isExpired) {
|
||||
return FeatureStatus.InCurrentPlanButExpired
|
||||
}
|
||||
|
||||
return FeatureStatus.Entitled
|
||||
}
|
||||
|
||||
private isFreeFeature(featureId: FeatureIdentifier) {
|
||||
return [FeatureIdentifier.DarkTheme, FeatureIdentifier.PlainEditor].includes(featureId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LEGACY_PROD_EXT_ORIGIN, PROD_OFFLINE_FEATURES_URL } from '@Lib/Hosts'
|
||||
import { SNFeatureRepo } from '@standardnotes/models'
|
||||
import { MutatorClientInterface } from '@standardnotes/services'
|
||||
|
||||
export class MigrateFeatureRepoToOfflineEntitlementsUseCase {
|
||||
constructor(private mutator: MutatorClientInterface) {}
|
||||
|
||||
async execute(featureRepos: SNFeatureRepo[] = []): Promise<SNFeatureRepo[]> {
|
||||
const updatedRepos: SNFeatureRepo[] = []
|
||||
for (const item of featureRepos) {
|
||||
if (item.migratedToOfflineEntitlements) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!item.onlineUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
const repoUrl = item.onlineUrl
|
||||
const { origin } = new URL(repoUrl)
|
||||
|
||||
if (!origin.includes(LEGACY_PROD_EXT_ORIGIN)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const userKeyMatch = repoUrl.match(/\w{32,64}/)
|
||||
if (userKeyMatch && userKeyMatch.length > 0) {
|
||||
const userKey = userKeyMatch[0]
|
||||
|
||||
const updatedRepo = await this.mutator.changeFeatureRepo(item, (m) => {
|
||||
m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL
|
||||
m.offlineKey = userKey
|
||||
m.migratedToOfflineEntitlements = true
|
||||
})
|
||||
|
||||
updatedRepos.push(updatedRepo)
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRepos
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SettingsClientInterface } from '@Lib/Services/Settings/SettingsClientInterface'
|
||||
import { SNFeatureRepo } from '@standardnotes/models'
|
||||
import { MutatorClientInterface } from '@standardnotes/services'
|
||||
import { SettingName } from '@standardnotes/settings'
|
||||
|
||||
export class MigrateFeatureRepoToUserSettingUseCase {
|
||||
constructor(private mutator: MutatorClientInterface, private settings: SettingsClientInterface) {}
|
||||
|
||||
async execute(featureRepos: SNFeatureRepo[] = []): Promise<void> {
|
||||
for (const item of featureRepos) {
|
||||
if (item.migratedToUserSetting) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!item.onlineUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
const repoUrl: string = item.onlineUrl
|
||||
const userKeyMatch = repoUrl.match(/\w{32,64}/)
|
||||
|
||||
if (userKeyMatch && userKeyMatch.length > 0) {
|
||||
const userKey = userKeyMatch[0]
|
||||
await this.settings.updateSetting(SettingName.create(SettingName.NAMES.ExtensionKey).getValue(), userKey, true)
|
||||
|
||||
await this.mutator.changeFeatureRepo(item, (m) => {
|
||||
m.migratedToUserSetting = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user