Compare commits

...

39 Commits

Author SHA1 Message Date
standardci
b0cde4ab75 chore(release): publish new version
- @standardnotes/analytics@1.49.0
 - @standardnotes/api-gateway@1.36.11
 - @standardnotes/auth-server@1.50.0
 - @standardnotes/domain-events-infra@1.9.10
 - @standardnotes/domain-events@2.76.0
 - @standardnotes/event-store@1.6.5
 - @standardnotes/files-server@1.8.5
 - @standardnotes/scheduler-server@1.13.6
 - @standardnotes/syncing-server@1.10.22
 - @standardnotes/websockets-server@1.4.5
 - @standardnotes/workspace-server@1.17.5
2022-11-07 06:45:26 +00:00
Karol Sójko
197c9914ca feat(analytics): add handling subscription refunded event 2022-11-07 07:43:36 +01:00
standardci
d7ef6898be chore(release): publish new version
- @standardnotes/analytics@1.48.0
 - @standardnotes/api-gateway@1.36.10
 - @standardnotes/auth-server@1.49.13
 - @standardnotes/syncing-server@1.10.21
2022-11-07 06:13:47 +00:00
Karol Sójko
2aa57f1f0d feat(analytics): add subscription renewed event handler 2022-11-07 07:11:10 +01:00
standardci
dcc0e38707 chore(release): publish new version
- @standardnotes/analytics@1.47.0
 - @standardnotes/api-gateway@1.36.9
 - @standardnotes/auth-server@1.49.12
 - @standardnotes/domain-events-infra@1.9.9
 - @standardnotes/domain-events@2.75.0
 - @standardnotes/event-store@1.6.4
 - @standardnotes/files-server@1.8.4
 - @standardnotes/scheduler-server@1.13.5
 - @standardnotes/syncing-server@1.10.20
 - @standardnotes/websockets-server@1.4.4
 - @standardnotes/workspace-server@1.17.4
2022-11-04 14:01:17 +00:00
Karol Sójko
037fb2398a feat(analytics): add subscription cancelled event handler 2022-11-04 14:59:30 +01:00
Karol Sójko
182512d07c fix(syncing-server): event specs 2022-11-04 14:59:30 +01:00
Karol Sójko
a3be4b063d fix(auth): event specs 2022-11-04 14:59:30 +01:00
standardci
a97be4c342 chore(release): publish new version
- @standardnotes/analytics@1.46.0
 - @standardnotes/api-gateway@1.36.8
 - @standardnotes/auth-server@1.49.11
 - @standardnotes/syncing-server@1.10.19
2022-11-04 13:28:39 +00:00
Karol Sójko
5902cbb621 feat(analytics): add payment success event handler 2022-11-04 14:26:47 +01:00
standardci
afc26d42ca chore(release): publish new version
- @standardnotes/analytics@1.45.0
 - @standardnotes/api-gateway@1.36.7
 - @standardnotes/auth-server@1.49.10
 - @standardnotes/syncing-server@1.10.18
2022-11-04 13:23:51 +00:00
Karol Sójko
51b12d05d4 feat: add payment failed handler and email to analytics entity 2022-11-04 14:21:34 +01:00
standardci
3fe7b4ae24 chore(release): publish new version
- @standardnotes/analytics@1.44.0
 - @standardnotes/api-gateway@1.36.6
 - @standardnotes/auth-server@1.49.9
 - @standardnotes/syncing-server@1.10.17
2022-11-04 13:02:55 +00:00
Karol Sójko
2720a7c827 feat(analytics): removing analytics entity upon account deletion 2022-11-04 14:01:10 +01:00
standardci
8d89b8ef12 chore(release): publish new version
- @standardnotes/analytics@1.43.0
 - @standardnotes/api-gateway@1.36.5
 - @standardnotes/auth-server@1.49.8
 - @standardnotes/domain-events-infra@1.9.8
 - @standardnotes/domain-events@2.74.1
 - @standardnotes/event-store@1.6.3
 - @standardnotes/files-server@1.8.3
 - @standardnotes/scheduler-server@1.13.4
 - @standardnotes/syncing-server@1.10.16
 - @standardnotes/websockets-server@1.4.3
 - @standardnotes/workspace-server@1.17.3
2022-11-04 12:55:55 +00:00
Karol Sójko
5383e0cf52 feat(analytics): add account deletion event handler 2022-11-04 13:54:07 +01:00
Karol Sójko
7b05bf8991 fix(auth): add user created at timestamp to account deletion event 2022-11-04 13:54:07 +01:00
Karol Sójko
b4c5b5a84e fix(api-gateway): removing sns bindings 2022-11-04 13:54:07 +01:00
standardci
e115523acd chore(release): publish new version
- @standardnotes/analytics@1.42.0
 - @standardnotes/api-gateway@1.36.4
 - @standardnotes/auth-server@1.49.7
 - @standardnotes/syncing-server@1.10.15
2022-11-04 10:20:35 +00:00
Karol Sójko
35611fbc07 fix(analytics): imports 2022-11-04 11:18:03 +01:00
Karol Sójko
034aa38153 feat(analytics): add user registered handler 2022-11-04 11:10:03 +01:00
standardci
795728ab31 chore(release): publish new version
- @standardnotes/analytics@1.41.0
 - @standardnotes/api-gateway@1.36.3
 - @standardnotes/auth-server@1.49.6
 - @standardnotes/syncing-server@1.10.14
2022-11-04 09:58:38 +00:00
Karol Sójko
262d295121 fix(analytics): linter setup with migrations 2022-11-04 10:56:22 +01:00
Karol Sójko
4e5ac0a47b feat(analytics): add retrieving user analytics id 2022-11-04 10:55:43 +01:00
standardci
51b8cbdab2 chore(release): publish new version
- @standardnotes/analytics@1.40.0
 - @standardnotes/api-gateway@1.36.2
 - @standardnotes/auth-server@1.49.5
 - @standardnotes/syncing-server@1.10.13
2022-11-04 09:52:57 +00:00
Karol Sójko
f315b1ac5c feat(analytics): add analytics entities 2022-11-04 10:50:38 +01:00
standardci
2feaa8d956 chore(release): publish new version
- @standardnotes/analytics@1.39.1
 - @standardnotes/api-gateway@1.36.1
 - @standardnotes/auth-server@1.49.4
 - @standardnotes/syncing-server@1.10.12
2022-11-04 09:16:15 +00:00
Karol Sójko
5329f2a2fb fix(analytics): linter setup 2022-11-04 10:14:18 +01:00
standardci
5d9d2d0c8d chore(release): publish new version
- @standardnotes/analytics@1.39.0
 - @standardnotes/api-gateway@1.36.0
 - @standardnotes/auth-server@1.49.3
 - @standardnotes/syncing-server@1.10.11
2022-11-04 09:09:58 +00:00
Karol Sójko
34e11fd5b0 feat(analytics): move the analytics report from api-gateway to analytics 2022-11-04 10:07:33 +01:00
Karol Sójko
dc1f19ed04 chore: fix commands 2022-11-04 09:50:36 +01:00
standardci
ff7c52a05e chore(release): publish new version
- @standardnotes/analytics@1.38.0
 - @standardnotes/api-gateway@1.35.1
 - @standardnotes/auth-server@1.49.2
 - @standardnotes/domain-events-infra@1.9.7
 - @standardnotes/domain-events@2.74.0
 - @standardnotes/event-store@1.6.2
 - @standardnotes/files-server@1.8.2
 - @standardnotes/scheduler-server@1.13.3
 - @standardnotes/syncing-server@1.10.10
 - @standardnotes/websockets-server@1.4.2
 - @standardnotes/workspace-server@1.17.2
2022-11-04 08:41:48 +00:00
Karol Sójko
d5684326b1 feat: add analytics worker service 2022-11-04 09:39:30 +01:00
standardci
017c55d190 chore(release): publish new version
- @standardnotes/auth-server@1.49.1
2022-11-03 13:31:06 +00:00
Karol Sójko
2504887e8d fix(auth): updating offline subscription end date 2022-11-03 14:29:17 +01:00
standardci
805e63379c chore(release): publish new version
- @standardnotes/scheduler-server@1.13.2
2022-11-03 10:03:57 +00:00
Karol Sójko
dcb20e6ea6 fix(scheduler): specs 2022-11-03 11:01:55 +01:00
standardci
786b94380b chore(release): publish new version
- @standardnotes/analytics@1.37.0
 - @standardnotes/api-gateway@1.35.0
 - @standardnotes/auth-server@1.49.0
 - @standardnotes/syncing-server@1.10.9
2022-11-03 09:53:56 +00:00
Karol Sójko
460d6a8d0f feat(auth): add analytics for subscription reactivating 2022-11-03 10:51:43 +01:00
89 changed files with 2801 additions and 382 deletions

39
.github/workflows/analytics.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Analytics Server
concurrency:
group: analytics
cancel-in-progress: true
on:
push:
tags:
- '*standardnotes/analytics*'
workflow_dispatch:
jobs:
call_server_application_workflow:
name: Server Application
uses: standardnotes/server/.github/workflows/common-server-application.yml@main
with:
service_name: analytics
workspace_name: "@standardnotes/analytics"
e2e_tag_parameter_name: analytics_image_tag
deploy_web: false
package_path: packages/analytics
secrets: inherit
newrelic:
needs: call_server_application_workflow
runs-on: ubuntu-latest
steps:
- name: Create New Relic deployment marker for Worker
uses: newrelic/deployment-marker-action@v1
with:
accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }}
apiKey: ${{ secrets.NEW_RELIC_API_KEY }}
applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_ANALYTICS_WORKER_PROD }}
revision: "${{ github.sha }}"
description: "Automated Deployment via Github Actions"
user: "${{ github.actor }}"

638
.pnp.cjs generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"lint:event-store": "yarn workspace @standardnotes/event-store lint",
"lint:websockets": "yarn workspace @standardnotes/websockets-server lint",
"lint:workspace": "yarn workspace @standardnotes/workspace-server lint",
"lint:analytics": "yarn workspace @standardnotes/analytics lint",
"clean": "yarn workspaces foreach -p --verbose run clean",
"setup:env": "cp .env.sample .env && yarn workspaces foreach -p --verbose run setup:env",
"start:auth": "yarn workspace @standardnotes/auth-server start",
@@ -32,6 +33,7 @@
"start:api-gateway": "yarn workspace @standardnotes/api-gateway start",
"start:websockets": "yarn workspace @standardnotes/websockets-server start",
"start:workspace": "yarn workspace @standardnotes/workspace-server start",
"start:analytics": "yarn workspace @standardnotes/analytics worker",
"release": "lerna version --conventional-graduate --conventional-commits --yes -m \"chore(release): publish new version\"",
"publish": "lerna publish from-git --yes --no-verify-access --loglevel verbose",
"postversion": "./scripts/push-tags-one-by-one.sh",

View File

@@ -0,0 +1,28 @@
LOG_LEVEL=debug
NODE_ENV=development
DB_HOST=127.0.0.1
DB_REPLICA_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=analytics
DB_PASSWORD=changeme123
DB_DATABASE=analytics
DB_DEBUG_LEVEL=all # "all" | "query" | "schema" | "error" | "warn" | "info" | "log" | "migration"
DB_MIGRATIONS_PATH=dist/migrations/*.js
REDIS_URL=redis://cache
REDIS_EVENTS_CHANNEL=events
SNS_TOPIC_ARN=
SNS_AWS_REGION=
SQS_QUEUE_URL=
SQS_AWS_REGION=
# (Optional) New Relic Setup
NEW_RELIC_ENABLED=false
NEW_RELIC_APP_NAME=Analytics
NEW_RELIC_LICENSE_KEY=
NEW_RELIC_NO_CONFIG_FILE=true
NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=false
NEW_RELIC_LOG_ENABLED=false
NEW_RELIC_LOG_LEVEL=info

View File

@@ -3,6 +3,98 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.49.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.48.0...@standardnotes/analytics@1.49.0) (2022-11-07)
### Features
* **analytics:** add handling subscription refunded event ([197c991](https://github.com/standardnotes/server/commit/197c9914caf8fb31ce3ee69d05381d2a6416b366))
# [1.48.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.47.0...@standardnotes/analytics@1.48.0) (2022-11-07)
### Features
* **analytics:** add subscription renewed event handler ([2aa57f1](https://github.com/standardnotes/server/commit/2aa57f1f0ddace741b79e9375b87464eaaba6320))
# [1.47.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.46.0...@standardnotes/analytics@1.47.0) (2022-11-04)
### Features
* **analytics:** add subscription cancelled event handler ([037fb23](https://github.com/standardnotes/server/commit/037fb2398ae9aaa11682e1a8576bab28c69e0f9b))
# [1.46.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.45.0...@standardnotes/analytics@1.46.0) (2022-11-04)
### Features
* **analytics:** add payment success event handler ([5902cbb](https://github.com/standardnotes/server/commit/5902cbb6218a5b3982b4c56f8d6644f4f5bc6d32))
# [1.45.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.44.0...@standardnotes/analytics@1.45.0) (2022-11-04)
### Features
* add payment failed handler and email to analytics entity ([51b12d0](https://github.com/standardnotes/server/commit/51b12d05d49868db1d61313c4d8b3829994e7eb3))
# [1.44.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.43.0...@standardnotes/analytics@1.44.0) (2022-11-04)
### Features
* **analytics:** removing analytics entity upon account deletion ([2720a7c](https://github.com/standardnotes/server/commit/2720a7c827a6352cb5254e88d42d45c385921448))
# [1.43.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.42.0...@standardnotes/analytics@1.43.0) (2022-11-04)
### Features
* **analytics:** add account deletion event handler ([5383e0c](https://github.com/standardnotes/server/commit/5383e0cf525ddd203beee451f1cfd5fd8600b522))
# [1.42.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.41.0...@standardnotes/analytics@1.42.0) (2022-11-04)
### Bug Fixes
* **analytics:** imports ([35611fb](https://github.com/standardnotes/server/commit/35611fbc07e50efbba954d7c96db9917e0dddd7d))
### Features
* **analytics:** add user registered handler ([034aa38](https://github.com/standardnotes/server/commit/034aa381539cf4b46769cfd1646dec2051d836c3))
# [1.41.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.40.0...@standardnotes/analytics@1.41.0) (2022-11-04)
### Bug Fixes
* **analytics:** linter setup with migrations ([262d295](https://github.com/standardnotes/server/commit/262d2951218753f6f14613c5d8ae20ade0e4ef06))
### Features
* **analytics:** add retrieving user analytics id ([4e5ac0a](https://github.com/standardnotes/server/commit/4e5ac0a47b469e5fa681f0131d04c9823cebedf3))
# [1.40.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.39.1...@standardnotes/analytics@1.40.0) (2022-11-04)
### Features
* **analytics:** add analytics entities ([f315b1a](https://github.com/standardnotes/server/commit/f315b1ac5c9369d36fa616c6b4bb5492148564f8))
## [1.39.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.39.0...@standardnotes/analytics@1.39.1) (2022-11-04)
### Bug Fixes
* **analytics:** linter setup ([5329f2a](https://github.com/standardnotes/server/commit/5329f2a2fb815f8691e37279fcadcf01beb716ad))
# [1.39.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.38.0...@standardnotes/analytics@1.39.0) (2022-11-04)
### Features
* **analytics:** move the analytics report from api-gateway to analytics ([34e11fd](https://github.com/standardnotes/server/commit/34e11fd5b0ef09a056c90127065c9dfae3e0172a))
# [1.38.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.37.0...@standardnotes/analytics@1.38.0) (2022-11-04)
### Features
* add analytics worker service ([d568432](https://github.com/standardnotes/server/commit/d5684326b1301855d0e07415195d4b246292f9a9))
# [1.37.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.36.0...@standardnotes/analytics@1.37.0) (2022-11-03)
### Features
* **auth:** add analytics for subscription reactivating ([460d6a8](https://github.com/standardnotes/server/commit/460d6a8d0f17eee624feb5d2588086ae6f0996e4))
# [1.36.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.35.1...@standardnotes/analytics@1.36.0) (2022-10-19)
### Features

View File

@@ -0,0 +1,17 @@
FROM node:16.15.1-alpine
RUN apk add --update \
curl \
&& rm -rf /var/cache/apk/*
ENV NODE_ENV production
RUN corepack enable
WORKDIR /workspace
COPY ./ /workspace
ENTRYPOINT [ "/workspace/packages/analytics/docker/entrypoint.sh" ]
CMD [ "start-worker" ]

View File

@@ -4,30 +4,34 @@ import 'newrelic'
import { Logger } from 'winston'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { AnalyticsActivity } from '../src/Domain/Analytics/AnalyticsActivity'
import { Period } from '../src/Domain/Time/Period'
import { StatisticsMeasure } from '../src/Domain/Statistics/StatisticsMeasure'
import { AnalyticsStoreInterface } from '../src/Domain/Analytics/AnalyticsStoreInterface'
import { StatisticsStoreInterface } from '../src/Domain/Statistics/StatisticsStoreInterface'
import { PeriodKeyGeneratorInterface } from '../src/Domain/Time/PeriodKeyGeneratorInterface'
import { ContainerConfigLoader } from '../src/Bootstrap/Container'
import TYPES from '../src/Bootstrap/Types'
import { Env } from '../src/Bootstrap/Env'
import {
DomainEventPublisherInterface,
DailyAnalyticsReportGeneratedEvent,
DomainEventService,
} from '@standardnotes/domain-events'
import {
AnalyticsActivity,
AnalyticsStoreInterface,
Period,
PeriodKeyGeneratorInterface,
StatisticsMeasure,
StatisticsStoreInterface,
} from '@standardnotes/analytics'
import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
const requestReport = async (
analyticsStore: AnalyticsStoreInterface,
statisticsStore: StatisticsStoreInterface,
domainEventFactory: DomainEventFactoryInterface,
domainEventPublisher: DomainEventPublisherInterface,
periodKeyGenerator: PeriodKeyGeneratorInterface,
): Promise<void> => {
const analyticsOverTime = []
const analyticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
totalCount: number
}> = []
const thirtyDaysAnalyticsNames = [
AnalyticsActivity.GeneralActivity,
@@ -40,6 +44,7 @@ const requestReport = async (
AnalyticsActivity.SubscriptionRefunded,
AnalyticsActivity.ExistingCustomersChurn,
AnalyticsActivity.NewCustomersChurn,
AnalyticsActivity.SubscriptionReactivated,
]
for (const analyticsName of thirtyDaysAnalyticsNames) {
@@ -68,7 +73,11 @@ const requestReport = async (
}
}
const yesterdayActivityStatistics = []
const yesterdayActivityStatistics: Array<{
name: string
retention: number
totalCount: number
}> = []
const yesterdayActivityNames = [
AnalyticsActivity.LimitedDiscountOfferPurchased,
AnalyticsActivity.GeneralActivity,
@@ -113,7 +122,13 @@ const requestReport = async (
StatisticsMeasure.NewCustomers,
StatisticsMeasure.TotalCustomers,
]
const statisticMeasures = []
const statisticMeasures: Array<{
name: string
totalValue: number
average: number
increments: number
period: number
}> = []
for (const statisticMeasureName of statisticMeasureNames) {
for (const period of [Period.Yesterday, Period.ThisMonth]) {
statisticMeasures.push({
@@ -127,7 +142,11 @@ const requestReport = async (
}
const periodKeys = periodKeyGenerator.getDiscretePeriodKeys(Period.Last7Days)
const retentionOverDays = []
const retentionOverDays: Array<{
firstPeriodKey: string
secondPeriodKey: string
value: number
}> = []
for (let i = 0; i < periodKeys.length; i++) {
for (let j = 0; j < periodKeys.length - i; j++) {
const dailyRetention = await analyticsStore.calculateActivitiesRetention({
@@ -146,7 +165,10 @@ const requestReport = async (
}
const monthlyPeriodKeys = periodKeyGenerator.getDiscretePeriodKeys(Period.ThisYear)
const churnRates = []
const churnRates: Array<{
rate: number
periodKey: string
}> = []
for (const monthPeriodKey of monthlyPeriodKeys) {
const monthPeriod = periodKeyGenerator.convertPeriodKeyToPeriod(monthPeriodKey)
const dailyPeriodKeys = periodKeyGenerator.getDiscretePeriodKeys(monthPeriod)
@@ -178,39 +200,28 @@ const requestReport = async (
})
}
const event: DailyAnalyticsReportGeneratedEvent = {
type: 'DAILY_ANALYTICS_REPORT_GENERATED',
createdAt: new Date(),
meta: {
correlation: {
userIdentifier: '',
userIdentifierType: 'uuid',
},
origin: DomainEventService.ApiGateway,
},
payload: {
applicationStatistics: await statisticsStore.getYesterdayApplicationUsage(),
snjsStatistics: await statisticsStore.getYesterdaySNJSUsage(),
outOfSyncIncidents: await statisticsStore.getYesterdayOutOfSyncIncidents(),
activityStatistics: yesterdayActivityStatistics,
activityStatisticsOverTime: analyticsOverTime,
statisticMeasures,
retentionStatistics: [
{
firstActivity: AnalyticsActivity.Register,
secondActivity: AnalyticsActivity.GeneralActivity,
retention: {
periodKeys,
values: retentionOverDays,
},
const event = domainEventFactory.createDailyAnalyticsReportGeneratedEvent({
applicationStatistics: await statisticsStore.getYesterdayApplicationUsage(),
snjsStatistics: await statisticsStore.getYesterdaySNJSUsage(),
outOfSyncIncidents: await statisticsStore.getYesterdayOutOfSyncIncidents(),
activityStatistics: yesterdayActivityStatistics,
activityStatisticsOverTime: analyticsOverTime,
statisticMeasures,
retentionStatistics: [
{
firstActivity: AnalyticsActivity.Register,
secondActivity: AnalyticsActivity.GeneralActivity,
retention: {
periodKeys,
values: retentionOverDays,
},
],
churn: {
periodKeys: monthlyPeriodKeys,
values: churnRates,
},
],
churn: {
periodKeys: monthlyPeriodKeys,
values: churnRates,
},
}
})
await domainEventPublisher.publish(event)
}
@@ -226,10 +237,13 @@ void container.load().then((container) => {
const analyticsStore: AnalyticsStoreInterface = container.get(TYPES.AnalyticsStore)
const statisticsStore: StatisticsStoreInterface = container.get(TYPES.StatisticsStore)
const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.DomainEventFactory)
const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.DomainEventPublisher)
const periodKeyGenerator: PeriodKeyGeneratorInterface = container.get(TYPES.PeriodKeyGenerator)
Promise.resolve(requestReport(analyticsStore, statisticsStore, domainEventPublisher, periodKeyGenerator))
Promise.resolve(
requestReport(analyticsStore, statisticsStore, domainEventFactory, domainEventPublisher, periodKeyGenerator),
)
.then(() => {
logger.info('Usage report generation complete')

View File

@@ -0,0 +1,29 @@
import 'reflect-metadata'
import 'newrelic'
import { Logger } from 'winston'
import { DomainEventSubscriberFactoryInterface } from '@standardnotes/domain-events'
import * as dayjs from 'dayjs'
import * as utc from 'dayjs/plugin/utc'
import { ContainerConfigLoader } from '../src/Bootstrap/Container'
import TYPES from '../src/Bootstrap/Types'
import { Env } from '../src/Bootstrap/Env'
const container = new ContainerConfigLoader()
void container.load().then((container) => {
dayjs.extend(utc)
const env: Env = new Env()
env.load()
const logger: Logger = container.get(TYPES.Logger)
logger.info('Starting worker...')
const subscriberFactory: DomainEventSubscriberFactoryInterface = container.get(TYPES.DomainEventSubscriberFactory)
subscriberFactory.create().start()
setInterval(() => logger.info('Alive and kicking!'), 20 * 60 * 1000)
})

View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -e
COMMAND=$1 && shift 1
case "$COMMAND" in
'start-worker' )
echo "Starting Worker..."
yarn workspace @standardnotes/analytics worker
;;
'report' )
echo "Starting Usage Report Generation..."
yarn workspace @standardnotes/analytics report
;;
* )
echo "Unknown command"
;;
esac
exec "$@"

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class initDatabase1667555285111 implements MigrationInterface {
name = 'initDatabase1667555285111'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE TABLE `analytics_entities` (`id` int NOT NULL AUTO_INCREMENT, `user_uuid` varchar(36) NOT NULL, INDEX `user_uuid` (`user_uuid`), PRIMARY KEY (`id`)) ENGINE=InnoDB',
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX `user_uuid` ON `analytics_entities`')
await queryRunner.query('DROP TABLE `analytics_entities`')
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class addEmailToAnalyticsEntity1667568051894 implements MigrationInterface {
name = 'addEmailToAnalyticsEntity1667568051894'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `analytics_entities` ADD `user_email` varchar(255) NULL')
await queryRunner.query('CREATE INDEX `email` ON `analytics_entities` (`user_email`)')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX `email` ON `analytics_entities`')
await queryRunner.query('ALTER TABLE `analytics_entities` DROP COLUMN `user_email`')
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/analytics",
"version": "1.36.0",
"version": "1.49.0",
"engines": {
"node": ">=14.0.0 <17.0.0"
},
@@ -20,19 +20,41 @@
"clean": "rm -fr dist",
"build": "tsc --build",
"lint": "eslint . --ext .ts",
"test": "jest spec --coverage"
"lint:fix": "eslint . --ext .ts --fix",
"test": "jest --coverage --config=./jest.config.js --maxWorkers=50%",
"worker": "yarn node dist/bin/worker.js",
"report": "yarn node dist/bin/report.js",
"setup:env": "cp .env.sample .env",
"typeorm": "typeorm-ts-node-commonjs"
},
"devDependencies": {
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.1.1",
"@types/newrelic": "^7.0.3",
"@types/node": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"eslint": "^8.14.0",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.1.2",
"ts-jest": "^29.0.3",
"typescript": "^4.8.4"
},
"dependencies": {
"@newrelic/winston-enricher": "^4.0.0",
"@sentry/node": "^7.3.0",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "workspace:*",
"@standardnotes/time": "workspace:*",
"aws-sdk": "^2.1158.0",
"dayjs": "^1.11.6",
"dotenv": "^16.0.1",
"inversify": "^6.0.1",
"ioredis": "^5.2.3",
"reflect-metadata": "^0.1.13"
"mysql2": "^2.3.3",
"newrelic": "^9.0.0",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.3.6",
"winston": "^3.8.1"
}
}

View File

@@ -0,0 +1,202 @@
import * as winston from 'winston'
import Redis from 'ioredis'
import * as AWS from 'aws-sdk'
import { Container } from 'inversify'
import {
DomainEventHandlerInterface,
DomainEventMessageHandlerInterface,
DomainEventSubscriberFactoryInterface,
} from '@standardnotes/domain-events'
import { Env } from './Env'
import TYPES from './Types'
import { AppDataSource } from './DataSource'
import { DomainEventFactory } from '../Domain/Event/DomainEventFactory'
import {
RedisDomainEventPublisher,
RedisDomainEventSubscriberFactory,
RedisEventMessageHandler,
SNSDomainEventPublisher,
SQSDomainEventSubscriberFactory,
SQSEventMessageHandler,
SQSNewRelicEventMessageHandler,
} from '@standardnotes/domain-events-infra'
import { Timer, TimerInterface } from '@standardnotes/time'
import { PeriodKeyGeneratorInterface } from '../Domain/Time/PeriodKeyGeneratorInterface'
import { PeriodKeyGenerator } from '../Domain/Time/PeriodKeyGenerator'
import { AnalyticsStoreInterface } from '../Domain/Analytics/AnalyticsStoreInterface'
import { RedisAnalyticsStore } from '../Infra/Redis/RedisAnalyticsStore'
import { StatisticsStoreInterface } from '../Domain/Statistics/StatisticsStoreInterface'
import { RedisStatisticsStore } from '../Infra/Redis/RedisStatisticsStore'
import { AnalyticsEntityRepositoryInterface } from '../Domain/Entity/AnalyticsEntityRepositoryInterface'
import { MySQLAnalyticsEntityRepository } from '../Infra/MySQL/MySQLAnalyticsEntityRepository'
import { Repository } from 'typeorm'
import { AnalyticsEntity } from '../Domain/Entity/AnalyticsEntity'
import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { UserRegisteredEventHandler } from '../Domain/Handler/UserRegisteredEventHandler'
import { AccountDeletionRequestedEventHandler } from '../Domain/Handler/AccountDeletionRequestedEventHandler'
import { PaymentFailedEventHandler } from '../Domain/Handler/PaymentFailedEventHandler'
import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEventHandler'
import { SubscriptionCancelledEventHandler } from '../Domain/Handler/SubscriptionCancelledEventHandler'
import { SubscriptionRenewedEventHandler } from '../Domain/Handler/SubscriptionRenewedEventHandler'
import { SubscriptionRefundedEventHandler } from '../Domain/Handler/SubscriptionRefundedEventHandler'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
export class ContainerConfigLoader {
async load(): Promise<Container> {
const env: Env = new Env()
env.load()
const container = new Container()
await AppDataSource.initialize()
const redisUrl = env.get('REDIS_URL')
const isRedisInClusterMode = redisUrl.indexOf(',') > 0
let redis
if (isRedisInClusterMode) {
redis = new Redis.Cluster(redisUrl.split(','))
} else {
redis = new Redis(redisUrl)
}
container.bind(TYPES.Redis).toConstantValue(redis)
const newrelicWinstonFormatter = newrelicFormatter(winston)
const winstonFormatters = [winston.format.splat(), winston.format.json()]
if (env.get('NEW_RELIC_ENABLED', true) === 'true') {
winstonFormatters.push(newrelicWinstonFormatter())
}
const logger = winston.createLogger({
level: env.get('LOG_LEVEL') || 'info',
format: winston.format.combine(...winstonFormatters),
transports: [new winston.transports.Console({ level: env.get('LOG_LEVEL') || 'info' })],
})
container.bind<winston.Logger>(TYPES.Logger).toConstantValue(logger)
if (env.get('SNS_AWS_REGION', true)) {
container.bind<AWS.SNS>(TYPES.SNS).toConstantValue(
new AWS.SNS({
apiVersion: 'latest',
region: env.get('SNS_AWS_REGION', true),
}),
)
}
if (env.get('SQS_QUEUE_URL', true)) {
const sqsConfig: AWS.SQS.Types.ClientConfiguration = {
apiVersion: 'latest',
region: env.get('SQS_AWS_REGION', true),
}
if (env.get('SQS_ACCESS_KEY_ID', true) && env.get('SQS_SECRET_ACCESS_KEY', true)) {
sqsConfig.credentials = {
accessKeyId: env.get('SQS_ACCESS_KEY_ID', true),
secretAccessKey: env.get('SQS_SECRET_ACCESS_KEY', true),
}
}
container.bind<AWS.SQS>(TYPES.SQS).toConstantValue(new AWS.SQS(sqsConfig))
}
// env vars
container.bind(TYPES.REDIS_URL).toConstantValue(env.get('REDIS_URL'))
container.bind(TYPES.SNS_TOPIC_ARN).toConstantValue(env.get('SNS_TOPIC_ARN', true))
container.bind(TYPES.SNS_AWS_REGION).toConstantValue(env.get('SNS_AWS_REGION', true))
container.bind(TYPES.SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL', true))
container.bind(TYPES.REDIS_EVENTS_CHANNEL).toConstantValue(env.get('REDIS_EVENTS_CHANNEL'))
container.bind(TYPES.NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
// Repositories
container
.bind<AnalyticsEntityRepositoryInterface>(TYPES.AnalyticsEntityRepository)
.to(MySQLAnalyticsEntityRepository)
// ORM
container
.bind<Repository<AnalyticsEntity>>(TYPES.ORMAnalyticsEntityRepository)
.toConstantValue(AppDataSource.getRepository(AnalyticsEntity))
// Use Case
container.bind<GetUserAnalyticsId>(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId)
// Hanlders
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
container
.bind<AccountDeletionRequestedEventHandler>(TYPES.AccountDeletionRequestedEventHandler)
.to(AccountDeletionRequestedEventHandler)
container.bind<PaymentFailedEventHandler>(TYPES.PaymentFailedEventHandler).to(PaymentFailedEventHandler)
container.bind<PaymentSuccessEventHandler>(TYPES.PaymentSuccessEventHandler).to(PaymentSuccessEventHandler)
container
.bind<SubscriptionCancelledEventHandler>(TYPES.SubscriptionCancelledEventHandler)
.to(SubscriptionCancelledEventHandler)
container
.bind<SubscriptionRenewedEventHandler>(TYPES.SubscriptionRenewedEventHandler)
.to(SubscriptionRenewedEventHandler)
container
.bind<SubscriptionRefundedEventHandler>(TYPES.SubscriptionRefundedEventHandler)
.to(SubscriptionRefundedEventHandler)
// Services
container.bind<DomainEventFactory>(TYPES.DomainEventFactory).to(DomainEventFactory)
container.bind<PeriodKeyGeneratorInterface>(TYPES.PeriodKeyGenerator).toConstantValue(new PeriodKeyGenerator())
container
.bind<AnalyticsStoreInterface>(TYPES.AnalyticsStore)
.toConstantValue(new RedisAnalyticsStore(container.get(TYPES.PeriodKeyGenerator), container.get(TYPES.Redis)))
container
.bind<StatisticsStoreInterface>(TYPES.StatisticsStore)
.toConstantValue(new RedisStatisticsStore(container.get(TYPES.PeriodKeyGenerator), container.get(TYPES.Redis)))
container.bind<TimerInterface>(TYPES.Timer).toConstantValue(new Timer())
if (env.get('SNS_TOPIC_ARN', true)) {
container
.bind<SNSDomainEventPublisher>(TYPES.DomainEventPublisher)
.toConstantValue(new SNSDomainEventPublisher(container.get(TYPES.SNS), container.get(TYPES.SNS_TOPIC_ARN)))
} else {
container
.bind<RedisDomainEventPublisher>(TYPES.DomainEventPublisher)
.toConstantValue(
new RedisDomainEventPublisher(container.get(TYPES.Redis), container.get(TYPES.REDIS_EVENTS_CHANNEL)),
)
}
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['USER_REGISTERED', container.get(TYPES.UserRegisteredEventHandler)],
])
if (env.get('SQS_QUEUE_URL', true)) {
container
.bind<DomainEventMessageHandlerInterface>(TYPES.DomainEventMessageHandler)
.toConstantValue(
env.get('NEW_RELIC_ENABLED', true) === 'true'
? new SQSNewRelicEventMessageHandler(eventHandlers, container.get(TYPES.Logger))
: new SQSEventMessageHandler(eventHandlers, container.get(TYPES.Logger)),
)
container
.bind<DomainEventSubscriberFactoryInterface>(TYPES.DomainEventSubscriberFactory)
.toConstantValue(
new SQSDomainEventSubscriberFactory(
container.get(TYPES.SQS),
container.get(TYPES.SQS_QUEUE_URL),
container.get(TYPES.DomainEventMessageHandler),
),
)
} else {
container
.bind<DomainEventMessageHandlerInterface>(TYPES.DomainEventMessageHandler)
.toConstantValue(new RedisEventMessageHandler(eventHandlers, container.get(TYPES.Logger)))
container
.bind<DomainEventSubscriberFactoryInterface>(TYPES.DomainEventSubscriberFactory)
.toConstantValue(
new RedisDomainEventSubscriberFactory(
container.get(TYPES.Redis),
container.get(TYPES.DomainEventMessageHandler),
container.get(TYPES.REDIS_EVENTS_CHANNEL),
),
)
}
return container
}
}

View File

@@ -0,0 +1,43 @@
import { DataSource, LoggerOptions } from 'typeorm'
import { AnalyticsEntity } from '../Domain/Entity/AnalyticsEntity'
import { Env } from './Env'
const env: Env = new Env()
env.load()
const maxQueryExecutionTime = env.get('DB_MAX_QUERY_EXECUTION_TIME', true)
? +env.get('DB_MAX_QUERY_EXECUTION_TIME', true)
: 45_000
export const AppDataSource = new DataSource({
type: 'mysql',
charset: 'utf8mb4',
supportBigNumbers: true,
bigNumberStrings: false,
maxQueryExecutionTime,
replication: {
master: {
host: env.get('DB_HOST'),
port: parseInt(env.get('DB_PORT')),
username: env.get('DB_USERNAME'),
password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'),
},
slaves: [
{
host: env.get('DB_REPLICA_HOST'),
port: parseInt(env.get('DB_PORT')),
username: env.get('DB_USERNAME'),
password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'),
},
],
removeNodeErrorCount: 10,
},
entities: [AnalyticsEntity],
migrations: [env.get('DB_MIGRATIONS_PATH', true) ?? 'dist/migrations/*.js'],
migrationsRun: true,
logging: <LoggerOptions>env.get('DB_DEBUG_LEVEL'),
})

View File

@@ -0,0 +1,24 @@
import { config, DotenvParseOutput } from 'dotenv'
import { injectable } from 'inversify'
@injectable()
export class Env {
private env?: DotenvParseOutput
public load(): void {
const output = config()
this.env = <DotenvParseOutput>output.parsed
}
public get(key: string, optional = false): string {
if (!this.env) {
this.load()
}
if (!process.env[key] && !optional) {
throw new Error(`Environment variable ${key} not set`)
}
return <string>process.env[key]
}
}

View File

@@ -0,0 +1,39 @@
const TYPES = {
Logger: Symbol.for('Logger'),
Redis: Symbol.for('Redis'),
SNS: Symbol.for('SNS'),
SQS: Symbol.for('SQS'),
// env vars
REDIS_URL: Symbol.for('REDIS_URL'),
SNS_TOPIC_ARN: Symbol.for('SNS_TOPIC_ARN'),
SNS_AWS_REGION: Symbol.for('SNS_AWS_REGION'),
SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'),
SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'),
REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'),
NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'),
// Repositories
AnalyticsEntityRepository: Symbol.for('AnalyticsEntityRepository'),
// ORM
ORMAnalyticsEntityRepository: Symbol.for('ORMAnalyticsEntityRepository'),
// Use Case
GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'),
// Handlers
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
PaymentFailedEventHandler: Symbol.for('PaymentFailedEventHandler'),
PaymentSuccessEventHandler: Symbol.for('PaymentSuccessEventHandler'),
SubscriptionCancelledEventHandler: Symbol.for('SubscriptionCancelledEventHandler'),
SubscriptionRenewedEventHandler: Symbol.for('SubscriptionRenewedEventHandler'),
SubscriptionRefundedEventHandler: Symbol.for('SubscriptionRefundedEventHandler'),
// Services
DomainEventPublisher: Symbol.for('DomainEventPublisher'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
DomainEventFactory: Symbol.for('DomainEventFactory'),
DomainEventMessageHandler: Symbol.for('DomainEventMessageHandler'),
AnalyticsStore: Symbol.for('AnalyticsStore'),
StatisticsStore: Symbol.for('StatisticsStore'),
Timer: Symbol.for('Timer'),
PeriodKeyGenerator: Symbol.for('PeriodKeyGenerator'),
}
export default TYPES

View File

@@ -12,6 +12,7 @@ export enum AnalyticsActivity {
SubscriptionRefunded = 'subscription-refunded',
SubscriptionCancelled = 'subscription-cancelled',
SubscriptionExpired = 'subscription-expired',
SubscriptionReactivated = 'subscription-reactivated',
EmailUnbackedUpData = 'email-unbacked-up-data',
EmailBackup = 'email-backup',
LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased',

View File

@@ -0,0 +1,22 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'analytics_entities' })
export class AnalyticsEntity {
@PrimaryGeneratedColumn()
declare id: number
@Column({
name: 'user_uuid',
length: 36,
})
@Index('user_uuid')
declare userUuid: string
@Column({
name: 'user_email',
length: 255,
nullable: true,
})
@Index('email')
declare userEmail: string
}

View File

@@ -0,0 +1,9 @@
import { Uuid } from '@standardnotes/common'
import { AnalyticsEntity } from './AnalyticsEntity'
export interface AnalyticsEntityRepositoryInterface {
save(analyticsEntity: AnalyticsEntity): Promise<AnalyticsEntity>
remove(analyticsEntity: AnalyticsEntity): Promise<void>
findOneByUserUuid(userUuid: Uuid): Promise<AnalyticsEntity | null>
findOneByUserEmail(email: string): Promise<AnalyticsEntity | null>
}

View File

@@ -0,0 +1,174 @@
import 'reflect-metadata'
import { TimerInterface } from '@standardnotes/time'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { Period } from '../Time/Period'
import { DomainEventFactory } from './DomainEventFactory'
describe('DomainEventFactory', () => {
let timer: TimerInterface
const createFactory = () => new DomainEventFactory(timer)
beforeEach(() => {
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
timer.getUTCDate = jest.fn().mockReturnValue(new Date(1))
})
it('should create a DAILY_ANALYTICS_REPORT_GENERATED event', () => {
expect(
createFactory().createDailyAnalyticsReportGeneratedEvent({
snjsStatistics: [
{
version: '1-2-3',
count: 2,
},
],
applicationStatistics: [
{
version: '2-3-4',
count: 45,
},
],
activityStatistics: [
{
name: AnalyticsActivity.Register,
retention: 24,
totalCount: 45,
},
],
statisticMeasures: [
{
name: StatisticsMeasure.Income,
totalValue: 43,
average: 23,
increments: 5,
period: Period.Today,
},
],
activityStatisticsOverTime: [
{
name: AnalyticsActivity.Register,
period: Period.Last30Days,
counts: [
{
periodKey: '2022-10-9',
totalCount: 3,
},
],
totalCount: 123,
},
],
outOfSyncIncidents: 324,
retentionStatistics: [
{
firstActivity: AnalyticsActivity.Register,
secondActivity: AnalyticsActivity.Login,
retention: {
periodKeys: ['2022-10-9'],
values: [
{
firstPeriodKey: AnalyticsActivity.Register,
secondPeriodKey: AnalyticsActivity.Login,
value: 12,
},
],
},
},
],
churn: {
periodKeys: ['2022-10-9'],
values: [
{
rate: 12,
periodKey: '2022-10-9',
},
],
},
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '',
userIdentifierType: 'uuid',
},
origin: 'analytics',
},
payload: {
activityStatistics: [
{
name: 'register',
retention: 24,
totalCount: 45,
},
],
activityStatisticsOverTime: [
{
counts: [
{
periodKey: '2022-10-9',
totalCount: 3,
},
],
name: 'register',
period: 9,
totalCount: 123,
},
],
applicationStatistics: [
{
count: 45,
version: '2-3-4',
},
],
churn: {
periodKeys: ['2022-10-9'],
values: [
{
periodKey: '2022-10-9',
rate: 12,
},
],
},
outOfSyncIncidents: 324,
retentionStatistics: [
{
firstActivity: 'register',
retention: {
periodKeys: ['2022-10-9'],
values: [
{
firstPeriodKey: 'register',
secondPeriodKey: 'login',
value: 12,
},
],
},
secondActivity: 'login',
},
],
snjsStatistics: [
{
count: 2,
version: '1-2-3',
},
],
statisticMeasures: [
{
average: 23,
increments: 5,
name: 'income',
period: 0,
totalValue: 43,
},
],
},
type: 'DAILY_ANALYTICS_REPORT_GENERATED',
})
})
})

View File

@@ -0,0 +1,75 @@
import { DomainEventService, DailyAnalyticsReportGeneratedEvent } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
@injectable()
export class DomainEventFactory implements DomainEventFactoryInterface {
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
createDailyAnalyticsReportGeneratedEvent(dto: {
snjsStatistics: Array<{
version: string
count: number
}>
applicationStatistics: Array<{
version: string
count: number
}>
activityStatistics: Array<{
name: string
retention: number
totalCount: number
}>
statisticMeasures: Array<{
name: string
totalValue: number
average: number
increments: number
period: number
}>
activityStatisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
totalCount: number
}>
outOfSyncIncidents: number
retentionStatistics: Array<{
firstActivity: string
secondActivity: string
retention: {
periodKeys: Array<string>
values: Array<{
firstPeriodKey: string
secondPeriodKey: string
value: number
}>
}
}>
churn: {
periodKeys: Array<string>
values: Array<{
rate: number
periodKey: string
}>
}
}): DailyAnalyticsReportGeneratedEvent {
return {
type: 'DAILY_ANALYTICS_REPORT_GENERATED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: '',
userIdentifierType: 'uuid',
},
origin: DomainEventService.Analytics,
},
payload: dto,
}
}
}

View File

@@ -0,0 +1,55 @@
import { DailyAnalyticsReportGeneratedEvent } from '@standardnotes/domain-events'
export interface DomainEventFactoryInterface {
createDailyAnalyticsReportGeneratedEvent(dto: {
snjsStatistics: Array<{
version: string
count: number
}>
applicationStatistics: Array<{
version: string
count: number
}>
activityStatistics: Array<{
name: string
retention: number
totalCount: number
}>
statisticMeasures: Array<{
name: string
totalValue: number
average: number
increments: number
period: number
}>
activityStatisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
totalCount: number
}>
outOfSyncIncidents: number
retentionStatistics: Array<{
firstActivity: string
secondActivity: string
retention: {
periodKeys: Array<string>
values: Array<{
firstPeriodKey: string
secondPeriodKey: string
value: number
}>
}
}>
churn: {
periodKeys: Array<string>
values: Array<{
rate: number
periodKey: string
}>
}
}): DailyAnalyticsReportGeneratedEvent
}

View File

@@ -0,0 +1,69 @@
import 'reflect-metadata'
import { AccountDeletionRequestedEvent } from '@standardnotes/domain-events'
import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler'
import { TimerInterface } from '@standardnotes/time'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { Period } from '../Time/Period'
import { AnalyticsEntityRepositoryInterface } from '../Entity/AnalyticsEntityRepositoryInterface'
describe('AccountDeletionRequestedEventHandler', () => {
let event: AccountDeletionRequestedEvent
let analyticsEntityRepository: AnalyticsEntityRepositoryInterface
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
let timer: TimerInterface
const createHandler = () =>
new AccountDeletionRequestedEventHandler(analyticsEntityRepository, analyticsStore, statisticsStore, timer)
beforeEach(() => {
event = {} as jest.Mocked<AccountDeletionRequestedEvent>
event.createdAt = new Date(1)
event.payload = {
userUuid: '1-2-3',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: '2-3-4',
}
analyticsEntityRepository = {} as jest.Mocked<AnalyticsEntityRepositoryInterface>
analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue({ id: 3 })
analyticsEntityRepository.remove = jest.fn()
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.incrementMeasure = jest.fn()
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
})
it('should mark account deletion and registration length', async () => {
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalledWith(['DeleteAccount'], 3, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
expect(statisticsStore.incrementMeasure).toHaveBeenCalledWith('registration-length', 122, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
expect(analyticsEntityRepository.remove).toHaveBeenCalled()
})
it('should not mark anything if entity is not found', async () => {
analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
await createHandler().handle(event)
expect(analyticsStore.markActivity).not.toHaveBeenCalled()
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
expect(analyticsEntityRepository.remove).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,44 @@
import { AccountDeletionRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { AnalyticsEntityRepositoryInterface } from '../Entity/AnalyticsEntityRepositoryInterface'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { Period } from '../Time/Period'
@injectable()
export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.AnalyticsEntityRepository) private analyticsEntityRepository: AnalyticsEntityRepositoryInterface,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
@inject(TYPES.Timer) private timer: TimerInterface,
) {}
async handle(event: AccountDeletionRequestedEvent): Promise<void> {
const analyticsEntity = await this.analyticsEntityRepository.findOneByUserUuid(event.payload.userUuid)
if (analyticsEntity === null) {
return
}
await this.analyticsStore.markActivity([AnalyticsActivity.DeleteAccount], analyticsEntity.id, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
const registrationLength = this.timer.getTimestampInMicroseconds() - event.payload.userCreatedAtTimestamp
await this.statisticsStore.incrementMeasure(StatisticsMeasure.RegistrationLength, registrationLength, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
await this.analyticsEntityRepository.remove(analyticsEntity)
}
}

View File

@@ -0,0 +1,34 @@
import 'reflect-metadata'
import { PaymentFailedEvent } from '@standardnotes/domain-events'
import { PaymentFailedEventHandler } from './PaymentFailedEventHandler'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
describe('PaymentFailedEventHandler', () => {
let event: PaymentFailedEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
const createHandler = () => new PaymentFailedEventHandler(getUserAnalyticsId, analyticsStore)
beforeEach(() => {
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
event = {} as jest.Mocked<PaymentFailedEvent>
event.payload = {
userEmail: 'test@test.com',
}
})
it('should mark payment failed for analytics', async () => {
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,25 @@
import { DomainEventHandlerInterface, PaymentFailedEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { Period } from '../Time/Period'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
export class PaymentFailedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
) {}
async handle(event: PaymentFailedEvent): Promise<void> {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
await this.analyticsStore.markActivity([AnalyticsActivity.PaymentFailed], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
}

View File

@@ -0,0 +1,75 @@
import 'reflect-metadata'
import { PaymentSuccessEvent } from '@standardnotes/domain-events'
import { PaymentSuccessEventHandler } from './PaymentSuccessEventHandler'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { Logger } from 'winston'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { Period } from '../Time/Period'
describe('PaymentSuccessEventHandler', () => {
let event: PaymentSuccessEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
let logger: Logger
const createHandler = () =>
new PaymentSuccessEventHandler(getUserAnalyticsId, analyticsStore, statisticsStore, logger)
beforeEach(() => {
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.incrementMeasure = jest.fn()
event = {} as jest.Mocked<PaymentSuccessEvent>
event.payload = {
userEmail: 'test@test.com',
amount: 12.45,
billingFrequency: 12,
paymentType: 'initial',
subscriptionName: 'PRO_PLAN',
}
logger = {} as jest.Mocked<Logger>
logger.warn = jest.fn()
})
it('should mark payment success for analytics', async () => {
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalled()
expect(statisticsStore.incrementMeasure).toHaveBeenNthCalledWith(
2,
'pro-subscription-initial-annual-payments-income',
12.45,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
})
it('should mark non-detailed payment success statistics for analytics', async () => {
event.payload = {
userEmail: 'test@test.com',
amount: 12.45,
billingFrequency: 13,
paymentType: 'initial',
subscriptionName: 'PRO_PLAN',
}
await createHandler().handle(event)
expect(statisticsStore.incrementMeasure).toBeCalledTimes(1)
expect(statisticsStore.incrementMeasure).toHaveBeenNthCalledWith(1, 'income', 12.45, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
})
})

View File

@@ -0,0 +1,93 @@
import { PaymentType, SubscriptionBillingFrequency, SubscriptionName } from '@standardnotes/common'
import { DomainEventHandlerInterface, PaymentSuccessEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { Period } from '../Time/Period'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
export class PaymentSuccessEventHandler implements DomainEventHandlerInterface {
private readonly DETAILED_MEASURES = new Map([
[
SubscriptionName.PlusPlan,
new Map([
[
PaymentType.Initial,
new Map([
[SubscriptionBillingFrequency.Monthly, StatisticsMeasure.PlusSubscriptionInitialMonthlyPaymentsIncome],
[SubscriptionBillingFrequency.Annual, StatisticsMeasure.PlusSubscriptionInitialAnnualPaymentsIncome],
]),
],
[
PaymentType.Renewal,
new Map([
[SubscriptionBillingFrequency.Monthly, StatisticsMeasure.PlusSubscriptionRenewingMonthlyPaymentsIncome],
[SubscriptionBillingFrequency.Annual, StatisticsMeasure.PlusSubscriptionRenewingAnnualPaymentsIncome],
]),
],
]),
],
[
SubscriptionName.ProPlan,
new Map([
[
PaymentType.Initial,
new Map([
[SubscriptionBillingFrequency.Monthly, StatisticsMeasure.ProSubscriptionInitialMonthlyPaymentsIncome],
[SubscriptionBillingFrequency.Annual, StatisticsMeasure.ProSubscriptionInitialAnnualPaymentsIncome],
]),
],
[
PaymentType.Renewal,
new Map([
[SubscriptionBillingFrequency.Monthly, StatisticsMeasure.ProSubscriptionRenewingMonthlyPaymentsIncome],
[SubscriptionBillingFrequency.Annual, StatisticsMeasure.ProSubscriptionRenewingAnnualPaymentsIncome],
]),
],
]),
],
])
constructor(
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
async handle(event: PaymentSuccessEvent): Promise<void> {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
await this.analyticsStore.markActivity([AnalyticsActivity.PaymentSuccess], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
const statisticMeasures = [StatisticsMeasure.Income]
const detailedMeasure = this.DETAILED_MEASURES.get(event.payload.subscriptionName as SubscriptionName)
?.get(event.payload.paymentType as PaymentType)
?.get(event.payload.billingFrequency as SubscriptionBillingFrequency)
if (detailedMeasure !== undefined) {
statisticMeasures.push(detailedMeasure)
} else {
this.logger.warn(
`Could not find detailed measure for: subscription - ${event.payload.subscriptionName}, payment type - ${event.payload.paymentType}, billing frequency - ${event.payload.billingFrequency}`,
)
}
for (const measure of statisticMeasures) {
await this.statisticsStore.incrementMeasure(measure, event.payload.amount, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
}
}

View File

@@ -0,0 +1,69 @@
import 'reflect-metadata'
import { SubscriptionName } from '@standardnotes/common'
import { SubscriptionCancelledEvent } from '@standardnotes/domain-events'
import { SubscriptionCancelledEventHandler } from './SubscriptionCancelledEventHandler'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { Period } from '../Time/Period'
describe('SubscriptionCancelledEventHandler', () => {
let event: SubscriptionCancelledEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
const createHandler = () => new SubscriptionCancelledEventHandler(getUserAnalyticsId, analyticsStore, statisticsStore)
beforeEach(() => {
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.incrementMeasure = jest.fn()
event = {} as jest.Mocked<SubscriptionCancelledEvent>
event.createdAt = new Date(1)
event.payload = {
subscriptionId: 1,
userEmail: 'test@test.com',
subscriptionName: SubscriptionName.ProPlan,
subscriptionCreatedAt: 1642395451515000,
subscriptionUpdatedAt: 1642395451515001,
lastPayedAt: 1642395451515001,
subscriptionEndsAt: 1642395451515000 + 10,
timestamp: 1,
offline: false,
replaced: false,
}
})
it('should track subscription cancelled statistics', async () => {
event.payload.timestamp = 1642395451516000
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalled()
expect(statisticsStore.incrementMeasure).toHaveBeenCalledWith(StatisticsMeasure.SubscriptionLength, 1000, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
})
it('should not track statistics for subscriptions that are in a legacy 5 year plan', async () => {
event.payload.timestamp = 1642395451516000
event.payload.subscriptionEndsAt = 1642395451515000 + 126_230_400_000_001
event.payload.subscriptionCreatedAt = 1642395451515000
await createHandler().handle(event)
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,60 @@
import { DomainEventHandlerInterface, SubscriptionCancelledEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { Period } from '../Time/Period'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
export class SubscriptionCancelledEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
) {}
async handle(event: SubscriptionCancelledEvent): Promise<void> {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionCancelled], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
await this.trackSubscriptionStatistics(event)
}
private async trackSubscriptionStatistics(event: SubscriptionCancelledEvent) {
if (this.isLegacy5yearSubscriptionPlan(event.payload.subscriptionEndsAt, event.payload.subscriptionCreatedAt)) {
return
}
const subscriptionLength = event.payload.timestamp - event.payload.subscriptionCreatedAt
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
const remainingSubscriptionTime = event.payload.subscriptionEndsAt - event.payload.timestamp
const totalSubscriptionTime = event.payload.subscriptionEndsAt - event.payload.lastPayedAt
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
await this.statisticsStore.incrementMeasure(
StatisticsMeasure.RemainingSubscriptionTimePercentage,
remainingSubscriptionPercentage,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
}
private isLegacy5yearSubscriptionPlan(subscriptionEndsAt: number, subscriptionCreatedAt: number) {
const fourYearsInMicroseconds = 126_230_400_000_000
return subscriptionEndsAt - subscriptionCreatedAt > fourYearsInMicroseconds
}
}

View File

@@ -0,0 +1,78 @@
import 'reflect-metadata'
import { SubscriptionName } from '@standardnotes/common'
import { SubscriptionRefundedEvent } from '@standardnotes/domain-events'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { SubscriptionRefundedEventHandler } from './SubscriptionRefundedEventHandler'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { Period } from '../Time/Period'
describe('SubscriptionRefundedEventHandler', () => {
let event: SubscriptionRefundedEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
const createHandler = () => new SubscriptionRefundedEventHandler(getUserAnalyticsId, analyticsStore, statisticsStore)
beforeEach(() => {
event = {} as jest.Mocked<SubscriptionRefundedEvent>
event.createdAt = new Date(1)
event.payload = {
subscriptionId: 1,
userEmail: 'test@test.com',
subscriptionName: SubscriptionName.PlusPlan,
timestamp: 1,
offline: false,
userExistingSubscriptionsCount: 3,
totalActiveSubscriptionsCount: 1,
}
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(true)
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.setMeasure = jest.fn()
})
it('should mark churn for new customer', async () => {
event.payload.userExistingSubscriptionsCount = 1
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.SubscriptionRefunded], 3, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.NewCustomersChurn], 3, [
Period.ThisMonth,
])
})
it('should mark churn for existing customer', async () => {
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.ExistingCustomersChurn], 3, [
Period.ThisMonth,
])
})
it('should not mark churn if customer did not purchase subscription in defined analytic periods', async () => {
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(false)
await createHandler().handle(event)
expect(analyticsStore.markActivity).not.toHaveBeenCalledWith([AnalyticsActivity.ExistingCustomersChurn], 3, [
Period.ThisMonth,
])
})
})

View File

@@ -0,0 +1,54 @@
import { DomainEventHandlerInterface, SubscriptionRefundedEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
import { Period } from '../Time/Period'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable()
export class SubscriptionRefundedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
) {}
async handle(event: SubscriptionRefundedEvent): Promise<void> {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRefunded], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
await this.markChurnActivity(analyticsId, event)
}
private async markChurnActivity(analyticsId: number, event: SubscriptionRefundedEvent): Promise<void> {
const churnActivity =
event.payload.userExistingSubscriptionsCount > 1
? AnalyticsActivity.ExistingCustomersChurn
: AnalyticsActivity.NewCustomersChurn
for (const period of [Period.ThisMonth, Period.ThisWeek, Period.Today]) {
const customerPurchasedInPeriod = await this.analyticsStore.wasActivityDone(
AnalyticsActivity.SubscriptionPurchased,
analyticsId,
period,
)
if (customerPurchasedInPeriod) {
await this.analyticsStore.markActivity([churnActivity], analyticsId, [period])
}
}
await this.statisticsStore.setMeasure(
StatisticsMeasure.TotalCustomers,
event.payload.totalActiveSubscriptionsCount,
[Period.Today, Period.ThisWeek, Period.ThisMonth, Period.ThisYear],
)
}
}

View File

@@ -0,0 +1,43 @@
import 'reflect-metadata'
import { SubscriptionName } from '@standardnotes/common'
import { SubscriptionRenewedEvent } from '@standardnotes/domain-events'
import { SubscriptionRenewedEventHandler } from './SubscriptionRenewedEventHandler'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
describe('SubscriptionRenewedEventHandler', () => {
let event: SubscriptionRenewedEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
const createHandler = () => new SubscriptionRenewedEventHandler(getUserAnalyticsId, analyticsStore)
beforeEach(() => {
event = {} as jest.Mocked<SubscriptionRenewedEvent>
event.createdAt = new Date(1)
event.payload = {
subscriptionId: 1,
userEmail: 'test@test.com',
subscriptionName: SubscriptionName.ProPlan,
subscriptionExpiresAt: 2,
timestamp: 1,
offline: false,
}
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 3 })
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
analyticsStore.unmarkActivity = jest.fn()
})
it('should track subscription renewed statistics', async () => {
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalled()
expect(analyticsStore.unmarkActivity).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,30 @@
import { DomainEventHandlerInterface, SubscriptionRenewedEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { Period } from '../Time/Period'
@injectable()
export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
) {}
async handle(event: SubscriptionRenewedEvent): Promise<void> {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionRenewed], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
await this.analyticsStore.unmarkActivity(
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
analyticsId,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
}
}

View File

@@ -0,0 +1,47 @@
import 'reflect-metadata'
import { UserRegisteredEvent } from '@standardnotes/domain-events'
import { ProtocolVersion } from '@standardnotes/common'
import { UserRegisteredEventHandler } from './UserRegisteredEventHandler'
import { AnalyticsEntityRepositoryInterface } from '../Entity/AnalyticsEntityRepositoryInterface'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { Period } from '../Time/Period'
describe('UserRegisteredEventHandler', () => {
let analyticsEntityRepository: AnalyticsEntityRepositoryInterface
let event: UserRegisteredEvent
let analyticsStore: AnalyticsStoreInterface
const createHandler = () => new UserRegisteredEventHandler(analyticsEntityRepository, analyticsStore)
beforeEach(() => {
event = {} as jest.Mocked<UserRegisteredEvent>
event.createdAt = new Date(1)
event.payload = {
userUuid: '1-2-3',
email: 'test@test.te',
protocolVersion: ProtocolVersion.V004,
}
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn()
analyticsEntityRepository = {} as jest.Mocked<AnalyticsEntityRepositoryInterface>
analyticsEntityRepository.save = jest.fn().mockImplementation((entity) => ({
...entity,
id: 1,
}))
})
it('should save analytics entity upon user registration', async () => {
await createHandler().handle(event)
expect(analyticsEntityRepository.save).toHaveBeenCalled()
expect(analyticsStore.markActivity).toHaveBeenCalledWith(['register'], 1, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
})
})

View File

@@ -0,0 +1,30 @@
import { DomainEventHandlerInterface, UserRegisteredEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
import { AnalyticsEntity } from '../Entity/AnalyticsEntity'
import { AnalyticsEntityRepositoryInterface } from '../Entity/AnalyticsEntityRepositoryInterface'
import { Period } from '../Time/Period'
@injectable()
export class UserRegisteredEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.AnalyticsEntityRepository) private analyticsEntityRepository: AnalyticsEntityRepositoryInterface,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
) {}
async handle(event: UserRegisteredEvent): Promise<void> {
let analyticsEntity = new AnalyticsEntity()
analyticsEntity.userUuid = event.payload.userUuid
analyticsEntity.userEmail = event.payload.email
analyticsEntity = await this.analyticsEntityRepository.save(analyticsEntity)
await this.analyticsStore.markActivity([AnalyticsActivity.Register], analyticsEntity.id, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
}

View File

@@ -0,0 +1,42 @@
import 'reflect-metadata'
import { AnalyticsEntity } from '../../Entity/AnalyticsEntity'
import { AnalyticsEntityRepositoryInterface } from '../../Entity/AnalyticsEntityRepositoryInterface'
import { GetUserAnalyticsId } from './GetUserAnalyticsId'
describe('GetUserAnalyticsId', () => {
let analyticsEntityRepository: AnalyticsEntityRepositoryInterface
let analyticsEntity: AnalyticsEntity
const createUseCase = () => new GetUserAnalyticsId(analyticsEntityRepository)
beforeEach(() => {
analyticsEntity = { id: 123 } as jest.Mocked<AnalyticsEntity>
analyticsEntityRepository = {} as jest.Mocked<AnalyticsEntityRepositoryInterface>
analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue(analyticsEntity)
analyticsEntityRepository.findOneByUserEmail = jest.fn().mockReturnValue(analyticsEntity)
})
it('should return analytics id for a user by uuid', async () => {
expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ analyticsId: 123 })
})
it('should return analytics id for a user by email', async () => {
expect(await createUseCase().execute({ userEmail: 'test@test.te' })).toEqual({ analyticsId: 123 })
})
it('should throw error if user is missing analytics entity', async () => {
analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
let error = null
try {
await createUseCase().execute({ userUuid: '1-2-3' })
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})

View File

@@ -0,0 +1,30 @@
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { AnalyticsEntityRepositoryInterface } from '../../Entity/AnalyticsEntityRepositoryInterface'
import { UseCaseInterface } from '../UseCaseInterface'
import { GetUserAnalyticsIdDTO } from './GetUserAnalyticsIdDTO'
import { GetUserAnalyticsIdResponse } from './GetUserAnalyticsIdResponse'
@injectable()
export class GetUserAnalyticsId implements UseCaseInterface {
constructor(
@inject(TYPES.AnalyticsEntityRepository) private analyticsEntityRepository: AnalyticsEntityRepositoryInterface,
) {}
async execute(dto: GetUserAnalyticsIdDTO): Promise<GetUserAnalyticsIdResponse> {
let analyticsEntity = null
if (dto.userUuid) {
analyticsEntity = await this.analyticsEntityRepository.findOneByUserUuid(dto.userUuid)
} else if (dto.userEmail) {
analyticsEntity = await this.analyticsEntityRepository.findOneByUserEmail(dto.userEmail)
}
if (analyticsEntity === null) {
throw new Error(`Could not find analytics entity for user ${dto.userUuid}`)
}
return {
analyticsId: analyticsEntity.id,
}
}
}

View File

@@ -0,0 +1,10 @@
import { Either, Uuid } from '@standardnotes/common'
export type GetUserAnalyticsIdDTO = Either<
{
userUuid: Uuid
},
{
userEmail: string
}
>

View File

@@ -0,0 +1,3 @@
export type GetUserAnalyticsIdResponse = {
analyticsId: number
}

View File

@@ -0,0 +1,3 @@
export interface UseCaseInterface {
execute(...args: any[]): Promise<Record<string, unknown>>
}

View File

@@ -0,0 +1,60 @@
import 'reflect-metadata'
import { Repository, SelectQueryBuilder } from 'typeorm'
import { AnalyticsEntity } from '../../Domain/Entity/AnalyticsEntity'
import { MySQLAnalyticsEntityRepository } from './MySQLAnalyticsEntityRepository'
describe('MySQLAnalyticsEntityRepository', () => {
let ormRepository: Repository<AnalyticsEntity>
let analyticsEntity: AnalyticsEntity
let queryBuilder: SelectQueryBuilder<AnalyticsEntity>
const createRepository = () => new MySQLAnalyticsEntityRepository(ormRepository)
beforeEach(() => {
analyticsEntity = {} as jest.Mocked<AnalyticsEntity>
queryBuilder = {} as jest.Mocked<SelectQueryBuilder<AnalyticsEntity>>
ormRepository = {} as jest.Mocked<Repository<AnalyticsEntity>>
ormRepository.save = jest.fn()
ormRepository.remove = jest.fn()
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder)
})
it('should save', async () => {
await createRepository().save(analyticsEntity)
expect(ormRepository.save).toHaveBeenCalledWith(analyticsEntity)
})
it('should remove', async () => {
await createRepository().remove(analyticsEntity)
expect(ormRepository.remove).toHaveBeenCalledWith(analyticsEntity)
})
it('should find one by user uuid', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity)
const result = await createRepository().findOneByUserUuid('123')
expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_uuid = :userUuid', { userUuid: '123' })
expect(result).toEqual(analyticsEntity)
})
it('should find one by user email', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity)
const result = await createRepository().findOneByUserEmail('test@test.te')
expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_email = :email', { email: 'test@test.te' })
expect(result).toEqual(analyticsEntity)
})
})

View File

@@ -0,0 +1,37 @@
import { Uuid } from '@standardnotes/common'
import { inject, injectable } from 'inversify'
import { Repository } from 'typeorm'
import TYPES from '../../Bootstrap/Types'
import { AnalyticsEntity } from '../../Domain/Entity/AnalyticsEntity'
import { AnalyticsEntityRepositoryInterface } from '../../Domain/Entity/AnalyticsEntityRepositoryInterface'
@injectable()
export class MySQLAnalyticsEntityRepository implements AnalyticsEntityRepositoryInterface {
constructor(
@inject(TYPES.ORMAnalyticsEntityRepository)
private ormRepository: Repository<AnalyticsEntity>,
) {}
async findOneByUserEmail(email: string): Promise<AnalyticsEntity | null> {
return this.ormRepository
.createQueryBuilder('analytics_entity')
.where('analytics_entity.user_email = :email', { email })
.getOne()
}
async findOneByUserUuid(userUuid: Uuid): Promise<AnalyticsEntity | null> {
return this.ormRepository
.createQueryBuilder('analytics_entity')
.where('analytics_entity.user_uuid = :userUuid', { userUuid })
.getOne()
}
async save(analyticsEntity: AnalyticsEntity): Promise<AnalyticsEntity> {
return this.ormRepository.save(analyticsEntity)
}
async remove(analyticsEntity: AnalyticsEntity): Promise<void> {
await this.ormRepository.remove(analyticsEntity)
}
}

View File

@@ -5,7 +5,9 @@
"outDir": "./dist",
},
"include": [
"src/**/*"
"src/**/*",
"bin/**/*",
"migrations/**/*"
],
"references": []
}

View File

@@ -27,9 +27,5 @@ NEW_RELIC_LOG_LEVEL=info
REDIS_URL=redis://cache
REDIS_EVENTS_CHANNEL=events
# (Optional) SNS Setup
SNS_TOPIC_ARN=
SNS_AWS_REGION=
# (Optional) Caching Cross Service Tokens
CROSS_SERVICE_TOKEN_CACHE_TTL=

View File

@@ -3,6 +3,68 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.36.11](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.10...@standardnotes/api-gateway@1.36.11) (2022-11-07)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.10](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.9...@standardnotes/api-gateway@1.36.10) (2022-11-07)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.9](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.8...@standardnotes/api-gateway@1.36.9) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.8](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.7...@standardnotes/api-gateway@1.36.8) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.7](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.6...@standardnotes/api-gateway@1.36.7) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.5...@standardnotes/api-gateway@1.36.6) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.4...@standardnotes/api-gateway@1.36.5) (2022-11-04)
### Bug Fixes
* **api-gateway:** removing sns bindings ([b4c5b5a](https://github.com/standardnotes/api-gateway/commit/b4c5b5a84e4c619f175620b141279dfa8e2e6754))
## [1.36.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.3...@standardnotes/api-gateway@1.36.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.3](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.2...@standardnotes/api-gateway@1.36.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.1...@standardnotes/api-gateway@1.36.2) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.36.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.0...@standardnotes/api-gateway@1.36.1) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
# [1.36.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.35.1...@standardnotes/api-gateway@1.36.0) (2022-11-04)
### Features
* **analytics:** move the analytics report from api-gateway to analytics ([34e11fd](https://github.com/standardnotes/api-gateway/commit/34e11fd5b0ef09a056c90127065c9dfae3e0172a))
## [1.35.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.35.0...@standardnotes/api-gateway@1.35.1) (2022-11-04)
**Note:** Version bump only for package @standardnotes/api-gateway
# [1.35.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.34.1...@standardnotes/api-gateway@1.35.0) (2022-11-03)
### Features
* **auth:** add analytics for subscription reactivating ([460d6a8](https://github.com/standardnotes/api-gateway/commit/460d6a8d0f17eee624feb5d2588086ae6f0996e4))
## [1.34.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.34.0...@standardnotes/api-gateway@1.34.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/api-gateway

View File

@@ -16,11 +16,6 @@ case "$COMMAND" in
yarn workspace @standardnotes/api-gateway start
;;
'report' )
echo "Starting Usage Report Generation..."
yarn workspace @standardnotes/api-gateway report
;;
* )
echo "Unknown command"
;;

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.34.1",
"version": "1.36.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -17,7 +17,6 @@
"lint": "eslint . --ext .ts",
"setup:env": "cp .env.sample .env",
"start": "yarn node dist/bin/server.js",
"report": "yarn node dist/bin/report.js",
"upgrade:snjs": "yarn ncu -u '@standardnotes/*'"
},
"dependencies": {

View File

@@ -2,7 +2,6 @@ import * as winston from 'winston'
import axios, { AxiosInstance } from 'axios'
import Redis from 'ioredis'
import { Container } from 'inversify'
import * as AWS from 'aws-sdk'
import {
AnalyticsStoreInterface,
PeriodKeyGenerator,
@@ -11,7 +10,6 @@ import {
RedisStatisticsStore,
StatisticsStoreInterface,
} from '@standardnotes/analytics'
import { RedisDomainEventPublisher, SNSDomainEventPublisher } from '@standardnotes/domain-events-infra'
import { Timer, TimerInterface } from '@standardnotes/time'
import { Env } from './Env'
@@ -58,15 +56,6 @@ export class ContainerConfigLoader {
}
container.bind(TYPES.Redis).toConstantValue(redis)
if (env.get('SNS_AWS_REGION', true)) {
container.bind<AWS.SNS>(TYPES.SNS).toConstantValue(
new AWS.SNS({
apiVersion: 'latest',
region: env.get('SNS_AWS_REGION', true),
}),
)
}
container.bind<AxiosInstance>(TYPES.HTTPClient).toConstantValue(axios.create())
// env vars
@@ -81,8 +70,6 @@ export class ContainerConfigLoader {
.bind(TYPES.HTTP_CALL_TIMEOUT)
.toConstantValue(env.get('HTTP_CALL_TIMEOUT', true) ? +env.get('HTTP_CALL_TIMEOUT', true) : 60_000)
container.bind(TYPES.VERSION).toConstantValue(env.get('VERSION'))
container.bind(TYPES.SNS_TOPIC_ARN).toConstantValue(env.get('SNS_TOPIC_ARN', true))
container.bind(TYPES.SNS_AWS_REGION).toConstantValue(env.get('SNS_AWS_REGION', true))
container.bind(TYPES.REDIS_EVENTS_CHANNEL).toConstantValue(env.get('REDIS_EVENTS_CHANNEL'))
container.bind(TYPES.CROSS_SERVICE_TOKEN_CACHE_TTL).toConstantValue(+env.get('CROSS_SERVICE_TOKEN_CACHE_TTL', true))
@@ -106,18 +93,6 @@ export class ContainerConfigLoader {
container.bind<CrossServiceTokenCacheInterface>(TYPES.CrossServiceTokenCache).to(RedisCrossServiceTokenCache)
container.bind<TimerInterface>(TYPES.Timer).toConstantValue(new Timer())
if (env.get('SNS_TOPIC_ARN', true)) {
container
.bind<SNSDomainEventPublisher>(TYPES.DomainEventPublisher)
.toConstantValue(new SNSDomainEventPublisher(container.get(TYPES.SNS), container.get(TYPES.SNS_TOPIC_ARN)))
} else {
container
.bind<RedisDomainEventPublisher>(TYPES.DomainEventPublisher)
.toConstantValue(
new RedisDomainEventPublisher(container.get(TYPES.Redis), container.get(TYPES.REDIS_EVENTS_CHANNEL)),
)
}
return container
}
}

View File

@@ -2,7 +2,6 @@ const TYPES = {
Logger: Symbol.for('Logger'),
Redis: Symbol.for('Redis'),
HTTPClient: Symbol.for('HTTPClient'),
SNS: Symbol.for('SNS'),
// env vars
SYNCING_SERVER_JS_URL: Symbol.for('SYNCING_SERVER_JS_URL'),
AUTH_SERVER_URL: Symbol.for('AUTH_SERVER_URL'),
@@ -13,8 +12,6 @@ const TYPES = {
AUTH_JWT_SECRET: Symbol.for('AUTH_JWT_SECRET'),
HTTP_CALL_TIMEOUT: Symbol.for('HTTP_CALL_TIMEOUT'),
VERSION: Symbol.for('VERSION'),
SNS_TOPIC_ARN: Symbol.for('SNS_TOPIC_ARN'),
SNS_AWS_REGION: Symbol.for('SNS_AWS_REGION'),
REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'),
CROSS_SERVICE_TOKEN_CACHE_TTL: Symbol.for('CROSS_SERVICE_TOKEN_CACHE_TTL'),
// Middleware
@@ -27,7 +24,6 @@ const TYPES = {
CrossServiceTokenCache: Symbol.for('CrossServiceTokenCache'),
AnalyticsStore: Symbol.for('AnalyticsStore'),
StatisticsStore: Symbol.for('StatisticsStore'),
DomainEventPublisher: Symbol.for('DomainEventPublisher'),
Timer: Symbol.for('Timer'),
PeriodKeyGenerator: Symbol.for('PeriodKeyGenerator'),
}

View File

@@ -3,6 +3,76 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.50.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.13...@standardnotes/auth-server@1.50.0) (2022-11-07)
### Features
* **analytics:** add handling subscription refunded event ([197c991](https://github.com/standardnotes/server/commit/197c9914caf8fb31ce3ee69d05381d2a6416b366))
## [1.49.13](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.12...@standardnotes/auth-server@1.49.13) (2022-11-07)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.12](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.11...@standardnotes/auth-server@1.49.12) (2022-11-04)
### Bug Fixes
* **auth:** event specs ([a3be4b0](https://github.com/standardnotes/server/commit/a3be4b063d679c125d152e7025750a6ca1d5d7a1))
## [1.49.11](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.10...@standardnotes/auth-server@1.49.11) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.10](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.9...@standardnotes/auth-server@1.49.10) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.9](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.8...@standardnotes/auth-server@1.49.9) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.8](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.7...@standardnotes/auth-server@1.49.8) (2022-11-04)
### Bug Fixes
* **auth:** add user created at timestamp to account deletion event ([7b05bf8](https://github.com/standardnotes/server/commit/7b05bf899152a2f36ce1fa851e8d5821c67840d7))
## [1.49.7](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.6...@standardnotes/auth-server@1.49.7) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.6](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.5...@standardnotes/auth-server@1.49.6) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.5](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.4...@standardnotes/auth-server@1.49.5) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.3...@standardnotes/auth-server@1.49.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.2...@standardnotes/auth-server@1.49.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.1...@standardnotes/auth-server@1.49.2) (2022-11-04)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.49.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.49.0...@standardnotes/auth-server@1.49.1) (2022-11-03)
### Bug Fixes
* **auth:** updating offline subscription end date ([2504887](https://github.com/standardnotes/server/commit/2504887e8d2d06608927e17a766cf0794dda2d4d))
# [1.49.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.48.2...@standardnotes/auth-server@1.49.0) (2022-11-03)
### Features
* **auth:** add analytics for subscription reactivating ([460d6a8](https://github.com/standardnotes/server/commit/460d6a8d0f17eee624feb5d2588086ae6f0996e4))
## [1.48.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.48.1...@standardnotes/auth-server@1.48.2) (2022-11-03)
**Note:** Version bump only for package @standardnotes/auth-server

View File

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

View File

@@ -205,6 +205,7 @@ import { SubscriptionInvitesController } from '../Controller/SubscriptionInvites
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { ProcessUserRequest } from '../Domain/UseCase/ProcessUserRequest/ProcessUserRequest'
import { UserRequestsController } from '../Controller/UserRequestsController'
import { SubscriptionReactivatedEventHandler } from '../Domain/Handler/SubscriptionReactivatedEventHandler'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -493,6 +494,9 @@ export class ContainerConfigLoader {
container.bind<PaymentFailedEventHandler>(TYPES.PaymentFailedEventHandler).to(PaymentFailedEventHandler)
container.bind<PaymentSuccessEventHandler>(TYPES.PaymentSuccessEventHandler).to(PaymentSuccessEventHandler)
container.bind<RefundProcessedEventHandler>(TYPES.RefundProcessedEventHandler).to(RefundProcessedEventHandler)
container
.bind<SubscriptionReactivatedEventHandler>(TYPES.SubscriptionReactivatedEventHandler)
.to(SubscriptionReactivatedEventHandler)
// Services
container.bind<UAParser>(TYPES.DeviceDetector).toConstantValue(new UAParser())
@@ -604,6 +608,7 @@ export class ContainerConfigLoader {
['PAYMENT_FAILED', container.get(TYPES.PaymentFailedEventHandler)],
['PAYMENT_SUCCESS', container.get(TYPES.PaymentSuccessEventHandler)],
['REFUND_PROCESSED', container.get(TYPES.RefundProcessedEventHandler)],
['SUBSCRIPTION_REACTIVATED', container.get(TYPES.SubscriptionReactivatedEventHandler)],
])
if (env.get('SQS_QUEUE_URL', true)) {

View File

@@ -145,6 +145,7 @@ const TYPES = {
PaymentFailedEventHandler: Symbol.for('PaymentFailedEventHandler'),
PaymentSuccessEventHandler: Symbol.for('PaymentSuccessEventHandler'),
RefundProcessedEventHandler: Symbol.for('RefundProcessedEventHandler'),
SubscriptionReactivatedEventHandler: Symbol.for('SubscriptionReactivatedEventHandler'),
// Services
DeviceDetector: Symbol.for('DeviceDetector'),
SessionService: Symbol.for('SessionService'),

View File

@@ -363,6 +363,7 @@ describe('DomainEventFactory', () => {
expect(
createFactory().createAccountDeletionRequestedEvent({
userUuid: '1-2-3',
userCreatedAtTimestamp: 123,
regularSubscriptionUuid: '2-3-4',
}),
).toEqual({
@@ -376,6 +377,7 @@ describe('DomainEventFactory', () => {
},
payload: {
userUuid: '1-2-3',
userCreatedAtTimestamp: 123,
regularSubscriptionUuid: '2-3-4',
},
type: 'ACCOUNT_DELETION_REQUESTED',

View File

@@ -254,6 +254,7 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
createAccountDeletionRequestedEvent(dto: {
userUuid: Uuid
userCreatedAtTimestamp: number
regularSubscriptionUuid: Uuid | undefined
}): AccountDeletionRequestedEvent {
return {

View File

@@ -55,6 +55,7 @@ export interface DomainEventFactoryInterface {
): CloudBackupRequestedEvent
createAccountDeletionRequestedEvent(dto: {
userUuid: Uuid
userCreatedAtTimestamp: number
regularSubscriptionUuid: Uuid | undefined
}): AccountDeletionRequestedEvent
createUserRolesChangedEvent(userUuid: string, email: string, currentRoles: RoleName[]): UserRolesChangedEvent

View File

@@ -80,6 +80,7 @@ describe('AccountDeletionRequestedEventHandler', () => {
event.createdAt = new Date(1)
event.payload = {
userUuid: '1-2-3',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: '2-3-4',
}

View File

@@ -71,6 +71,10 @@ describe('SubscriptionCancelledEventHandler', () => {
timestamp,
offline: false,
replaced: false,
subscriptionCreatedAt: 1,
subscriptionEndsAt: 2,
subscriptionUpdatedAt: 2,
lastPayedAt: 1,
}
})

View File

@@ -91,7 +91,9 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
const limitedDiscountPurchased = ['limited-10', 'limited-20'].includes(event.payload.discountCode as string)
const limitedDiscountPurchased = ['limited-10', 'limited-20', 'exit-20'].includes(
event.payload.discountCode as string,
)
if (limitedDiscountPurchased) {
await this.analyticsStore.markActivity([AnalyticsActivity.LimitedDiscountOfferPurchased], analyticsId, [
Period.Today,

View File

@@ -0,0 +1,78 @@
import 'reflect-metadata'
import { RoleName, SubscriptionName } from '@standardnotes/common'
import { SubscriptionReactivatedEvent } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { SubscriptionReactivatedEventHandler } from './SubscriptionReactivatedEventHandler'
import { AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
describe('SubscriptionReactivatedEventHandler', () => {
let userRepository: UserRepositoryInterface
let logger: Logger
let user: User
let event: SubscriptionReactivatedEvent
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
const createHandler = () =>
new SubscriptionReactivatedEventHandler(userRepository, analyticsStore, getUserAnalyticsId, logger)
beforeEach(() => {
user = {
uuid: '123',
email: 'test@test.com',
roles: Promise.resolve([
{
name: RoleName.ProUser,
},
]),
} as jest.Mocked<User>
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
userRepository.save = jest.fn().mockReturnValue(user)
event = {} as jest.Mocked<SubscriptionReactivatedEvent>
event.createdAt = new Date(1)
event.payload = {
previousSubscriptionId: 1,
currentSubscriptionId: 2,
userEmail: 'test@test.com',
subscriptionName: SubscriptionName.PlusPlan,
subscriptionExpiresAt: 5,
discountCode: 'exit-20',
}
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()
})
it('should mark subscription reactivated activity for analytics', async () => {
await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalledWith(['subscription-reactivated'], 3, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
})
it('should not do anything if no user is found for specified email', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
await createHandler().handle(event)
expect(analyticsStore.markActivity).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,34 @@
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
import { DomainEventHandlerInterface, SubscriptionReactivatedEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
@injectable()
export class SubscriptionReactivatedEventHandler implements DomainEventHandlerInterface {
constructor(
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.Logger) private logger: Logger,
) {}
async handle(event: SubscriptionReactivatedEvent): Promise<void> {
const user = await this.userRepository.findOneByEmail(event.payload.userEmail)
if (user === null) {
this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`)
return
}
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionReactivated], analyticsId, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
}
}

View File

@@ -80,6 +80,8 @@ describe('SubscriptionRefundedEventHandler', () => {
subscriptionName: SubscriptionName.PlusPlan,
timestamp,
offline: false,
userExistingSubscriptionsCount: 3,
totalActiveSubscriptionsCount: 1,
}
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>

View File

@@ -37,7 +37,11 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
return
}
await this.updateOfflineSubscriptionEndsAt(offlineUserSubscription, event.payload.timestamp)
await this.updateOfflineSubscriptionEndsAt(
offlineUserSubscription,
event.payload.subscriptionExpiresAt,
event.payload.timestamp,
)
await this.roleService.setOfflineUserRole(offlineUserSubscription)
@@ -92,9 +96,10 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
private async updateOfflineSubscriptionEndsAt(
offlineUserSubscription: OfflineUserSubscription,
subscriptionExpiresAt: number,
timestamp: number,
): Promise<void> {
offlineUserSubscription.endsAt = timestamp
offlineUserSubscription.endsAt = subscriptionExpiresAt
offlineUserSubscription.updatedAt = timestamp
await this.offlineUserSubscriptionRepository.save(offlineUserSubscription)

View File

@@ -8,6 +8,7 @@ import { DeleteAccount } from './DeleteAccount'
import { UserSubscription } from '../../Subscription/UserSubscription'
import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface'
import { TimerInterface } from '@standardnotes/time'
describe('DeleteAccount', () => {
let userRepository: UserRepositoryInterface
@@ -16,9 +17,10 @@ describe('DeleteAccount', () => {
let userSubscriptionService: UserSubscriptionServiceInterface
let user: User
let regularSubscription: UserSubscription
let timer: TimerInterface
const createUseCase = () =>
new DeleteAccount(userRepository, userSubscriptionService, domainEventPublisher, domainEventFactory)
new DeleteAccount(userRepository, userSubscriptionService, domainEventPublisher, domainEventFactory, timer)
beforeEach(() => {
user = {
@@ -46,6 +48,9 @@ describe('DeleteAccount', () => {
domainEventFactory.createAccountDeletionRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<AccountDeletionRequestedEvent>)
timer = {} as jest.Mocked<TimerInterface>
timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1)
})
it('should trigger account deletion - no subscription', async () => {
@@ -62,6 +67,7 @@ describe('DeleteAccount', () => {
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1)
expect(domainEventFactory.createAccountDeletionRequestedEvent).toHaveBeenLastCalledWith({
userUuid: '1-2-3',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: undefined,
})
})
@@ -80,6 +86,7 @@ describe('DeleteAccount', () => {
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1)
expect(domainEventFactory.createAccountDeletionRequestedEvent).toHaveBeenLastCalledWith({
userUuid: '1-2-3',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: '1-2-3',
})
})

View File

@@ -1,10 +1,13 @@
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { UseCaseInterface } from '../UseCaseInterface'
import { DeleteAccountDTO } from './DeleteAccountDTO'
import { DeleteAccountResponse } from './DeleteAccountResponse'
@@ -15,6 +18,7 @@ export class DeleteAccount implements UseCaseInterface {
@inject(TYPES.UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface,
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.Timer) private timer: TimerInterface,
) {}
async execute(dto: DeleteAccountDTO): Promise<DeleteAccountResponse> {
@@ -37,6 +41,7 @@ export class DeleteAccount implements UseCaseInterface {
await this.domainEventPublisher.publish(
this.domainEventFactory.createAccountDeletionRequestedEvent({
userUuid: user.uuid,
userCreatedAtTimestamp: this.timer.convertDateToMicroseconds(user.createdAt),
regularSubscriptionUuid,
}),
)

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.9.10](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.9...@standardnotes/domain-events-infra@1.9.10) (2022-11-07)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.9](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.8...@standardnotes/domain-events-infra@1.9.9) (2022-11-04)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.8](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.7...@standardnotes/domain-events-infra@1.9.8) (2022-11-04)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.7](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.6...@standardnotes/domain-events-infra@1.9.7) (2022-11-04)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.6](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.5...@standardnotes/domain-events-infra@1.9.6) (2022-11-03)
**Note:** Version bump only for package @standardnotes/domain-events-infra

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events-infra",
"version": "1.9.6",
"version": "1.9.10",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [2.76.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.75.0...@standardnotes/domain-events@2.76.0) (2022-11-07)
### Features
* **analytics:** add handling subscription refunded event ([197c991](https://github.com/standardnotes/server/commit/197c9914caf8fb31ce3ee69d05381d2a6416b366))
# [2.75.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.74.1...@standardnotes/domain-events@2.75.0) (2022-11-04)
### Features
* **analytics:** add subscription cancelled event handler ([037fb23](https://github.com/standardnotes/server/commit/037fb2398ae9aaa11682e1a8576bab28c69e0f9b))
## [2.74.1](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.74.0...@standardnotes/domain-events@2.74.1) (2022-11-04)
### Bug Fixes
* **auth:** add user created at timestamp to account deletion event ([7b05bf8](https://github.com/standardnotes/server/commit/7b05bf899152a2f36ce1fa851e8d5821c67840d7))
# [2.74.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.73.1...@standardnotes/domain-events@2.74.0) (2022-11-04)
### Features
* add analytics worker service ([d568432](https://github.com/standardnotes/server/commit/d5684326b1301855d0e07415195d4b246292f9a9))
## [2.73.1](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.73.0...@standardnotes/domain-events@2.73.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/domain-events

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events",
"version": "2.73.1",
"version": "2.76.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -2,5 +2,6 @@ import { Uuid } from '@standardnotes/common'
export interface AccountDeletionRequestedEventPayload {
userUuid: string
userCreatedAtTimestamp: number
regularSubscriptionUuid: Uuid | undefined
}

View File

@@ -9,4 +9,5 @@ export enum DomainEventService {
Files = 'files',
Scheduler = 'scheduler',
Workspace = 'workspace',
Analytics = 'analytics',
}

View File

@@ -4,6 +4,10 @@ export interface SubscriptionCancelledEventPayload {
userEmail: string
subscriptionId: number
subscriptionName: SubscriptionName
subscriptionCreatedAt: number
subscriptionUpdatedAt: number
lastPayedAt: number
subscriptionEndsAt: number
timestamp: number
offline: boolean
replaced: boolean

View File

@@ -4,6 +4,8 @@ export interface SubscriptionRefundedEventPayload {
userEmail: string
subscriptionId: number
subscriptionName: SubscriptionName
userExistingSubscriptionsCount: number
totalActiveSubscriptionsCount: number
timestamp: number
offline: boolean
}

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.6.5](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.4...@standardnotes/event-store@1.6.5) (2022-11-07)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.4](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.3...@standardnotes/event-store@1.6.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.3](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.2...@standardnotes/event-store@1.6.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.2](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.1...@standardnotes/event-store@1.6.2) (2022-11-04)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.1](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.0...@standardnotes/event-store@1.6.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/event-store

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/event-store",
"version": "1.6.1",
"version": "1.6.5",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.8.5](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.4...@standardnotes/files-server@1.8.5) (2022-11-07)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.4](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.3...@standardnotes/files-server@1.8.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.3](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.2...@standardnotes/files-server@1.8.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.2](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.1...@standardnotes/files-server@1.8.2) (2022-11-04)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.1](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.0...@standardnotes/files-server@1.8.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/files-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files-server",
"version": "1.8.1",
"version": "1.8.5",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.13.6](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.13.5...@standardnotes/scheduler-server@1.13.6) (2022-11-07)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.13.5](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.13.4...@standardnotes/scheduler-server@1.13.5) (2022-11-04)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.13.4](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.13.3...@standardnotes/scheduler-server@1.13.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.13.3](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.13.2...@standardnotes/scheduler-server@1.13.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.13.2](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.13.1...@standardnotes/scheduler-server@1.13.2) (2022-11-03)
### Bug Fixes
* **scheduler:** specs ([dcb20e6](https://github.com/standardnotes/server/commit/dcb20e6ea612d8ca2e169e12e33c268855517962))
## [1.13.1](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.13.0...@standardnotes/scheduler-server@1.13.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/scheduler-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/scheduler-server",
"version": "1.13.1",
"version": "1.13.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -3,6 +3,7 @@ import {
DiscountWithdrawRequestedEvent,
DomainEventPublisherInterface,
EmailMessageRequestedEvent,
ExitDiscountWithdrawRequestedEvent,
} from '@standardnotes/domain-events'
import { PredicateName } from '@standardnotes/predicates'
import 'reflect-metadata'
@@ -48,6 +49,9 @@ describe('JobDoneInterpreter', () => {
domainEventFactory.createDiscountWithdrawRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<DiscountWithdrawRequestedEvent>)
domainEventFactory.createExitDiscountWithdrawRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<ExitDiscountWithdrawRequestedEvent>)
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()

View File

@@ -3,6 +3,64 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.10.22](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.21...@standardnotes/syncing-server@1.10.22) (2022-11-07)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.21](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.20...@standardnotes/syncing-server@1.10.21) (2022-11-07)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.20](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.19...@standardnotes/syncing-server@1.10.20) (2022-11-04)
### Bug Fixes
* **syncing-server:** event specs ([182512d](https://github.com/standardnotes/syncing-server-js/commit/182512d07c68ae644594404724bf1a0f5e0c71ae))
## [1.10.19](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.18...@standardnotes/syncing-server@1.10.19) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.18](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.17...@standardnotes/syncing-server@1.10.18) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.17](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.16...@standardnotes/syncing-server@1.10.17) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.16](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.15...@standardnotes/syncing-server@1.10.16) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.15](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.14...@standardnotes/syncing-server@1.10.15) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.14](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.13...@standardnotes/syncing-server@1.10.14) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.13](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.12...@standardnotes/syncing-server@1.10.13) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.12](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.11...@standardnotes/syncing-server@1.10.12) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.11](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.10...@standardnotes/syncing-server@1.10.11) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.10](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.9...@standardnotes/syncing-server@1.10.10) (2022-11-04)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.9](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.8...@standardnotes/syncing-server@1.10.9) (2022-11-03)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.10.8](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.10.7...@standardnotes/syncing-server@1.10.8) (2022-11-03)
**Note:** Version bump only for package @standardnotes/syncing-server

View File

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

View File

@@ -31,6 +31,7 @@ describe('AccountDeletionRequestedEventHandler', () => {
event.createdAt = new Date(1)
event.payload = {
userUuid: '2-3-4',
userCreatedAtTimestamp: 1,
regularSubscriptionUuid: '1-2-3',
}
})

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.4.5](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.4...@standardnotes/websockets-server@1.4.5) (2022-11-07)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.4](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.3...@standardnotes/websockets-server@1.4.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.3](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.2...@standardnotes/websockets-server@1.4.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.2](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.1...@standardnotes/websockets-server@1.4.2) (2022-11-04)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.1](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.0...@standardnotes/websockets-server@1.4.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/websockets-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/websockets-server",
"version": "1.4.1",
"version": "1.4.5",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.17.5](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.4...@standardnotes/workspace-server@1.17.5) (2022-11-07)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.17.4](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.3...@standardnotes/workspace-server@1.17.4) (2022-11-04)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.17.3](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.2...@standardnotes/workspace-server@1.17.3) (2022-11-04)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.17.2](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.1...@standardnotes/workspace-server@1.17.2) (2022-11-04)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.17.1](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.0...@standardnotes/workspace-server@1.17.1) (2022-11-03)
**Note:** Version bump only for package @standardnotes/workspace-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/workspace-server",
"version": "1.17.1",
"version": "1.17.5",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -1805,15 +1805,32 @@ __metadata:
version: 0.0.0-use.local
resolution: "@standardnotes/analytics@workspace:packages/analytics"
dependencies:
"@newrelic/winston-enricher": "npm:^4.0.0"
"@sentry/node": "npm:^7.3.0"
"@standardnotes/common": "workspace:*"
"@standardnotes/domain-events": "workspace:*"
"@standardnotes/domain-events-infra": "workspace:*"
"@standardnotes/time": "workspace:*"
"@types/ioredis": "npm:^4.28.10"
"@types/jest": "npm:^29.1.1"
"@types/newrelic": "npm:^7.0.3"
"@types/node": "npm:^18.0.0"
"@typescript-eslint/eslint-plugin": "npm:^5.30.0"
aws-sdk: "npm:^2.1158.0"
dayjs: "npm:^1.11.6"
dotenv: "npm:^16.0.1"
eslint: "npm:^8.14.0"
eslint-plugin-prettier: "npm:^4.2.1"
inversify: "npm:^6.0.1"
ioredis: "npm:^5.2.3"
jest: "npm:^29.1.2"
mysql2: "npm:^2.3.3"
newrelic: "npm:^9.0.0"
reflect-metadata: "npm:^0.1.13"
ts-jest: "npm:^29.0.3"
typeorm: "npm:^0.3.6"
typescript: "npm:^4.8.4"
winston: "npm:^3.8.1"
languageName: unknown
linkType: soft