mirror of
https://github.com/standardnotes/server
synced 2026-01-20 14:04:34 -05:00
Compare commits
55 Commits
@standardn
...
@standardn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
006f1fccec | ||
|
|
c0f5817d47 | ||
|
|
3da952fa52 | ||
|
|
f1834d58d2 | ||
|
|
b0cde4ab75 | ||
|
|
197c9914ca | ||
|
|
d7ef6898be | ||
|
|
2aa57f1f0d | ||
|
|
dcc0e38707 | ||
|
|
037fb2398a | ||
|
|
182512d07c | ||
|
|
a3be4b063d | ||
|
|
a97be4c342 | ||
|
|
5902cbb621 | ||
|
|
afc26d42ca | ||
|
|
51b12d05d4 | ||
|
|
3fe7b4ae24 | ||
|
|
2720a7c827 | ||
|
|
8d89b8ef12 | ||
|
|
5383e0cf52 | ||
|
|
7b05bf8991 | ||
|
|
b4c5b5a84e | ||
|
|
e115523acd | ||
|
|
35611fbc07 | ||
|
|
034aa38153 | ||
|
|
795728ab31 | ||
|
|
262d295121 | ||
|
|
4e5ac0a47b | ||
|
|
51b8cbdab2 | ||
|
|
f315b1ac5c | ||
|
|
2feaa8d956 | ||
|
|
5329f2a2fb | ||
|
|
5d9d2d0c8d | ||
|
|
34e11fd5b0 | ||
|
|
dc1f19ed04 | ||
|
|
ff7c52a05e | ||
|
|
d5684326b1 | ||
|
|
017c55d190 | ||
|
|
2504887e8d | ||
|
|
805e63379c | ||
|
|
dcb20e6ea6 | ||
|
|
786b94380b | ||
|
|
460d6a8d0f | ||
|
|
0dbc929c8e | ||
|
|
0c5305acf6 | ||
|
|
34139efafb | ||
|
|
eb53c3896f | ||
|
|
2af4c6fb55 | ||
|
|
d66f784538 | ||
|
|
f127241857 | ||
|
|
5b0d9dd394 | ||
|
|
ee29d18484 | ||
|
|
2255f856f9 | ||
|
|
f2415527f0 | ||
|
|
59eb70ce62 |
39
.github/workflows/analytics.yml
vendored
Normal file
39
.github/workflows/analytics.yml
vendored
Normal 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 }}"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@standardnotes-utils-npm-1.11.0-afbc24024c-9e7d9c1257.zip
vendored
Normal file
BIN
.yarn/cache/@standardnotes-utils-npm-1.11.0-afbc24024c-9e7d9c1257.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
|
||||
28
packages/analytics/.env.sample
Normal file
28
packages/analytics/.env.sample
Normal 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
|
||||
@@ -3,6 +3,110 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.51.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.50.0...@standardnotes/analytics@1.51.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription expired events ([c0f5817](https://github.com/standardnotes/server/commit/c0f5817d4753410ee5d997c1b94e340b400cb5d9))
|
||||
|
||||
# [1.50.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.49.0...@standardnotes/analytics@1.50.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription purchased events ([f1834d5](https://github.com/standardnotes/server/commit/f1834d58d2215c81322e82a0ec279617103b3260))
|
||||
|
||||
# [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
|
||||
|
||||
17
packages/analytics/Dockerfile
Normal file
17
packages/analytics/Dockerfile
Normal 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" ]
|
||||
@@ -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')
|
||||
|
||||
29
packages/analytics/bin/worker.ts
Normal file
29
packages/analytics/bin/worker.ts
Normal 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)
|
||||
})
|
||||
22
packages/analytics/docker/entrypoint.sh
Executable file
22
packages/analytics/docker/entrypoint.sh
Executable 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 "$@"
|
||||
16
packages/analytics/migrations/1667555285111-init_database.ts
Normal file
16
packages/analytics/migrations/1667555285111-init_database.ts
Normal 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`')
|
||||
}
|
||||
}
|
||||
@@ -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`')
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/analytics",
|
||||
"version": "1.36.0",
|
||||
"version": "1.51.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"
|
||||
}
|
||||
}
|
||||
|
||||
210
packages/analytics/src/Bootstrap/Container.ts
Normal file
210
packages/analytics/src/Bootstrap/Container.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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'
|
||||
import { SubscriptionPurchasedEventHandler } from '../Domain/Handler/SubscriptionPurchasedEventHandler'
|
||||
import { SubscriptionExpiredEventHandler } from '../Domain/Handler/SubscriptionExpiredEventHandler'
|
||||
|
||||
// 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)
|
||||
container
|
||||
.bind<SubscriptionPurchasedEventHandler>(TYPES.SubscriptionPurchasedEventHandler)
|
||||
.to(SubscriptionPurchasedEventHandler)
|
||||
container
|
||||
.bind<SubscriptionExpiredEventHandler>(TYPES.SubscriptionExpiredEventHandler)
|
||||
.to(SubscriptionExpiredEventHandler)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
43
packages/analytics/src/Bootstrap/DataSource.ts
Normal file
43
packages/analytics/src/Bootstrap/DataSource.ts
Normal 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'),
|
||||
})
|
||||
24
packages/analytics/src/Bootstrap/Env.ts
Normal file
24
packages/analytics/src/Bootstrap/Env.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
41
packages/analytics/src/Bootstrap/Types.ts
Normal file
41
packages/analytics/src/Bootstrap/Types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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'),
|
||||
SubscriptionPurchasedEventHandler: Symbol.for('SubscriptionPurchasedEventHandler'),
|
||||
SubscriptionExpiredEventHandler: Symbol.for('SubscriptionExpiredEventHandler'),
|
||||
// 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
|
||||
@@ -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',
|
||||
|
||||
22
packages/analytics/src/Domain/Entity/AnalyticsEntity.ts
Normal file
22
packages/analytics/src/Domain/Entity/AnalyticsEntity.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
174
packages/analytics/src/Domain/Event/DomainEventFactory.spec.ts
Normal file
174
packages/analytics/src/Domain/Event/DomainEventFactory.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
75
packages/analytics/src/Domain/Event/DomainEventFactory.ts
Normal file
75
packages/analytics/src/Domain/Event/DomainEventFactory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionExpiredEvent } from '@standardnotes/domain-events'
|
||||
|
||||
import { SubscriptionExpiredEventHandler } from './SubscriptionExpiredEventHandler'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
|
||||
describe('SubscriptionExpiredEventHandler', () => {
|
||||
let event: SubscriptionExpiredEvent
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createHandler = () => new SubscriptionExpiredEventHandler(getUserAnalyticsId, analyticsStore, statisticsStore)
|
||||
|
||||
beforeEach(() => {
|
||||
event = {} as jest.Mocked<SubscriptionExpiredEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
subscriptionName: SubscriptionName.PlusPlan,
|
||||
timestamp: 1,
|
||||
offline: false,
|
||||
totalActiveSubscriptionsCount: 123,
|
||||
}
|
||||
|
||||
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.setMeasure = jest.fn()
|
||||
})
|
||||
|
||||
it('should update analytics and statistics', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalled()
|
||||
expect(statisticsStore.setMeasure).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DomainEventHandlerInterface, SubscriptionExpiredEvent } 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 SubscriptionExpiredEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionExpiredEvent): Promise<void> {
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity(
|
||||
[AnalyticsActivity.SubscriptionExpired, AnalyticsActivity.ExistingCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
await this.statisticsStore.setMeasure(
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
event.payload.totalActiveSubscriptionsCount,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth, Period.ThisYear],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionPurchasedEvent } from '@standardnotes/domain-events'
|
||||
|
||||
import { SubscriptionPurchasedEventHandler } from './SubscriptionPurchasedEventHandler'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '../Analytics/AnalyticsStoreInterface'
|
||||
import { StatisticsStoreInterface } from '../Statistics/StatisticsStoreInterface'
|
||||
import { Period } from '../Time/Period'
|
||||
|
||||
describe('SubscriptionPurchasedEventHandler', () => {
|
||||
let event: SubscriptionPurchasedEvent
|
||||
let subscriptionExpiresAt: number
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createHandler = () => new SubscriptionPurchasedEventHandler(getUserAnalyticsId, analyticsStore, statisticsStore)
|
||||
|
||||
beforeEach(() => {
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.incrementMeasure = jest.fn()
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
event = {} as jest.Mocked<SubscriptionPurchasedEvent>
|
||||
event.createdAt = new Date(1)
|
||||
event.payload = {
|
||||
subscriptionId: 1,
|
||||
userEmail: 'test@test.com',
|
||||
subscriptionName: SubscriptionName.ProPlan,
|
||||
subscriptionExpiresAt,
|
||||
timestamp: 60,
|
||||
offline: false,
|
||||
discountCode: null,
|
||||
limitedDiscountPurchased: false,
|
||||
newSubscriber: true,
|
||||
totalActiveSubscriptionsCount: 123,
|
||||
userRegisteredAt: 23,
|
||||
}
|
||||
|
||||
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 mark subscription creation statistics', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not measure registration to subscription time if this is not user's first subscription", async () => {
|
||||
event.payload.newSubscriber = false
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update analytics on limited discount offer purchasing', async () => {
|
||||
event.payload.limitedDiscountPurchased = true
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith(['limited-discount-offer-purchased'], 3, [Period.Today])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DomainEventHandlerInterface, SubscriptionPurchasedEvent } 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 SubscriptionPurchasedEventHandler implements DomainEventHandlerInterface {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
) {}
|
||||
|
||||
async handle(event: SubscriptionPurchasedEvent): Promise<void> {
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userEmail: event.payload.userEmail })
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.SubscriptionPurchased], analyticsId, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
await this.analyticsStore.unmarkActivity(
|
||||
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
if (event.payload.limitedDiscountPurchased) {
|
||||
await this.analyticsStore.markActivity([AnalyticsActivity.LimitedDiscountOfferPurchased], analyticsId, [
|
||||
Period.Today,
|
||||
])
|
||||
}
|
||||
|
||||
if (event.payload.newSubscriber) {
|
||||
await this.statisticsStore.incrementMeasure(
|
||||
StatisticsMeasure.RegistrationToSubscriptionTime,
|
||||
event.payload.timestamp - event.payload.userRegisteredAt,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.NewCustomers, 1, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
await this.statisticsStore.setMeasure(
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
event.payload.totalActiveSubscriptionsCount,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth, Period.ThisYear],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Either, Uuid } from '@standardnotes/common'
|
||||
|
||||
export type GetUserAnalyticsIdDTO = Either<
|
||||
{
|
||||
userUuid: Uuid
|
||||
},
|
||||
{
|
||||
userEmail: string
|
||||
}
|
||||
>
|
||||
@@ -0,0 +1,3 @@
|
||||
export type GetUserAnalyticsIdResponse = {
|
||||
analyticsId: number
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface UseCaseInterface {
|
||||
execute(...args: any[]): Promise<Record<string, unknown>>
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
"outDir": "./dist",
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
"src/**/*",
|
||||
"bin/**/*",
|
||||
"migrations/**/*"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -3,6 +3,90 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.36.13](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.12...@standardnotes/api-gateway@1.36.13) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.36.12](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.36.11...@standardnotes/api-gateway@1.36.12) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [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
|
||||
|
||||
# [1.34.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.33.6...@standardnotes/api-gateway@1.34.0) (2022-11-02)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add processing user requests ([2255f85](https://github.com/standardnotes/api-gateway/commit/2255f856f928e855ac94f8aca4e1fb81047f58f7))
|
||||
|
||||
## [1.33.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.33.5...@standardnotes/api-gateway@1.33.6) (2022-11-02)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.33.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.33.4...@standardnotes/api-gateway@1.33.5) (2022-11-01)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
@@ -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"
|
||||
;;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api-gateway",
|
||||
"version": "1.33.5",
|
||||
"version": "1.36.13",
|
||||
"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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -147,4 +147,9 @@ export class UsersController extends BaseHttpController {
|
||||
async deleteUser(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callPaymentsServer(request, response, 'api/account', request.body)
|
||||
}
|
||||
|
||||
@httpPost('/:userUuid/requests', TYPES.AuthMiddleware)
|
||||
async submitRequest(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callAuthServer(request, response, `users/${request.params.userUuid}/requests`, request.body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,108 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.52.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.51.0...@standardnotes/auth-server@1.52.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription expired events ([c0f5817](https://github.com/standardnotes/server/commit/c0f5817d4753410ee5d997c1b94e340b400cb5d9))
|
||||
|
||||
# [1.51.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.50.0...@standardnotes/auth-server@1.51.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription purchased events ([f1834d5](https://github.com/standardnotes/server/commit/f1834d58d2215c81322e82a0ec279617103b3260))
|
||||
|
||||
# [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
|
||||
|
||||
## [1.48.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.48.0...@standardnotes/auth-server@1.48.1) (2022-11-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** controller name ([5b0d9dd](https://github.com/standardnotes/server/commit/5b0d9dd3949d4da9d5ac3ca86a0e54ead2ce730d))
|
||||
|
||||
# [1.48.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.47.7...@standardnotes/auth-server@1.48.0) (2022-11-02)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add processing user requests ([2255f85](https://github.com/standardnotes/server/commit/2255f856f928e855ac94f8aca4e1fb81047f58f7))
|
||||
|
||||
## [1.47.7](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.47.6...@standardnotes/auth-server@1.47.7) (2022-11-02)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
## [1.47.6](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.47.5...@standardnotes/auth-server@1.47.6) (2022-11-01)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../src/Controller/SubscriptionSettingsController'
|
||||
|
||||
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController'
|
||||
import '../src/Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController'
|
||||
import '../src/Infra/InversifyExpressUtils/InversifyExpressUserRequestsController'
|
||||
import '../src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'
|
||||
|
||||
import * as cors from 'cors'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/auth-server",
|
||||
"version": "1.47.6",
|
||||
"version": "1.52.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
@@ -33,7 +33,7 @@
|
||||
"@newrelic/winston-enricher": "^4.0.0",
|
||||
"@sentry/node": "^7.3.0",
|
||||
"@standardnotes/analytics": "workspace:*",
|
||||
"@standardnotes/api": "^1.17.2",
|
||||
"@standardnotes/api": "^1.19.0",
|
||||
"@standardnotes/common": "workspace:*",
|
||||
"@standardnotes/domain-events": "workspace:*",
|
||||
"@standardnotes/domain-events-infra": "workspace:*",
|
||||
@@ -49,7 +49,7 @@
|
||||
"axios": "^0.27.2",
|
||||
"bcryptjs": "2.4.3",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "^1.11.3",
|
||||
"dayjs": "^1.11.6",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.18.1",
|
||||
"inversify": "^6.0.1",
|
||||
|
||||
@@ -203,6 +203,9 @@ import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEven
|
||||
import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler'
|
||||
import { SubscriptionInvitesController } from '../Controller/SubscriptionInvitesController'
|
||||
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')
|
||||
@@ -266,6 +269,7 @@ export class ContainerConfigLoader {
|
||||
// Controller
|
||||
container.bind<AuthController>(TYPES.AuthController).to(AuthController)
|
||||
container.bind<SubscriptionInvitesController>(TYPES.SubscriptionInvitesController).to(SubscriptionInvitesController)
|
||||
container.bind<UserRequestsController>(TYPES.UserRequestsController).to(UserRequestsController)
|
||||
|
||||
// Repositories
|
||||
container.bind<SessionRepositoryInterface>(TYPES.SessionRepository).to(MySQLSessionRepository)
|
||||
@@ -438,6 +442,7 @@ export class ContainerConfigLoader {
|
||||
container.bind<GetUserAnalyticsId>(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId)
|
||||
container.bind<VerifyPredicate>(TYPES.VerifyPredicate).to(VerifyPredicate)
|
||||
container.bind<CreateCrossServiceToken>(TYPES.CreateCrossServiceToken).to(CreateCrossServiceToken)
|
||||
container.bind<ProcessUserRequest>(TYPES.ProcessUserRequest).to(ProcessUserRequest)
|
||||
|
||||
// Handlers
|
||||
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
|
||||
@@ -489,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())
|
||||
@@ -600,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)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const TYPES = {
|
||||
// Controller
|
||||
AuthController: Symbol.for('AuthController'),
|
||||
SubscriptionInvitesController: Symbol.for('SubscriptionInvitesController'),
|
||||
UserRequestsController: Symbol.for('UserRequestsController'),
|
||||
// Repositories
|
||||
UserRepository: Symbol.for('UserRepository'),
|
||||
SessionRepository: Symbol.for('SessionRepository'),
|
||||
@@ -121,6 +122,7 @@ const TYPES = {
|
||||
GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'),
|
||||
VerifyPredicate: Symbol.for('VerifyPredicate'),
|
||||
CreateCrossServiceToken: Symbol.for('CreateCrossServiceToken'),
|
||||
ProcessUserRequest: Symbol.for('ProcessUserRequest'),
|
||||
// Handlers
|
||||
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
|
||||
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
|
||||
@@ -143,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'),
|
||||
|
||||
@@ -109,4 +109,15 @@ describe('AuthController', () => {
|
||||
|
||||
expect(response.status).toEqual(400)
|
||||
})
|
||||
|
||||
it('should throw error on the delete user method as it is still a part of the payments server', async () => {
|
||||
let caughtError = null
|
||||
try {
|
||||
await createController().deleteAccount({ userUuid: '1-2-3' })
|
||||
} catch (error) {
|
||||
caughtError = error
|
||||
}
|
||||
|
||||
expect(caughtError).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject, injectable } from 'inversify'
|
||||
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
|
||||
import {
|
||||
HttpStatusCode,
|
||||
UserDeletionResponse,
|
||||
UserRegistrationRequestParams,
|
||||
UserRegistrationResponse,
|
||||
UserServerInterface,
|
||||
@@ -12,6 +13,7 @@ import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts'
|
||||
import { Register } from '../Domain/UseCase/Register'
|
||||
import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
import { UserDeletionRequestParams } from '@standardnotes/api/dist/Domain/Request/User/UserDeletionRequestParams'
|
||||
|
||||
@injectable()
|
||||
export class AuthController implements UserServerInterface {
|
||||
@@ -22,6 +24,10 @@ export class AuthController implements UserServerInterface {
|
||||
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
|
||||
) {}
|
||||
|
||||
async deleteAccount(_params: UserDeletionRequestParams): Promise<UserDeletionResponse> {
|
||||
throw new Error('This method is implemented on the payments server.')
|
||||
}
|
||||
|
||||
async register(params: UserRegistrationRequestParams): Promise<UserRegistrationResponse> {
|
||||
if (!params.email || !params.password) {
|
||||
return {
|
||||
|
||||
43
packages/auth/src/Controller/UserRequestsController.spec.ts
Normal file
43
packages/auth/src/Controller/UserRequestsController.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { UserRequestType } from '@standardnotes/common'
|
||||
|
||||
import { ProcessUserRequest } from '../Domain/UseCase/ProcessUserRequest/ProcessUserRequest'
|
||||
|
||||
import { UserRequestsController } from './UserRequestsController'
|
||||
|
||||
describe('UserRequestsController', () => {
|
||||
let processUserRequest: ProcessUserRequest
|
||||
|
||||
const createController = () => new UserRequestsController(processUserRequest)
|
||||
|
||||
beforeEach(() => {
|
||||
processUserRequest = {} as jest.Mocked<ProcessUserRequest>
|
||||
processUserRequest.execute = jest.fn().mockReturnValue({ success: true })
|
||||
})
|
||||
|
||||
it('should process user request', async () => {
|
||||
expect(
|
||||
await createController().submitUserRequest({
|
||||
userUuid: '1-2-3',
|
||||
requestType: UserRequestType.ExitDiscount,
|
||||
}),
|
||||
).toEqual({
|
||||
status: 200,
|
||||
data: { success: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('should not process user request', async () => {
|
||||
processUserRequest.execute = jest.fn().mockReturnValue({ success: false })
|
||||
expect(
|
||||
await createController().submitUserRequest({
|
||||
userUuid: '1-2-3',
|
||||
requestType: UserRequestType.ExitDiscount,
|
||||
}),
|
||||
).toEqual({
|
||||
status: 400,
|
||||
data: { success: false },
|
||||
})
|
||||
})
|
||||
})
|
||||
34
packages/auth/src/Controller/UserRequestsController.ts
Normal file
34
packages/auth/src/Controller/UserRequestsController.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
HttpStatusCode,
|
||||
UserRequestRequestParams,
|
||||
UserRequestResponse,
|
||||
UserRequestServerInterface,
|
||||
} from '@standardnotes/api'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import TYPES from '../Bootstrap/Types'
|
||||
import { ProcessUserRequest } from '../Domain/UseCase/ProcessUserRequest/ProcessUserRequest'
|
||||
|
||||
@injectable()
|
||||
export class UserRequestsController implements UserRequestServerInterface {
|
||||
constructor(@inject(TYPES.ProcessUserRequest) private processUserRequest: ProcessUserRequest) {}
|
||||
|
||||
async submitUserRequest(params: UserRequestRequestParams): Promise<UserRequestResponse> {
|
||||
const result = await this.processUserRequest.execute({
|
||||
requestType: params.requestType,
|
||||
userEmail: params.userEmail as string,
|
||||
userUuid: params.userUuid,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: HttpStatusCode.BadRequest,
|
||||
data: result,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: HttpStatusCode.Success,
|
||||
data: result,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,29 @@ describe('DomainEventFactory', () => {
|
||||
timer.getUTCDate = jest.fn().mockReturnValue(new Date(1))
|
||||
})
|
||||
|
||||
it('should create a EXIT_DISCOUNT_APPLY_REQUESTED event', () => {
|
||||
expect(
|
||||
createFactory().createExitDiscountApplyRequestedEvent({
|
||||
userEmail: 'test@test.te',
|
||||
discountCode: 'exit-20',
|
||||
}),
|
||||
).toEqual({
|
||||
createdAt: expect.any(Date),
|
||||
meta: {
|
||||
correlation: {
|
||||
userIdentifier: 'test@test.te',
|
||||
userIdentifierType: 'email',
|
||||
},
|
||||
origin: 'auth',
|
||||
},
|
||||
payload: {
|
||||
userEmail: 'test@test.te',
|
||||
discountCode: 'exit-20',
|
||||
},
|
||||
type: 'EXIT_DISCOUNT_APPLY_REQUESTED',
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a WEB_SOCKET_MESSAGE_REQUESTED event', () => {
|
||||
expect(
|
||||
createFactory().createWebSocketMessageRequestedEvent({
|
||||
@@ -340,6 +363,7 @@ describe('DomainEventFactory', () => {
|
||||
expect(
|
||||
createFactory().createAccountDeletionRequestedEvent({
|
||||
userUuid: '1-2-3',
|
||||
userCreatedAtTimestamp: 123,
|
||||
regularSubscriptionUuid: '2-3-4',
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -353,6 +377,7 @@ describe('DomainEventFactory', () => {
|
||||
},
|
||||
payload: {
|
||||
userUuid: '1-2-3',
|
||||
userCreatedAtTimestamp: 123,
|
||||
regularSubscriptionUuid: '2-3-4',
|
||||
},
|
||||
type: 'ACCOUNT_DELETION_REQUESTED',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DomainEventService,
|
||||
EmailMessageRequestedEvent,
|
||||
WebSocketMessageRequestedEvent,
|
||||
ExitDiscountApplyRequestedEvent,
|
||||
} from '@standardnotes/domain-events'
|
||||
import { Predicate, PredicateVerificationResult } from '@standardnotes/predicates'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
@@ -28,6 +29,24 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
|
||||
export class DomainEventFactory implements DomainEventFactoryInterface {
|
||||
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
|
||||
|
||||
createExitDiscountApplyRequestedEvent(dto: {
|
||||
userEmail: string
|
||||
discountCode: string
|
||||
}): ExitDiscountApplyRequestedEvent {
|
||||
return {
|
||||
type: 'EXIT_DISCOUNT_APPLY_REQUESTED',
|
||||
createdAt: this.timer.getUTCDate(),
|
||||
meta: {
|
||||
correlation: {
|
||||
userIdentifier: dto.userEmail,
|
||||
userIdentifierType: 'email',
|
||||
},
|
||||
origin: DomainEventService.Auth,
|
||||
},
|
||||
payload: dto,
|
||||
}
|
||||
}
|
||||
|
||||
createWebSocketMessageRequestedEvent(dto: { userUuid: Uuid; message: JSONString }): WebSocketMessageRequestedEvent {
|
||||
return {
|
||||
type: 'WEB_SOCKET_MESSAGE_REQUESTED',
|
||||
@@ -235,6 +254,7 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
|
||||
|
||||
createAccountDeletionRequestedEvent(dto: {
|
||||
userUuid: Uuid
|
||||
userCreatedAtTimestamp: number
|
||||
regularSubscriptionUuid: Uuid | undefined
|
||||
}): AccountDeletionRequestedEvent {
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PredicateVerifiedEvent,
|
||||
EmailMessageRequestedEvent,
|
||||
WebSocketMessageRequestedEvent,
|
||||
ExitDiscountApplyRequestedEvent,
|
||||
} from '@standardnotes/domain-events'
|
||||
import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType'
|
||||
|
||||
@@ -54,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
|
||||
@@ -83,4 +85,8 @@ export interface DomainEventFactoryInterface {
|
||||
predicate: Predicate
|
||||
predicateVerificationResult: PredicateVerificationResult
|
||||
}): PredicateVerifiedEvent
|
||||
createExitDiscountApplyRequestedEvent(dto: {
|
||||
userEmail: string
|
||||
discountCode: string
|
||||
}): ExitDiscountApplyRequestedEvent
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('AccountDeletionRequestedEventHandler', () => {
|
||||
event.createdAt = new Date(1)
|
||||
event.payload = {
|
||||
userUuid: '1-2-3',
|
||||
userCreatedAtTimestamp: 1,
|
||||
regularSubscriptionUuid: '2-3-4',
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
timestamp,
|
||||
offline: false,
|
||||
replaced: false,
|
||||
subscriptionCreatedAt: 1,
|
||||
subscriptionEndsAt: 2,
|
||||
subscriptionUpdatedAt: 2,
|
||||
lastPayedAt: 1,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
subscriptionName: SubscriptionName.PlusPlan,
|
||||
timestamp,
|
||||
offline: false,
|
||||
totalActiveSubscriptionsCount: 123,
|
||||
}
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
|
||||
@@ -105,6 +105,10 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
timestamp: dayjs.utc().valueOf(),
|
||||
offline: false,
|
||||
discountCode: null,
|
||||
limitedDiscountPurchased: false,
|
||||
newSubscriber: true,
|
||||
totalActiveSubscriptionsCount: 123,
|
||||
userRegisteredAt: dayjs.utc().valueOf() - 23,
|
||||
}
|
||||
|
||||
subscriptionSettingService = {} as jest.Mocked<SubscriptionSettingServiceInterface>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,8 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
subscriptionName: SubscriptionName.PlusPlan,
|
||||
timestamp,
|
||||
offline: false,
|
||||
userExistingSubscriptionsCount: 3,
|
||||
totalActiveSubscriptionsCount: 1,
|
||||
}
|
||||
|
||||
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { DomainEventPublisherInterface, ExitDiscountApplyRequestedEvent } from '@standardnotes/domain-events'
|
||||
import { UserRequestType } from '@standardnotes/common'
|
||||
|
||||
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
|
||||
import { UserSubscription } from '../../Subscription/UserSubscription'
|
||||
import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
|
||||
|
||||
import { ProcessUserRequest } from './ProcessUserRequest'
|
||||
|
||||
describe('ProcessUserRequest', () => {
|
||||
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
|
||||
let domainEventFactory: DomainEventFactoryInterface
|
||||
let domainEventPublisher: DomainEventPublisherInterface
|
||||
|
||||
const createUseCase = () =>
|
||||
new ProcessUserRequest(userSubscriptionRepository, domainEventFactory, domainEventPublisher)
|
||||
|
||||
beforeEach(() => {
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue({
|
||||
cancelled: true,
|
||||
} as jest.Mocked<UserSubscription>)
|
||||
|
||||
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
|
||||
domainEventFactory.createExitDiscountApplyRequestedEvent = jest
|
||||
.fn()
|
||||
.mockReturnValue({} as jest.Mocked<ExitDiscountApplyRequestedEvent>)
|
||||
|
||||
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
|
||||
domainEventPublisher.publish = jest.fn()
|
||||
})
|
||||
|
||||
it('should not process unsupported requests', async () => {
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
userEmail: 'test@test.te',
|
||||
userUuid: '1-2-3',
|
||||
requestType: 'foobar' as UserRequestType,
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
})
|
||||
|
||||
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not process uncancelled subscriptions', async () => {
|
||||
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue({} as jest.Mocked<UserSubscription>)
|
||||
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
userEmail: 'test@test.te',
|
||||
userUuid: '1-2-3',
|
||||
requestType: UserRequestType.ExitDiscount,
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
})
|
||||
|
||||
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not process non existing subscriptions', async () => {
|
||||
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
|
||||
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
userEmail: 'test@test.te',
|
||||
userUuid: '1-2-3',
|
||||
requestType: UserRequestType.ExitDiscount,
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
})
|
||||
|
||||
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish an exit discount apply requested event', async () => {
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
userEmail: 'test@test.te',
|
||||
userUuid: '1-2-3',
|
||||
requestType: UserRequestType.ExitDiscount,
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
})
|
||||
|
||||
expect(domainEventPublisher.publish).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { UserRequestType } from '@standardnotes/common'
|
||||
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
|
||||
|
||||
import { UseCaseInterface } from '../UseCaseInterface'
|
||||
import { ProcessUserRequestDTO } from './ProcessUserRequestDTO'
|
||||
import { ProcessUserRequestResponse } from './ProcessUserRequestResponse'
|
||||
|
||||
@injectable()
|
||||
export class ProcessUserRequest implements UseCaseInterface {
|
||||
constructor(
|
||||
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
|
||||
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
|
||||
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
|
||||
) {}
|
||||
|
||||
async execute(dto: ProcessUserRequestDTO): Promise<ProcessUserRequestResponse> {
|
||||
if (dto.requestType !== UserRequestType.ExitDiscount) {
|
||||
return {
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await this.userSubscriptionRepository.findOneByUserUuid(dto.userUuid)
|
||||
if (subscription === null || !subscription.cancelled) {
|
||||
return {
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
await this.domainEventPublisher.publish(
|
||||
this.domainEventFactory.createExitDiscountApplyRequestedEvent({
|
||||
userEmail: dto.userEmail,
|
||||
discountCode: 'exit-20',
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UserRequestType, Uuid } from '@standardnotes/common'
|
||||
|
||||
export type ProcessUserRequestDTO = {
|
||||
userUuid: Uuid
|
||||
userEmail: string
|
||||
requestType: UserRequestType
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ProcessUserRequestResponse = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { inject } from 'inversify'
|
||||
import { controller, BaseHttpController, results, httpPost } from 'inversify-express-utils'
|
||||
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
import { UserRequestsController } from '../../Controller/UserRequestsController'
|
||||
|
||||
@controller('/users/:userUuid/requests')
|
||||
export class InversifyExpressUserRequestsController extends BaseHttpController {
|
||||
constructor(@inject(TYPES.UserRequestsController) private userRequestsController: UserRequestsController) {
|
||||
super()
|
||||
}
|
||||
|
||||
@httpPost('/', TYPES.ApiGatewayAuthMiddleware)
|
||||
async submitRequest(request: Request, response: Response): Promise<results.JsonResult> {
|
||||
const result = await this.userRequestsController.submitUserRequest({
|
||||
requestType: request.body.requestType,
|
||||
userUuid: response.locals.user.uuid,
|
||||
userEmail: response.locals.user.email,
|
||||
})
|
||||
|
||||
return this.json(result.data, result.status)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.44.0](https://github.com/standardnotes/server/compare/@standardnotes/common@1.43.0...@standardnotes/common@1.44.0) (2022-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
* **common:** add subscription cancelled email message identifier ([0c5305a](https://github.com/standardnotes/server/commit/0c5305acf62aae047caedca25de25049c37f7653))
|
||||
|
||||
# [1.43.0](https://github.com/standardnotes/server/compare/@standardnotes/common@1.42.0...@standardnotes/common@1.43.0) (2022-11-01)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/common",
|
||||
"version": "1.43.0",
|
||||
"version": "1.44.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -26,4 +26,5 @@ export enum EmailMessageIdentifier {
|
||||
RATE_ADJUSTMENT_NOTICE = 'RATE_ADJUSTMENT_NOTICE',
|
||||
WORKSPACE_INVITE_CREATED = 'WORKSPACE_INVITE_CREATED',
|
||||
EXIT_DISCOUNT = 'EXIT_DISCOUNT',
|
||||
SUBSCRIPTION_CANCELLED = 'SUBSCRIPTION_CANCELLED',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.9.12](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.11...@standardnotes/domain-events-infra@1.9.12) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [1.9.11](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.10...@standardnotes/domain-events-infra@1.9.11) (2022-11-07)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [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
|
||||
|
||||
## [1.9.5](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.4...@standardnotes/domain-events-infra@1.9.5) (2022-11-02)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [1.9.4](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.3...@standardnotes/domain-events-infra@1.9.4) (2022-11-01)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/domain-events-infra",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.12",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,52 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [2.78.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.77.0...@standardnotes/domain-events@2.78.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription expired events ([c0f5817](https://github.com/standardnotes/server/commit/c0f5817d4753410ee5d997c1b94e340b400cb5d9))
|
||||
|
||||
# [2.77.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.76.0...@standardnotes/domain-events@2.77.0) (2022-11-07)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add handling subscription purchased events ([f1834d5](https://github.com/standardnotes/server/commit/f1834d58d2215c81322e82a0ec279617103b3260))
|
||||
|
||||
# [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
|
||||
|
||||
# [2.73.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.72.1...@standardnotes/domain-events@2.73.0) (2022-11-02)
|
||||
|
||||
### Features
|
||||
|
||||
* **domain-events:** add exit discount events ([59eb70c](https://github.com/standardnotes/server/commit/59eb70ce62eddd1ac8031667bc38e495e9c17bba))
|
||||
|
||||
## [2.72.1](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.72.0...@standardnotes/domain-events@2.72.1) (2022-11-01)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/domain-events",
|
||||
"version": "2.72.1",
|
||||
"version": "2.78.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,6 @@ import { Uuid } from '@standardnotes/common'
|
||||
|
||||
export interface AccountDeletionRequestedEventPayload {
|
||||
userUuid: string
|
||||
userCreatedAtTimestamp: number
|
||||
regularSubscriptionUuid: Uuid | undefined
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ export enum DomainEventService {
|
||||
Files = 'files',
|
||||
Scheduler = 'scheduler',
|
||||
Workspace = 'workspace',
|
||||
Analytics = 'analytics',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DomainEventInterface } from './DomainEventInterface'
|
||||
import { ExitDiscountApplyRequestedEventPayload } from './ExitDiscountApplyRequestedEventPayload'
|
||||
|
||||
export interface ExitDiscountApplyRequestedEvent extends DomainEventInterface {
|
||||
type: 'EXIT_DISCOUNT_APPLY_REQUESTED'
|
||||
payload: ExitDiscountApplyRequestedEventPayload
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user