فهرست منبع

feat(auth): add renewal of shared subscriptions (#952)

Karol Sójko 1 سال پیش
والد
کامیت
e150930072

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

@@ -281,6 +281,7 @@ import { CSVFileReaderInterface } from '../Domain/CSV/CSVFileReaderInterface'
 import { S3CsvFileReader } from '../Infra/S3/S3CsvFileReader'
 import { DeleteAccountsFromCSVFile } from '../Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile'
 import { AccountDeletionVerificationPassedEventHandler } from '../Domain/Handler/AccountDeletionVerificationPassedEventHandler'
+import { RenewSharedSubscriptions } from '../Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -1270,6 +1271,19 @@ export class ContainerConfigLoader {
           container.get<TriggerEmailBackupForUser>(TYPES.Auth_TriggerEmailBackupForUser),
         ),
       )
+    container
+      .bind<RenewSharedSubscriptions>(TYPES.Auth_RenewSharedSubscriptions)
+      .toConstantValue(
+        new RenewSharedSubscriptions(
+          container.get<ListSharedSubscriptionInvitations>(TYPES.Auth_ListSharedSubscriptionInvitations),
+          container.get<SharedSubscriptionInvitationRepositoryInterface>(
+            TYPES.Auth_SharedSubscriptionInvitationRepository,
+          ),
+          container.get<UserSubscriptionRepositoryInterface>(TYPES.Auth_UserSubscriptionRepository),
+          container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
     if (!isConfiguredForHomeServer) {
       container
         .bind<DeleteAccountsFromCSVFile>(TYPES.Auth_DeleteAccountsFromCSVFile)
@@ -1349,6 +1363,7 @@ export class ContainerConfigLoader {
           container.get<ApplyDefaultSubscriptionSettings>(TYPES.Auth_ApplyDefaultSubscriptionSettings),
           container.get<OfflineUserSubscriptionRepositoryInterface>(TYPES.Auth_OfflineUserSubscriptionRepository),
           container.get<RoleServiceInterface>(TYPES.Auth_RoleService),
+          container.get<RenewSharedSubscriptions>(TYPES.Auth_RenewSharedSubscriptions),
           container.get<winston.Logger>(TYPES.Auth_Logger),
         ),
       )

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

@@ -169,6 +169,7 @@ const TYPES = {
   Auth_TriggerEmailBackupForUser: Symbol.for('Auth_TriggerEmailBackupForUser'),
   Auth_TriggerEmailBackupForAllUsers: Symbol.for('Auth_TriggerEmailBackupForAllUsers'),
   Auth_DeleteAccountsFromCSVFile: Symbol.for('Auth_DeleteAccountsFromCSVFile'),
+  Auth_RenewSharedSubscriptions: Symbol.for('Auth_RenewSharedSubscriptions'),
   // Handlers
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
   Auth_AccountDeletionVerificationPassedEventHandler: Symbol.for('Auth_AccountDeletionVerificationPassedEventHandler'),

+ 13 - 0
packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts

@@ -11,6 +11,7 @@ import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription
 import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
 import { UserSubscriptionType } from '../Subscription/UserSubscriptionType'
 import { ApplyDefaultSubscriptionSettings } from '../UseCase/ApplyDefaultSubscriptionSettings/ApplyDefaultSubscriptionSettings'
+import { RenewSharedSubscriptions } from '../UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions'
 
 export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInterface {
   constructor(
@@ -19,6 +20,7 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
     private applyDefaultSubscriptionSettings: ApplyDefaultSubscriptionSettings,
     private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface,
     private roleService: RoleServiceInterface,
+    private renewSharedSubscriptions: RenewSharedSubscriptions,
     private logger: Logger,
   ) {}
 
@@ -58,6 +60,17 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
       event.payload.timestamp,
     )
 
+    const renewalResult = await this.renewSharedSubscriptions.execute({
+      inviterEmail: user.email,
+      newSubscriptionId: event.payload.subscriptionId,
+      newSubscriptionName: event.payload.subscriptionName,
+      newSubscriptionExpiresAt: event.payload.subscriptionExpiresAt,
+      timestamp: event.payload.timestamp,
+    })
+    if (renewalResult.isFailed()) {
+      this.logger.error(`Could not renew shared subscriptions for user ${user.uuid}: ${renewalResult.getError()}`)
+    }
+
     await this.addUserRole(user, event.payload.subscriptionName)
 
     const result = await this.applyDefaultSubscriptionSettings.execute({

+ 146 - 0
packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions.spec.ts

@@ -0,0 +1,146 @@
+import { Logger } from 'winston'
+import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface'
+import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { ListSharedSubscriptionInvitations } from '../ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations'
+import { RenewSharedSubscriptions } from './RenewSharedSubscriptions'
+import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation'
+import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType'
+import { User } from '../../User/User'
+import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
+
+describe('RenewSharedSubscriptions', () => {
+  let listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations
+  let sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface
+  let userSubscriptionRepository: UserSubscriptionRepositoryInterface
+  let userRepository: UserRepositoryInterface
+  let logger: Logger
+  let sharedSubscriptionInvitation: SharedSubscriptionInvitation
+  let user: User
+
+  const createUseCase = () =>
+    new RenewSharedSubscriptions(
+      listSharedSubscriptionInvitations,
+      sharedSubscriptionInvitationRepository,
+      userSubscriptionRepository,
+      userRepository,
+      logger,
+    )
+
+  beforeEach(() => {
+    user = {} as jest.Mocked<User>
+    user.uuid = '00000000-0000-0000-0000-000000000000'
+
+    sharedSubscriptionInvitation = {} as jest.Mocked<SharedSubscriptionInvitation>
+    sharedSubscriptionInvitation.uuid = '00000000-0000-0000-0000-000000000000'
+    sharedSubscriptionInvitation.inviteeIdentifier = 'test@test.te'
+    sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Email
+    sharedSubscriptionInvitation.status = InvitationStatus.Accepted
+
+    listSharedSubscriptionInvitations = {} as jest.Mocked<ListSharedSubscriptionInvitations>
+    listSharedSubscriptionInvitations.execute = jest.fn().mockReturnValue({
+      invitations: [sharedSubscriptionInvitation],
+    })
+
+    sharedSubscriptionInvitationRepository = {} as jest.Mocked<SharedSubscriptionInvitationRepositoryInterface>
+    sharedSubscriptionInvitationRepository.save = jest.fn()
+
+    userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
+    userSubscriptionRepository.save = jest.fn()
+
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
+
+    logger = {} as jest.Mocked<Logger>
+    logger.error = jest.fn()
+  })
+
+  it('should renew shared subscriptions', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      inviterEmail: 'inviter@test.te',
+      newSubscriptionId: 123,
+      newSubscriptionName: 'test',
+      newSubscriptionExpiresAt: 123,
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(sharedSubscriptionInvitationRepository.save).toBeCalledTimes(1)
+    expect(userSubscriptionRepository.save).toBeCalledTimes(1)
+  })
+
+  it('should log error if user not found', async () => {
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      inviterEmail: 'inviter@test.te',
+      newSubscriptionId: 123,
+      newSubscriptionName: 'test',
+      newSubscriptionExpiresAt: 123,
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(logger.error).toBeCalledTimes(1)
+  })
+
+  it('should log error if error occurs', async () => {
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockImplementation(() => {
+      throw new Error('test')
+    })
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      inviterEmail: 'inviter@test.te',
+      newSubscriptionId: 123,
+      newSubscriptionName: 'test',
+      newSubscriptionExpiresAt: 123,
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(logger.error).toBeCalledTimes(1)
+  })
+
+  it('should log error if username is invalid', async () => {
+    sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Email
+    sharedSubscriptionInvitation.inviteeIdentifier = ''
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      inviterEmail: 'inviter@test.te',
+      newSubscriptionId: 123,
+      newSubscriptionName: 'test',
+      newSubscriptionExpiresAt: 123,
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(logger.error).toBeCalledTimes(1)
+  })
+
+  it('should renew shared subscription for invitations by user uuid', async () => {
+    sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Uuid
+    sharedSubscriptionInvitation.inviteeIdentifier = '00000000-0000-0000-0000-000000000000'
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      inviterEmail: 'inviter@test.te',
+      newSubscriptionId: 123,
+      newSubscriptionName: 'test',
+      newSubscriptionExpiresAt: 123,
+      timestamp: 123,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(sharedSubscriptionInvitationRepository.save).toBeCalledTimes(1)
+    expect(userSubscriptionRepository.save).toBeCalledTimes(1)
+  })
+})

+ 104 - 0
packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions.ts

@@ -0,0 +1,104 @@
+import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
+import { Logger } from 'winston'
+
+import { RenewSharedSubscriptionsDTO } from './RenewSharedSubscriptionsDTO'
+import { ListSharedSubscriptionInvitations } from '../ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations'
+import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
+import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface'
+import { UserSubscription } from '../../Subscription/UserSubscription'
+import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
+import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType'
+
+export class RenewSharedSubscriptions implements UseCaseInterface<void> {
+  constructor(
+    private listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations,
+    private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface,
+    private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
+    private userRepository: UserRepositoryInterface,
+    private logger: Logger,
+  ) {}
+
+  async execute(dto: RenewSharedSubscriptionsDTO): Promise<Result<void>> {
+    const result = await this.listSharedSubscriptionInvitations.execute({
+      inviterEmail: dto.inviterEmail,
+    })
+
+    const acceptedInvitations = result.invitations.filter(
+      (invitation) => invitation.status === InvitationStatus.Accepted,
+    )
+
+    for (const invitation of acceptedInvitations) {
+      try {
+        const userUuid = await this.getInviteeUserUuid(invitation.inviteeIdentifier, invitation.inviteeIdentifierType)
+        if (userUuid === null) {
+          this.logger.error(
+            `[SUBSCRIPTION: ${dto.newSubscriptionId}] Could not renew shared subscription for invitation: ${invitation.uuid}: Could not find user with identifier: ${invitation.inviteeIdentifier}`,
+          )
+          continue
+        }
+
+        await this.createSharedSubscription({
+          subscriptionId: dto.newSubscriptionId,
+          subscriptionName: dto.newSubscriptionName,
+          userUuid,
+          timestamp: dto.timestamp,
+          subscriptionExpiresAt: dto.newSubscriptionExpiresAt,
+        })
+
+        invitation.subscriptionId = dto.newSubscriptionId
+        invitation.updatedAt = dto.timestamp
+
+        await this.sharedSubscriptionInvitationRepository.save(invitation)
+      } catch (error) {
+        this.logger.error(
+          `[SUBSCRIPTION: ${dto.newSubscriptionId}] Could not renew shared subscription for invitation: ${
+            invitation.uuid
+          }: ${(error as Error).message}`,
+        )
+      }
+    }
+
+    return Result.ok()
+  }
+
+  private async createSharedSubscription(dto: {
+    subscriptionId: number
+    subscriptionName: string
+    userUuid: string
+    subscriptionExpiresAt: number
+    timestamp: number
+  }): Promise<UserSubscription> {
+    const subscription = new UserSubscription()
+    subscription.planName = dto.subscriptionName
+    subscription.userUuid = dto.userUuid
+    subscription.createdAt = dto.timestamp
+    subscription.updatedAt = dto.timestamp
+    subscription.endsAt = dto.subscriptionExpiresAt
+    subscription.cancelled = false
+    subscription.subscriptionId = dto.subscriptionId
+    subscription.subscriptionType = UserSubscriptionType.Shared
+
+    return this.userSubscriptionRepository.save(subscription)
+  }
+
+  private async getInviteeUserUuid(inviteeIdentifier: string, inviteeIdentifierType: string): Promise<string | null> {
+    if (inviteeIdentifierType === InviteeIdentifierType.Email) {
+      const usernameOrError = Username.create(inviteeIdentifier)
+      if (usernameOrError.isFailed()) {
+        return null
+      }
+      const username = usernameOrError.getValue()
+
+      const user = await this.userRepository.findOneByUsernameOrEmail(username)
+      if (user === null) {
+        return null
+      }
+
+      return user.uuid
+    }
+
+    return inviteeIdentifier
+  }
+}

+ 7 - 0
packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptionsDTO.ts

@@ -0,0 +1,7 @@
+export interface RenewSharedSubscriptionsDTO {
+  inviterEmail: string
+  newSubscriptionId: number
+  newSubscriptionExpiresAt: number
+  newSubscriptionName: string
+  timestamp: number
+}