Przeglądaj źródła

feat(home-server): add activating premium features (#624)

Karol Sójko 2 lat temu
rodzic
commit
72ce190996

+ 5 - 0
packages/auth/src/Bootstrap/AuthServiceInterface.ts

@@ -0,0 +1,5 @@
+import { Result, ServiceInterface } from '@standardnotes/domain-core'
+
+export interface AuthServiceInterface extends ServiceInterface {
+  activatePremiumFeatures(username: string): Promise<Result<string>>
+}

+ 11 - 0
packages/auth/src/Bootstrap/Container.ts

@@ -251,6 +251,7 @@ import { HomeServerValetTokenController } from '../Infra/InversifyExpressUtils/H
 import { HomeServerWebSocketsController } from '../Infra/InversifyExpressUtils/HomeServer/HomeServerWebSocketsController'
 import { HomeServerSessionsController } from '../Infra/InversifyExpressUtils/HomeServer/HomeServerSessionsController'
 import { Transform } from 'stream'
+import { ActivatePremiumFeatures } from '../Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures'
 
 export class ContainerConfigLoader {
   async load(configuration?: {
@@ -781,6 +782,16 @@ export class ContainerConfigLoader {
           container.get(TYPES.Auth_CryptoNode),
         ),
       )
+    container
+      .bind<ActivatePremiumFeatures>(TYPES.Auth_ActivatePremiumFeatures)
+      .toConstantValue(
+        new ActivatePremiumFeatures(
+          container.get(TYPES.Auth_UserRepository),
+          container.get(TYPES.Auth_UserSubscriptionRepository),
+          container.get(TYPES.Auth_RoleService),
+          container.get(TYPES.Auth_Timer),
+        ),
+      )
 
     container
       .bind<CleanupSessionTraces>(TYPES.Auth_CleanupSessionTraces)

+ 23 - 3
packages/auth/src/Bootstrap/Service.ts

@@ -1,15 +1,21 @@
 import {
   ControllerContainerInterface,
+  Result,
   ServiceConfiguration,
   ServiceContainerInterface,
   ServiceIdentifier,
-  ServiceInterface,
 } from '@standardnotes/domain-core'
 
 import { ContainerConfigLoader } from './Container'
 import { DirectCallDomainEventPublisher } from '@standardnotes/domain-events-infra'
+import TYPES from './Types'
+import { Container } from 'inversify'
+import { ActivatePremiumFeatures } from '../Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures'
+import { AuthServiceInterface } from './AuthServiceInterface'
+
+export class Service implements AuthServiceInterface {
+  private container: Container | undefined
 
-export class Service implements ServiceInterface {
   constructor(
     private serviceContainer: ServiceContainerInterface,
     private controllerContainer: ControllerContainerInterface,
@@ -18,6 +24,16 @@ export class Service implements ServiceInterface {
     this.serviceContainer.register(this.getId(), this)
   }
 
+  async activatePremiumFeatures(username: string): Promise<Result<string>> {
+    if (!this.container) {
+      return Result.fail('Container not initialized')
+    }
+
+    const activatePremiumFeatures = this.container.get(TYPES.Auth_ActivatePremiumFeatures) as ActivatePremiumFeatures
+
+    return activatePremiumFeatures.execute({ username })
+  }
+
   async handleRequest(request: never, response: never, endpointOrMethodIdentifier: string): Promise<unknown> {
     const method = this.controllerContainer.get(endpointOrMethodIdentifier)
 
@@ -31,12 +47,16 @@ export class Service implements ServiceInterface {
   async getContainer(configuration?: ServiceConfiguration): Promise<unknown> {
     const config = new ContainerConfigLoader()
 
-    return config.load({
+    const container = await config.load({
       controllerConatiner: this.controllerContainer,
       directCallDomainEventPublisher: this.directCallDomainEventPublisher,
       logger: configuration?.logger,
       environmentOverrides: configuration?.environmentOverrides,
     })
+
+    this.container = container
+
+    return container
   }
 
   getId(): ServiceIdentifier {

+ 1 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -149,6 +149,7 @@ const TYPES = {
   Auth_ListAuthenticators: Symbol.for('Auth_ListAuthenticators'),
   Auth_DeleteAuthenticator: Symbol.for('Auth_DeleteAuthenticator'),
   Auth_GenerateRecoveryCodes: Symbol.for('Auth_GenerateRecoveryCodes'),
+  Auth_ActivatePremiumFeatures: Symbol.for('Auth_ActivatePremiumFeatures'),
   Auth_SignInWithRecoveryCodes: Symbol.for('Auth_SignInWithRecoveryCodes'),
   Auth_GetUserKeyParamsRecovery: Symbol.for('Auth_GetUserKeyParamsRecovery'),
   // Handlers

+ 1 - 0
packages/auth/src/Bootstrap/index.ts

@@ -1 +1,2 @@
+export * from './AuthServiceInterface'
 export * from './Service'

+ 67 - 0
packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.spec.ts

@@ -0,0 +1,67 @@
+import { TimerInterface } from '@standardnotes/time'
+import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
+import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+
+import { ActivatePremiumFeatures } from './ActivatePremiumFeatures'
+import { User } from '../../User/User'
+
+describe('ActivatePremiumFeatures', () => {
+  let userRepository: UserRepositoryInterface
+  let userSubscriptionRepository: UserSubscriptionRepositoryInterface
+  let roleService: RoleServiceInterface
+  let timer: TimerInterface
+  let user: User
+
+  const createUseCase = () =>
+    new ActivatePremiumFeatures(userRepository, userSubscriptionRepository, roleService, timer)
+
+  beforeEach(() => {
+    user = {} as jest.Mocked<User>
+
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockResolvedValue(user)
+
+    userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
+    userSubscriptionRepository.save = jest.fn()
+
+    roleService = {} as jest.Mocked<RoleServiceInterface>
+    roleService.addUserRole = jest.fn()
+
+    timer = {} as jest.Mocked<TimerInterface>
+    timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
+    timer.convertDateToMicroseconds = jest.fn().mockReturnValue(123456789)
+    timer.getUTCDateNDaysAhead = jest.fn().mockReturnValue(new Date('2024-01-01T00:00:00.000Z'))
+  })
+
+  it('should return error when username is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({ username: '' })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Username cannot be empty')
+  })
+
+  it('should return error when user is not found', async () => {
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockResolvedValue(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({ username: 'test@test.te' })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('User not found with username: test@test.te')
+  })
+
+  it('should save a subscription and add role to user', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({ username: 'test@test.te' })
+
+    expect(result.isFailed()).toBe(false)
+
+    expect(userSubscriptionRepository.save).toHaveBeenCalled()
+    expect(roleService.addUserRole).toHaveBeenCalled()
+  })
+})

+ 49 - 0
packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts

@@ -0,0 +1,49 @@
+import { Result, SubscriptionPlanName, UseCaseInterface, Username } from '@standardnotes/domain-core'
+import { TimerInterface } from '@standardnotes/time'
+
+import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
+import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { UserSubscription } from '../../Subscription/UserSubscription'
+import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
+import { ActivatePremiumFeaturesDTO } from './ActivatePremiumFeaturesDTO'
+
+export class ActivatePremiumFeatures implements UseCaseInterface<string> {
+  constructor(
+    private userRepository: UserRepositoryInterface,
+    private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
+    private roleService: RoleServiceInterface,
+    private timer: TimerInterface,
+  ) {}
+
+  async execute(dto: ActivatePremiumFeaturesDTO): Promise<Result<string>> {
+    const usernameOrError = Username.create(dto.username)
+    if (usernameOrError.isFailed()) {
+      return Result.fail(usernameOrError.getError())
+    }
+    const username = usernameOrError.getValue()
+
+    const user = await this.userRepository.findOneByUsernameOrEmail(username)
+    if (user === null) {
+      return Result.fail(`User not found with username: ${username.value}`)
+    }
+
+    const timestamp = this.timer.getTimestampInMicroseconds()
+
+    const subscription = new UserSubscription()
+    subscription.planName = SubscriptionPlanName.NAMES.ProPlan
+    subscription.user = Promise.resolve(user)
+    subscription.createdAt = timestamp
+    subscription.updatedAt = timestamp
+    subscription.endsAt = this.timer.convertDateToMicroseconds(this.timer.getUTCDateNDaysAhead(365))
+    subscription.cancelled = false
+    subscription.subscriptionId = 1
+    subscription.subscriptionType = UserSubscriptionType.Regular
+
+    await this.userSubscriptionRepository.save(subscription)
+
+    await this.roleService.addUserRole(user, SubscriptionPlanName.NAMES.ProPlan)
+
+    return Result.ok('Premium features activated.')
+  }
+}

+ 3 - 0
packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeaturesDTO.ts

@@ -0,0 +1,3 @@
+export interface ActivatePremiumFeaturesDTO {
+  username: string
+}

+ 12 - 2
packages/home-server/src/Server/HomeServer.ts

@@ -1,10 +1,10 @@
 import 'reflect-metadata'
 
-import { ControllerContainer, ServiceContainer } from '@standardnotes/domain-core'
+import { ControllerContainer, Result, ServiceContainer } from '@standardnotes/domain-core'
 import { Service as ApiGatewayService } from '@standardnotes/api-gateway'
 import { Service as FilesService } from '@standardnotes/files-server'
 import { DirectCallDomainEventPublisher } from '@standardnotes/domain-events-infra'
-import { Service as AuthService } from '@standardnotes/auth-server'
+import { Service as AuthService, AuthServiceInterface } from '@standardnotes/auth-server'
 import { Service as SyncingService } from '@standardnotes/syncing-server'
 import { Service as RevisionsService } from '@standardnotes/revisions-server'
 import { Container } from 'inversify'
@@ -24,6 +24,7 @@ import { HomeServerConfiguration } from './HomeServerConfiguration'
 
 export class HomeServer implements HomeServerInterface {
   private serverInstance: http.Server | undefined
+  private authService: AuthServiceInterface | undefined
   private logStream: PassThrough = new PassThrough()
 
   async start(configuration: HomeServerConfiguration): Promise<void> {
@@ -49,6 +50,7 @@ export class HomeServer implements HomeServerInterface {
 
     const apiGatewayService = new ApiGatewayService(serviceContainer)
     const authService = new AuthService(serviceContainer, controllerContainer, directCallDomainEventPublisher)
+    this.authService = authService
     const syncingService = new SyncingService(serviceContainer, controllerContainer, directCallDomainEventPublisher)
     const revisionsService = new RevisionsService(serviceContainer, controllerContainer, directCallDomainEventPublisher)
     const filesService = new FilesService(serviceContainer, directCallDomainEventPublisher)
@@ -152,6 +154,14 @@ export class HomeServer implements HomeServerInterface {
     return this.serverInstance.address() !== null
   }
 
+  async activatePremiumFeatures(username: string): Promise<Result<string>> {
+    if (!this.isRunning() || !this.authService) {
+      return Result.fail('Home server is not running.')
+    }
+
+    return this.authService.activatePremiumFeatures(username)
+  }
+
   logs(): NodeJS.ReadableStream {
     return this.logStream
   }

+ 2 - 0
packages/home-server/src/Server/HomeServerInterface.ts

@@ -1,7 +1,9 @@
+import { Result } from '@standardnotes/domain-core'
 import { HomeServerConfiguration } from './HomeServerConfiguration'
 
 export interface HomeServerInterface {
   start(configuration?: HomeServerConfiguration): Promise<void>
+  activatePremiumFeatures(username: string): Promise<Result<string>>
   stop(): Promise<void>
   isRunning(): Promise<boolean>
   logs(): NodeJS.ReadableStream