diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index 155dc5cff..785c94393 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/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(TYPES.Auth_TriggerEmailBackupForUser), ), ) + container + .bind(TYPES.Auth_RenewSharedSubscriptions) + .toConstantValue( + new RenewSharedSubscriptions( + container.get(TYPES.Auth_ListSharedSubscriptionInvitations), + container.get( + TYPES.Auth_SharedSubscriptionInvitationRepository, + ), + container.get(TYPES.Auth_UserSubscriptionRepository), + container.get(TYPES.Auth_UserRepository), + container.get(TYPES.Auth_Logger), + ), + ) if (!isConfiguredForHomeServer) { container .bind(TYPES.Auth_DeleteAccountsFromCSVFile) @@ -1349,6 +1363,7 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_ApplyDefaultSubscriptionSettings), container.get(TYPES.Auth_OfflineUserSubscriptionRepository), container.get(TYPES.Auth_RoleService), + container.get(TYPES.Auth_RenewSharedSubscriptions), container.get(TYPES.Auth_Logger), ), ) diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index f6a59b8e9..63b4f2d31 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/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'), diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts index 668cec7ca..cde6c4aa3 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts +++ b/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({ diff --git a/packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions.spec.ts b/packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions.spec.ts new file mode 100644 index 000000000..781637d5c --- /dev/null +++ b/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.uuid = '00000000-0000-0000-0000-000000000000' + + sharedSubscriptionInvitation = {} as jest.Mocked + 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.execute = jest.fn().mockReturnValue({ + invitations: [sharedSubscriptionInvitation], + }) + + sharedSubscriptionInvitationRepository = {} as jest.Mocked + sharedSubscriptionInvitationRepository.save = jest.fn() + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.save = jest.fn() + + userRepository = {} as jest.Mocked + userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user) + + logger = {} as jest.Mocked + 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) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions.ts b/packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions.ts new file mode 100644 index 000000000..ca837a9b8 --- /dev/null +++ b/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 { + constructor( + private listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations, + private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, + private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + private userRepository: UserRepositoryInterface, + private logger: Logger, + ) {} + + async execute(dto: RenewSharedSubscriptionsDTO): Promise> { + 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 { + 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 { + 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 + } +} diff --git a/packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptionsDTO.ts b/packages/auth/src/Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptionsDTO.ts new file mode 100644 index 000000000..23640e80e --- /dev/null +++ b/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 +}