refactor: native feature management (#2350)

This commit is contained in:
Mo
2023-07-12 12:56:08 -05:00
committed by GitHub
parent 49f7581cd8
commit 078ef3772c
223 changed files with 3996 additions and 3438 deletions

View File

@@ -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
}
}

View File

@@ -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)
})
})
})

View 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)
}
}

View File

@@ -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
}
}

View File

@@ -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
})
}
}
}
}