mirror of
https://github.com/standardnotes/server
synced 2026-01-16 20:04:32 -05:00
feat(auth): add renewal of shared subscriptions (#952)
This commit is contained in:
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface RenewSharedSubscriptionsDTO {
|
||||
inviterEmail: string
|
||||
newSubscriptionId: number
|
||||
newSubscriptionExpiresAt: number
|
||||
newSubscriptionName: string
|
||||
timestamp: number
|
||||
}
|
||||
Reference in New Issue
Block a user