Compare commits

...

4 Commits

Author SHA1 Message Date
standardci
82c9637f37 chore(release): publish new version
- @standardnotes/api-gateway@1.23.0
2022-09-30 12:02:50 +00:00
Karol Sójko
dfab849f48 feat(api-gateway): add churn metrics to the report 2022-09-30 14:01:15 +02:00
standardci
ad60b95537 chore(release): publish new version
- @standardnotes/analytics@1.32.0
 - @standardnotes/api-gateway@1.22.6
 - @standardnotes/auth-server@1.35.0
 - @standardnotes/syncing-server@1.8.15
2022-09-30 11:49:00 +00:00
Karol Sójko
8a98f746eb feat(auth): add tracking total customers count 2022-09-30 13:47:33 +02:00
24 changed files with 158 additions and 9 deletions

View File

@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.32.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.31.1...@standardnotes/analytics@1.32.0) (2022-09-30)
### Features
* **auth:** add tracking total customers count ([8a98f74](https://github.com/standardnotes/server/commit/8a98f746eb13c25f7940286aca594e2304232bdf))
## [1.31.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.31.0...@standardnotes/analytics@1.31.1) (2022-09-30)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/analytics",
"version": "1.31.1",
"version": "1.32.0",
"engines": {
"node": ">=14.0.0 <17.0.0"
},

View File

@@ -9,4 +9,5 @@ export enum StatisticsMeasure {
NotesCountPaidUsers = 'notes-count-paid-users',
FilesCount = 'files-count',
NewCustomers = 'new-customers',
TotalCustomers = 'total-customers',
}

View File

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

View File

@@ -7,6 +7,7 @@ export enum Period {
WeekBeforeLastWeek,
ThisMonth,
LastMonth,
ThisYear,
Last30Days,
Last7Days,
Q1ThisYear,

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.23.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.6...@standardnotes/api-gateway@1.23.0) (2022-09-30)
### Features
* **api-gateway:** add churn metrics to the report ([dfab849](https://github.com/standardnotes/api-gateway/commit/dfab849f48ab782c3cd2e97f52fdb72b7143002f))
## [1.22.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.5...@standardnotes/api-gateway@1.22.6) (2022-09-30)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.22.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.4...@standardnotes/api-gateway@1.22.5) (2022-09-30)
**Note:** Version bump only for package @standardnotes/api-gateway

View File

@@ -38,6 +38,8 @@ const requestReport = async (
AnalyticsActivity.DeleteAccount,
AnalyticsActivity.SubscriptionCancelled,
AnalyticsActivity.SubscriptionRefunded,
AnalyticsActivity.ExistingCustomersChurn,
AnalyticsActivity.NewCustomersChurn,
]
for (const analyticsName of thirtyDaysAnalyticsNames) {
@@ -74,6 +76,8 @@ const requestReport = async (
AnalyticsActivity.GeneralActivityPaidUsers,
AnalyticsActivity.PaymentFailed,
AnalyticsActivity.PaymentSuccess,
AnalyticsActivity.NewCustomersChurn,
AnalyticsActivity.ExistingCustomersChurn,
]
for (const activityName of yesterdayActivityNames) {
@@ -98,6 +102,8 @@ const requestReport = async (
StatisticsMeasure.NotesCountFreeUsers,
StatisticsMeasure.NotesCountPaidUsers,
StatisticsMeasure.FilesCount,
StatisticsMeasure.NewCustomers,
StatisticsMeasure.TotalCustomers,
]
const statisticMeasures = []
for (const statisticMeasureName of statisticMeasureNames) {

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.22.5",
"version": "1.23.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.35.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.34.1...@standardnotes/auth-server@1.35.0) (2022-09-30)
### Features
* **auth:** add tracking total customers count ([8a98f74](https://github.com/standardnotes/server/commit/8a98f746eb13c25f7940286aca594e2304232bdf))
## [1.34.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.34.0...@standardnotes/auth-server@1.34.1) (2022-09-30)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.34.1",
"version": "1.35.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.8.15](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.14...@standardnotes/syncing-server@1.8.15) (2022-09-30)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.8.14](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.13...@standardnotes/syncing-server@1.8.14) (2022-09-30)
**Note:** Version bump only for package @standardnotes/syncing-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.8.14",
"version": "1.8.15",
"engines": {
"node": ">=16.0.0 <17.0.0"
},