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

This commit is contained in:
Karol Sójko
2023-12-04 12:58:14 +01:00
committed by GitHub
parent edd92ef81a
commit e150930072
6 changed files with 286 additions and 0 deletions

View File

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

View File

@@ -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'),

View File

@@ -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({

View File

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

View File

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

View File

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