feat: add analytics for subscription cancelling, refunding and account deletion

This commit is contained in:
Karol Sójko 2022-08-11 09:26:21 +02:00
parent 666c919b70
commit 16076382ba
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
11 changed files with 151 additions and 4 deletions

View file

@ -3,8 +3,11 @@ export enum AnalyticsActivity {
EditingItems = 'editing-items',
Login = 'login',
Register = 'register',
DeleteAccount = 'DeleteAccount',
SubscriptionPurchased = 'subscription-purchased',
SubscriptionRenewed = 'subscription-renewed',
SubscriptionRefunded = 'subscription-refunded',
SubscriptionCancelled = 'subscription-cancelled',
EmailUnbackedUpData = 'email-unbacked-up-data',
EmailBackup = 'email-backup',
LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased',

View file

@ -128,6 +128,42 @@ const requestReport = async (
Period.Last30Days,
),
},
{
name: AnalyticsActivity.DeleteAccount,
period: Period.Last30Days,
counts: await analyticsStore.calculateActivityChangesTotalCount(
AnalyticsActivity.DeleteAccount,
Period.Last30Days,
),
totalCount: await analyticsStore.calculateActivityTotalCountOverTime(
AnalyticsActivity.DeleteAccount,
Period.Last30Days,
),
},
{
name: AnalyticsActivity.SubscriptionCancelled,
period: Period.Last30Days,
counts: await analyticsStore.calculateActivityChangesTotalCount(
AnalyticsActivity.SubscriptionCancelled,
Period.Last30Days,
),
totalCount: await analyticsStore.calculateActivityTotalCountOverTime(
AnalyticsActivity.SubscriptionCancelled,
Period.Last30Days,
),
},
{
name: AnalyticsActivity.SubscriptionRefunded,
period: Period.Last30Days,
counts: await analyticsStore.calculateActivityChangesTotalCount(
AnalyticsActivity.SubscriptionRefunded,
Period.Last30Days,
),
totalCount: await analyticsStore.calculateActivityTotalCountOverTime(
AnalyticsActivity.SubscriptionRefunded,
Period.Last30Days,
),
},
],
},
}

View file

@ -11,6 +11,8 @@ import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterfac
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '@standardnotes/analytics'
describe('AccountDeletionRequestedEventHandler', () => {
let userRepository: UserRepositoryInterface
@ -23,6 +25,8 @@ describe('AccountDeletionRequestedEventHandler', () => {
let revokedSession: RevokedSession
let user: User
let event: AccountDeletionRequestedEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
const createHandler = () =>
new AccountDeletionRequestedEventHandler(
@ -30,6 +34,8 @@ describe('AccountDeletionRequestedEventHandler', () => {
sessionRepository,
ephemeralSessionRepository,
revokedSessionRepository,
getUserAnalyticsId,
analyticsStore,
logger,
)
@ -72,6 +78,12 @@ describe('AccountDeletionRequestedEventHandler', () => {
regularSubscriptionUuid: '2-3-4',
}
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.warn = jest.fn()

View file

@ -1,3 +1,4 @@
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
import { AccountDeletionRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
@ -5,6 +6,7 @@ import TYPES from '../../Bootstrap/Types'
import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface'
import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
@injectable()
@ -14,6 +16,8 @@ export class AccountDeletionRequestedEventHandler implements DomainEventHandlerI
@inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface,
@inject(TYPES.EphemeralSessionRepository) private ephemeralSessionRepository: EphemeralSessionRepositoryInterface,
@inject(TYPES.RevokedSessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
@ -28,6 +32,13 @@ export class AccountDeletionRequestedEventHandler implements DomainEventHandlerI
await this.removeSessions(event.payload.userUuid)
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
await this.analyticsStore.markActivity([AnalyticsActivity.DeleteAccount], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
await this.userRepository.remove(user)
this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`)

View file

@ -8,17 +8,41 @@ import * as dayjs from 'dayjs'
import { SubscriptionCancelledEventHandler } from './SubscriptionCancelledEventHandler'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { AnalyticsStoreInterface } from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { User } from '../User/User'
describe('SubscriptionCancelledEventHandler', () => {
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface
let event: SubscriptionCancelledEvent
let userRepository: UserRepositoryInterface
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let timestamp: number
const createHandler = () =>
new SubscriptionCancelledEventHandler(userSubscriptionRepository, offlineUserSubscriptionRepository)
new SubscriptionCancelledEventHandler(
userSubscriptionRepository,
offlineUserSubscriptionRepository,
userRepository,
getUserAnalyticsId,
analyticsStore,
)
beforeEach(() => {
const user = { uuid: '1-2-3' } as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.updateCancelled = jest.fn()
@ -42,6 +66,16 @@ describe('SubscriptionCancelledEventHandler', () => {
await createHandler().handle(event)
expect(userSubscriptionRepository.updateCancelled).toHaveBeenCalledWith(1, true, timestamp)
expect(analyticsStore.markActivity).toHaveBeenCalled()
})
it('should update subscription cancelled - user not found', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
await createHandler().handle(event)
expect(userSubscriptionRepository.updateCancelled).toHaveBeenCalledWith(1, true, timestamp)
expect(analyticsStore.markActivity).not.toHaveBeenCalled()
})
it('should update offline subscription cancelled', async () => {

View file

@ -4,6 +4,9 @@ import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
@injectable()
export class SubscriptionCancelledEventHandler implements DomainEventHandlerInterface {
@ -11,6 +14,9 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
@inject(TYPES.OfflineUserSubscriptionRepository)
private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface,
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
) {}
async handle(event: SubscriptionCancelledEvent): Promise<void> {
if (event.payload.offline) {
@ -20,6 +26,16 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
}
await this.updateSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
const user = await this.userRepository.findOneByEmail(event.payload.userEmail)
if (user !== null) {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionCancelled], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
}
private async updateSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> {

View file

@ -68,7 +68,11 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
)
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionPurchased], analyticsId, [Period.Today])
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionPurchased], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
const limitedDiscountPurchased = event.payload.discountCode === 'limited-10'
if (limitedDiscountPurchased) {

View file

@ -13,6 +13,8 @@ import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscri
import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { UserSubscription } from '../Subscription/UserSubscription'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '@standardnotes/analytics'
describe('SubscriptionRefundedEventHandler', () => {
let userRepository: UserRepositoryInterface
@ -23,6 +25,8 @@ describe('SubscriptionRefundedEventHandler', () => {
let user: User
let event: SubscriptionRefundedEvent
let timestamp: number
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
const createHandler = () =>
new SubscriptionRefundedEventHandler(
@ -30,6 +34,8 @@ describe('SubscriptionRefundedEventHandler', () => {
userSubscriptionRepository,
offlineUserSubscriptionRepository,
roleService,
getUserAnalyticsId,
analyticsStore,
logger,
)
@ -72,6 +78,12 @@ describe('SubscriptionRefundedEventHandler', () => {
offline: false,
}
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.warn = jest.fn()

View file

@ -8,6 +8,8 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
export class SubscriptionRefundedEventHandler implements DomainEventHandlerInterface {
@ -17,6 +19,8 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
@inject(TYPES.OfflineUserSubscriptionRepository)
private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface,
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
@ -36,6 +40,13 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
await this.updateSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp)
await this.removeRoleFromSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName)
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRefunded], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
private async removeRoleFromSubscriptionUsers(

View file

@ -61,7 +61,11 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
await this.addRoleToSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName)
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRenewed], analyticsId, [Period.Today])
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRenewed], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
private async addRoleToSubscriptionUsers(subscriptionId: number, subscriptionName: SubscriptionName): Promise<void> {

View file

@ -25,7 +25,11 @@ export class UserRegisteredEventHandler implements DomainEventHandlerInterface {
}
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: event.payload.userUuid })
await this.analyticsStore.markActivity([AnalyticsActivity.Register], analyticsId, [Period.Today])
await this.analyticsStore.markActivity([AnalyticsActivity.Register], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
await this.httpClient.request({
method: 'POST',