feat(auth): add tracking total customers count

This commit is contained in:
Karol Sójko
2022-09-30 13:47:33 +02:00
parent 27cfd0ccf6
commit 8a98f746eb
15 changed files with 122 additions and 5 deletions
@@ -9,4 +9,5 @@ export enum StatisticsMeasure {
NotesCountPaidUsers = 'notes-count-paid-users',
FilesCount = 'files-count',
NewCustomers = 'new-customers',
TotalCustomers = 'total-customers',
}
@@ -9,6 +9,7 @@ export interface StatisticsStoreInterface {
getYesterdayApplicationUsage(): Promise<Array<{ version: string; count: number }>>
getYesterdayOutOfSyncIncidents(): Promise<number>
incrementMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void>
setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void>
getMeasureAverage(measure: StatisticsMeasure, period: Period): Promise<number>
getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number>
}
@@ -7,6 +7,7 @@ export enum Period {
WeekBeforeLastWeek,
ThisMonth,
LastMonth,
ThisYear,
Last30Days,
Last7Days,
Q1ThisYear,
@@ -49,11 +49,19 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
return this.getMonthlyKey()
case Period.LastMonth:
return this.getMonthlyKey(this.getLastMonthDate())
case Period.ThisYear:
return this.getYearlyKey()
default:
throw new Error(`Unsuporrted period: ${period}`)
}
}
private getYearlyKey(date?: Date): string {
date = date ?? new Date()
return this.getYear(date)
}
private getMonthlyKey(date?: Date): string {
date = date ?? new Date()
@@ -16,6 +16,7 @@ describe('RedisStatisticsStore', () => {
pipeline = {} as jest.Mocked<IORedis.Pipeline>
pipeline.incr = jest.fn()
pipeline.incrbyfloat = jest.fn()
pipeline.set = jest.fn()
pipeline.setbit = jest.fn()
pipeline.exec = jest.fn()
@@ -92,6 +93,13 @@ describe('RedisStatisticsStore', () => {
expect(pipeline.exec).toHaveBeenCalled()
})
it('should set a value to a measure', async () => {
await createStore().setMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth])
expect(pipeline.set).toHaveBeenCalledTimes(2)
expect(pipeline.exec).toHaveBeenCalled()
})
it('should increment measure by a value', async () => {
await createStore().incrementMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth])
@@ -8,6 +8,16 @@ import { StatisticsStoreInterface } from '../../Domain/Statistics/StatisticsStor
export class RedisStatisticsStore implements StatisticsStoreInterface {
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
async setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void> {
const pipeline = this.redisClient.pipeline()
for (const period of periods) {
pipeline.set(`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`, value)
}
await pipeline.exec()
}
async getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number> {
const totalValue = await this.redisClient.get(
`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
@@ -13,7 +13,7 @@ import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscri
import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { UserSubscription } from '../Subscription/UserSubscription'
import { AnalyticsStoreInterface } from '@standardnotes/analytics'
import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
describe('SubscriptionExpiredEventHandler', () => {
@@ -27,6 +27,7 @@ describe('SubscriptionExpiredEventHandler', () => {
let timestamp: number
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
const createHandler = () =>
new SubscriptionExpiredEventHandler(
@@ -36,6 +37,7 @@ describe('SubscriptionExpiredEventHandler', () => {
roleService,
getUserAnalyticsId,
analyticsStore,
statisticsStore,
logger,
)
@@ -56,6 +58,7 @@ describe('SubscriptionExpiredEventHandler', () => {
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.updateEndsAt = jest.fn()
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
userSubscriptionRepository.findBySubscriptionId = jest
.fn()
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
@@ -84,6 +87,9 @@ describe('SubscriptionExpiredEventHandler', () => {
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.setMeasure = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.warn = jest.fn()
@@ -8,7 +8,13 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { AnalyticsStoreInterface, AnalyticsActivity, Period } from '@standardnotes/analytics'
import {
AnalyticsStoreInterface,
AnalyticsActivity,
Period,
StatisticsMeasure,
StatisticsStoreInterface,
} from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
@@ -21,6 +27,7 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
@@ -47,6 +54,14 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
analyticsId,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
Period.ThisYear,
])
}
private async removeRoleFromSubscriptionUsers(
@@ -73,12 +73,14 @@ describe('SubscriptionPurchasedEventHandler', () => {
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.incrementMeasure = jest.fn()
statisticsStore.setMeasure = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1)
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(0)
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription)
offlineUserSubscription = {} as jest.Mocked<OfflineUserSubscription>
@@ -108,6 +108,14 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
Period.ThisYear,
])
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
Period.ThisYear,
])
}
}
@@ -14,7 +14,7 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { UserSubscription } from '../Subscription/UserSubscription'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
import { AnalyticsActivity, AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics'
describe('SubscriptionRefundedEventHandler', () => {
let userRepository: UserRepositoryInterface
@@ -27,6 +27,7 @@ describe('SubscriptionRefundedEventHandler', () => {
let timestamp: number
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
const createHandler = () =>
new SubscriptionRefundedEventHandler(
@@ -36,6 +37,7 @@ describe('SubscriptionRefundedEventHandler', () => {
roleService,
getUserAnalyticsId,
analyticsStore,
statisticsStore,
logger,
)
@@ -57,6 +59,7 @@ describe('SubscriptionRefundedEventHandler', () => {
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.updateEndsAt = jest.fn()
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(1)
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
userSubscriptionRepository.findBySubscriptionId = jest
.fn()
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
@@ -86,6 +89,9 @@ describe('SubscriptionRefundedEventHandler', () => {
analyticsStore.markActivity = jest.fn()
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(true)
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.setMeasure = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
logger.warn = jest.fn()
@@ -8,7 +8,13 @@ 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 {
AnalyticsActivity,
AnalyticsStoreInterface,
Period,
StatisticsMeasure,
StatisticsStoreInterface,
} from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
@@ -21,6 +27,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
@@ -85,5 +92,13 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
await this.analyticsStore.markActivity([churnActivity], analyticsId, [period])
}
}
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
Period.ThisYear,
])
}
}
@@ -12,5 +12,6 @@ export interface UserSubscriptionRepositoryInterface {
findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]>
updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise<void>
updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise<void>
countActiveSubscriptions(): Promise<number>
save(subscription: UserSubscription): Promise<UserSubscription>
}
@@ -7,14 +7,16 @@ import { UserSubscription } from '../../Domain/Subscription/UserSubscription'
import { MySQLUserSubscriptionRepository } from './MySQLUserSubscriptionRepository'
import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType'
import { TimerInterface } from '@standardnotes/time'
describe('MySQLUserSubscriptionRepository', () => {
let ormRepository: Repository<UserSubscription>
let selectQueryBuilder: SelectQueryBuilder<UserSubscription>
let updateQueryBuilder: UpdateQueryBuilder<UserSubscription>
let subscription: UserSubscription
let timer: TimerInterface
const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository)
const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository, timer)
beforeEach(() => {
selectQueryBuilder = {} as jest.Mocked<SelectQueryBuilder<UserSubscription>>
@@ -28,6 +30,9 @@ describe('MySQLUserSubscriptionRepository', () => {
ormRepository = {} as jest.Mocked<Repository<UserSubscription>>
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
ormRepository.save = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
})
it('should save', async () => {
@@ -58,6 +63,25 @@ describe('MySQLUserSubscriptionRepository', () => {
expect(result).toEqual([canceledSubscription, subscription])
})
it('should count all active subscriptions', async () => {
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
selectQueryBuilder.select = jest.fn().mockReturnThis()
selectQueryBuilder.distinct = jest.fn().mockReturnThis()
selectQueryBuilder.where = jest.fn().mockReturnThis()
selectQueryBuilder.getCount = jest.fn().mockReturnValue(2)
const result = await createRepository().countActiveSubscriptions()
expect(selectQueryBuilder.select).toHaveBeenCalledWith('user_uuid')
expect(selectQueryBuilder.distinct).toHaveBeenCalled()
expect(selectQueryBuilder.where).toHaveBeenCalledWith('ends_at > :timestamp', {
timestamp: 123,
})
expect(selectQueryBuilder.getCount).toHaveBeenCalled()
expect(result).toEqual(2)
})
it('should find one longest lasting uncanceled subscription by user uuid if there are canceled ones', async () => {
const canceledSubscription = {
planName: SubscriptionName.ProPlan,
@@ -1,4 +1,5 @@
import { Uuid } from '@standardnotes/common'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import { Repository } from 'typeorm'
import TYPES from '../../Bootstrap/Types'
@@ -12,8 +13,18 @@ export class MySQLUserSubscriptionRepository implements UserSubscriptionReposito
constructor(
@inject(TYPES.ORMUserSubscriptionRepository)
private ormRepository: Repository<UserSubscription>,
@inject(TYPES.Timer) private timer: TimerInterface,
) {}
async countActiveSubscriptions(): Promise<number> {
return await this.ormRepository
.createQueryBuilder()
.select('user_uuid')
.distinct()
.where('ends_at > :timestamp', { timestamp: this.timer.getTimestampInMicroseconds() })
.getCount()
}
async findByUserUuid(userUuid: string): Promise<UserSubscription[]> {
return await this.ormRepository
.createQueryBuilder()