mirror of
https://github.com/standardnotes/server
synced 2026-01-25 02:01:08 -05:00
Compare commits
69 Commits
@standardn
...
@standardn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6cf9651d | ||
|
|
8668fec33d | ||
|
|
76e34131fb | ||
|
|
3c40ee4b4a | ||
|
|
5abd7ae32c | ||
|
|
09b3f9a0d7 | ||
|
|
19455ba6a7 | ||
|
|
7d042689f0 | ||
|
|
f43fbf1584 | ||
|
|
24c0cb8366 | ||
|
|
2236cc3828 | ||
|
|
039d44718a | ||
|
|
f075cd8c4d | ||
|
|
ea0f3e8999 | ||
|
|
e7736bba25 | ||
|
|
fdf8809e13 | ||
|
|
6a9d479f71 | ||
|
|
82c9637f37 | ||
|
|
dfab849f48 | ||
|
|
ad60b95537 | ||
|
|
8a98f746eb | ||
|
|
27cfd0ccf6 | ||
|
|
82bb85174d | ||
|
|
8ceef4acbf | ||
|
|
b6118c17e1 | ||
|
|
a7fb622e69 | ||
|
|
39337c1c4f | ||
|
|
1f970aaf69 | ||
|
|
0a5b7e13cd | ||
|
|
1ce2b9eb44 | ||
|
|
477f146725 | ||
|
|
d7b02c4da9 | ||
|
|
40e673379b | ||
|
|
6ce9a4e834 | ||
|
|
c5a07a888a | ||
|
|
55587f6207 | ||
|
|
0d6b45c795 | ||
|
|
95f64d9952 | ||
|
|
54da5def4b | ||
|
|
d2fc1e057d | ||
|
|
0a90d98c71 | ||
|
|
cc269e3b35 | ||
|
|
b19093179b | ||
|
|
e2cc0bc003 | ||
|
|
644c52ae36 | ||
|
|
2554273a3f | ||
|
|
a8ee149d7a | ||
|
|
dcf92d58f9 | ||
|
|
053092031c | ||
|
|
c12e3eb3ec | ||
|
|
07def20f6b | ||
|
|
6c2cca66bd | ||
|
|
6efd336f34 | ||
|
|
81eb4be200 | ||
|
|
76cee6dbad | ||
|
|
dcc35a5738 | ||
|
|
5628de6445 | ||
|
|
53bea47727 | ||
|
|
d6cf8d400a | ||
|
|
b58cc335f2 | ||
|
|
03d1bc611c | ||
|
|
a48b09cefe | ||
|
|
d3f36c05df | ||
|
|
488ade25ab | ||
|
|
413a276d20 | ||
|
|
65675a21d6 | ||
|
|
d35de38289 | ||
|
|
83e1baa978 | ||
|
|
875edce5b1 |
60
.pnp.cjs
generated
60
.pnp.cjs
generated
@@ -2484,14 +2484,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
}]\
|
||||
]],\
|
||||
["@standardnotes/api", [\
|
||||
["npm:1.8.1", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.8.1-15c2e051d4-76c5d1a2d2.zip/node_modules/@standardnotes/api/",\
|
||||
["npm:1.9.0", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.9.0-507434ff00-cc3feac393.zip/node_modules/@standardnotes/api/",\
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/api", "npm:1.8.1"],\
|
||||
["@standardnotes/api", "npm:1.9.0"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/encryption", "npm:1.15.3"],\
|
||||
["@standardnotes/models", "npm:1.18.3"],\
|
||||
["@standardnotes/responses", "npm:1.10.2"],\
|
||||
["@standardnotes/encryption", "npm:1.15.9"],\
|
||||
["@standardnotes/models", "npm:1.22.0"],\
|
||||
["@standardnotes/responses", "npm:1.10.3"],\
|
||||
["@standardnotes/security", "workspace:packages/security"],\
|
||||
["@standardnotes/utils", "npm:1.9.0"],\
|
||||
["reflect-metadata", "npm:0.1.13"]\
|
||||
@@ -2563,11 +2563,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\
|
||||
["@sentry/node", "npm:7.5.0"],\
|
||||
["@standardnotes/analytics", "workspace:packages/analytics"],\
|
||||
["@standardnotes/api", "npm:1.8.1"],\
|
||||
["@standardnotes/api", "npm:1.9.0"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
|
||||
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
|
||||
["@standardnotes/features", "npm:1.50.0"],\
|
||||
["@standardnotes/features", "npm:1.52.1"],\
|
||||
["@standardnotes/predicates", "workspace:packages/predicates"],\
|
||||
["@standardnotes/responses", "npm:1.6.39"],\
|
||||
["@standardnotes/security", "workspace:packages/security"],\
|
||||
@@ -2651,7 +2651,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/features", "npm:1.50.0"],\
|
||||
["@standardnotes/features", "npm:1.52.1"],\
|
||||
["@standardnotes/predicates", "workspace:packages/predicates"],\
|
||||
["@standardnotes/security", "workspace:packages/security"],\
|
||||
["@types/jest", "npm:28.1.4"],\
|
||||
@@ -2688,14 +2688,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
}]\
|
||||
]],\
|
||||
["@standardnotes/encryption", [\
|
||||
["npm:1.15.3", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.15.3-3580c52c1f-1a7863299f.zip/node_modules/@standardnotes/encryption/",\
|
||||
["npm:1.15.9", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.15.9-00c7fac9f6-7595ac08ce.zip/node_modules/@standardnotes/encryption/",\
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/encryption", "npm:1.15.3"],\
|
||||
["@standardnotes/encryption", "npm:1.15.9"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/models", "npm:1.18.3"],\
|
||||
["@standardnotes/responses", "npm:1.10.2"],\
|
||||
["@standardnotes/sncrypto-common", "npm:1.11.1"],\
|
||||
["@standardnotes/models", "npm:1.22.0"],\
|
||||
["@standardnotes/responses", "npm:1.10.3"],\
|
||||
["@standardnotes/sncrypto-common", "npm:1.12.0"],\
|
||||
["@standardnotes/utils", "npm:1.9.0"],\
|
||||
["reflect-metadata", "npm:0.1.13"]\
|
||||
],\
|
||||
@@ -2743,10 +2743,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
],\
|
||||
"linkType": "HARD"\
|
||||
}],\
|
||||
["npm:1.52.0", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.52.0-8c1adf7881-3e6014272f.zip/node_modules/@standardnotes/features/",\
|
||||
["npm:1.52.1", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.52.1-1fee85cf4e-ff3684399e.zip/node_modules/@standardnotes/features/",\
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/features", "npm:1.52.0"],\
|
||||
["@standardnotes/features", "npm:1.52.1"],\
|
||||
["@standardnotes/auth", "npm:3.19.4"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/security", "workspace:packages/security"],\
|
||||
@@ -2808,13 +2808,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
}]\
|
||||
]],\
|
||||
["@standardnotes/models", [\
|
||||
["npm:1.18.3", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.18.3-6c65a62f30-21830c805f.zip/node_modules/@standardnotes/models/",\
|
||||
["npm:1.22.0", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.22.0-2cc72f987b-9928246368.zip/node_modules/@standardnotes/models/",\
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/models", "npm:1.18.3"],\
|
||||
["@standardnotes/models", "npm:1.22.0"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/features", "npm:1.52.0"],\
|
||||
["@standardnotes/responses", "npm:1.10.2"],\
|
||||
["@standardnotes/features", "npm:1.52.1"],\
|
||||
["@standardnotes/responses", "npm:1.10.3"],\
|
||||
["@standardnotes/utils", "npm:1.9.0"],\
|
||||
["lodash", "npm:4.17.21"],\
|
||||
["reflect-metadata", "npm:0.1.13"]\
|
||||
@@ -2851,12 +2851,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
}]\
|
||||
]],\
|
||||
["@standardnotes/responses", [\
|
||||
["npm:1.10.2", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.2-39d2d1f9b5-364724b5c7.zip/node_modules/@standardnotes/responses/",\
|
||||
["npm:1.10.3", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.3-7cdb15f83a-4a1e31eb89.zip/node_modules/@standardnotes/responses/",\
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/responses", "npm:1.10.2"],\
|
||||
["@standardnotes/responses", "npm:1.10.3"],\
|
||||
["@standardnotes/common", "workspace:packages/common"],\
|
||||
["@standardnotes/features", "npm:1.52.0"],\
|
||||
["@standardnotes/features", "npm:1.52.1"],\
|
||||
["@standardnotes/security", "workspace:packages/security"],\
|
||||
["reflect-metadata", "npm:0.1.13"]\
|
||||
],\
|
||||
@@ -2967,10 +2967,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
|
||||
}]\
|
||||
]],\
|
||||
["@standardnotes/sncrypto-common", [\
|
||||
["npm:1.11.1", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-sncrypto-common-npm-1.11.1-58d12d6912-69d698abb7.zip/node_modules/@standardnotes/sncrypto-common/",\
|
||||
["npm:1.12.0", {\
|
||||
"packageLocation": "./.yarn/cache/@standardnotes-sncrypto-common-npm-1.12.0-1a093ff006-b89a14bd23.zip/node_modules/@standardnotes/sncrypto-common/",\
|
||||
"packageDependencies": [\
|
||||
["@standardnotes/sncrypto-common", "npm:1.11.1"],\
|
||||
["@standardnotes/sncrypto-common", "npm:1.12.0"],\
|
||||
["reflect-metadata", "npm:0.1.13"]\
|
||||
],\
|
||||
"linkType": "HARD"\
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,42 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.34.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.33.0...@standardnotes/analytics@1.34.0) (2022-10-04)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add new statistics measures for income ([19455ba](https://github.com/standardnotes/server/commit/19455ba6a7d84a389830c728c3dfea550b156985))
|
||||
|
||||
# [1.33.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.32.0...@standardnotes/analytics@1.33.0) (2022-10-03)
|
||||
|
||||
### Features
|
||||
|
||||
* add calculating monthly churn rate ([f075cd8](https://github.com/standardnotes/server/commit/f075cd8c4dfc411ba513dfec21bb84c03b238254))
|
||||
|
||||
# [1.32.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.31.1...@standardnotes/analytics@1.32.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking total customers count ([8a98f74](https://github.com/standardnotes/server/commit/8a98f746eb13c25f7940286aca594e2304232bdf))
|
||||
|
||||
## [1.31.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.31.0...@standardnotes/analytics@1.31.1) (2022-09-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** fix calculating new and existing customers churn ([82bb851](https://github.com/standardnotes/server/commit/82bb85174d94a5e03f364604a1c07a9b1633920d))
|
||||
|
||||
# [1.31.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.30.0...@standardnotes/analytics@1.31.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add measuring new customers ([b6118c1](https://github.com/standardnotes/server/commit/b6118c17e176ba0acc93b95a38e32748ac851410))
|
||||
|
||||
# [1.30.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.29.1...@standardnotes/analytics@1.30.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking churn activity ([39337c1](https://github.com/standardnotes/server/commit/39337c1c4f799f39672eeb8c9d050e7cbb19878a))
|
||||
|
||||
## [1.29.1](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.29.0...@standardnotes/analytics@1.29.1) (2022-09-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/analytics",
|
||||
"version": "1.29.1",
|
||||
"version": "1.34.0",
|
||||
"engines": {
|
||||
"node": ">=14.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -11,9 +11,12 @@ export enum AnalyticsActivity {
|
||||
SubscriptionRenewed = 'subscription-renewed',
|
||||
SubscriptionRefunded = 'subscription-refunded',
|
||||
SubscriptionCancelled = 'subscription-cancelled',
|
||||
SubscriptionExpired = 'subscription-expired',
|
||||
EmailUnbackedUpData = 'email-unbacked-up-data',
|
||||
EmailBackup = 'email-backup',
|
||||
LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased',
|
||||
PaymentFailed = 'payment-failed',
|
||||
PaymentSuccess = 'payment-success',
|
||||
NewCustomersChurn = 'new-customers-churn',
|
||||
ExistingCustomersChurn = 'existing-customers-churn',
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface AnalyticsStoreInterface {
|
||||
secondActivity: AnalyticsActivity
|
||||
secondActivityPeriodKey: string
|
||||
}): Promise<number>
|
||||
calculateActivityTotalCount(activity: AnalyticsActivity, period: Period): Promise<number>
|
||||
calculateActivityTotalCount(activity: AnalyticsActivity, periodOrPeriodKey: Period | string): Promise<number>
|
||||
calculateActivityChangesTotalCount(
|
||||
activity: AnalyticsActivity,
|
||||
period: Period,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
export enum StatisticsMeasure {
|
||||
Income = 'income',
|
||||
PlusSubscriptionInitialMonthlyPaymentsIncome = 'plus-subscription-initial-monthly-payments-income',
|
||||
ProSubscriptionInitialMonthlyPaymentsIncome = 'pro-subscription-initial-monthly-payments-income',
|
||||
PlusSubscriptionInitialAnnualPaymentsIncome = 'plus-subscription-initial-annual-payments-income',
|
||||
ProSubscriptionInitialAnnualPaymentsIncome = 'pro-subscription-initial-annual-payments-income',
|
||||
PlusSubscriptionRenewingMonthlyPaymentsIncome = 'plus-subscription-renewing-monthly-payments-income',
|
||||
ProSubscriptionRenewingMonthlyPaymentsIncome = 'pro-subscription-renewing-monthly-payments-income',
|
||||
PlusSubscriptionRenewingAnnualPaymentsIncome = 'plus-subscription-renewing-annual-payments-income',
|
||||
ProSubscriptionRenewingAnnualPaymentsIncome = 'pro-subscription-renewing-annual-payments-income',
|
||||
SubscriptionLength = 'subscription-length',
|
||||
RegistrationLength = 'registration-length',
|
||||
RegistrationToSubscriptionTime = 'registration-to-subscription-time',
|
||||
@@ -8,4 +16,6 @@ export enum StatisticsMeasure {
|
||||
NotesCountFreeUsers = 'notes-count-free-users',
|
||||
NotesCountPaidUsers = 'notes-count-paid-users',
|
||||
FilesCount = 'files-count',
|
||||
NewCustomers = 'new-customers',
|
||||
TotalCustomers = 'total-customers',
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface StatisticsStoreInterface {
|
||||
getYesterdayApplicationUsage(): Promise<Array<{ version: string; count: number }>>
|
||||
getYesterdayOutOfSyncIncidents(): Promise<number>
|
||||
incrementMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void>
|
||||
setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void>
|
||||
getMeasureAverage(measure: StatisticsMeasure, period: Period): Promise<number>
|
||||
getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number>
|
||||
getMeasureTotal(measure: StatisticsMeasure, periodOrPeriodKey: Period | string): Promise<number>
|
||||
}
|
||||
|
||||
@@ -7,10 +7,23 @@ export enum Period {
|
||||
WeekBeforeLastWeek,
|
||||
ThisMonth,
|
||||
LastMonth,
|
||||
ThisYear,
|
||||
Last30Days,
|
||||
Last7Days,
|
||||
Q1ThisYear,
|
||||
Q2ThisYear,
|
||||
Q3ThisYear,
|
||||
Q4ThisYear,
|
||||
JanuaryThisYear,
|
||||
FebruaryThisYear,
|
||||
MarchThisYear,
|
||||
AprilThisYear,
|
||||
MayThisYear,
|
||||
JuneThisYear,
|
||||
JulyThisYear,
|
||||
AugustThisYear,
|
||||
SeptemberThisYear,
|
||||
OctoberThisYear,
|
||||
NovemberThisYear,
|
||||
DecemberThisYear,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,20 @@ import { PeriodKeyGenerator } from './PeriodKeyGenerator'
|
||||
|
||||
describe('PeriodKeyGenerator', () => {
|
||||
const createGenerator = () => new PeriodKeyGenerator()
|
||||
const months = [
|
||||
Period.JanuaryThisYear,
|
||||
Period.FebruaryThisYear,
|
||||
Period.MarchThisYear,
|
||||
Period.AprilThisYear,
|
||||
Period.MayThisYear,
|
||||
Period.JuneThisYear,
|
||||
Period.JulyThisYear,
|
||||
Period.AugustThisYear,
|
||||
Period.SeptemberThisYear,
|
||||
Period.OctoberThisYear,
|
||||
Period.NovemberThisYear,
|
||||
Period.DecemberThisYear,
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
@@ -48,6 +62,23 @@ describe('PeriodKeyGenerator', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for this year', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.ThisYear)).toEqual([
|
||||
'2022-1',
|
||||
'2022-2',
|
||||
'2022-3',
|
||||
'2022-4',
|
||||
'2022-5',
|
||||
'2022-6',
|
||||
'2022-7',
|
||||
'2022-8',
|
||||
'2022-9',
|
||||
'2022-10',
|
||||
'2022-11',
|
||||
'2022-12',
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for last 7 days', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.Last7Days)).toEqual([
|
||||
'2022-5-17',
|
||||
@@ -60,6 +91,81 @@ describe('PeriodKeyGenerator', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for this month', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.ThisMonth)).toEqual([
|
||||
'2022-5-1',
|
||||
'2022-5-2',
|
||||
'2022-5-3',
|
||||
'2022-5-4',
|
||||
'2022-5-5',
|
||||
'2022-5-6',
|
||||
'2022-5-7',
|
||||
'2022-5-8',
|
||||
'2022-5-9',
|
||||
'2022-5-10',
|
||||
'2022-5-11',
|
||||
'2022-5-12',
|
||||
'2022-5-13',
|
||||
'2022-5-14',
|
||||
'2022-5-15',
|
||||
'2022-5-16',
|
||||
'2022-5-17',
|
||||
'2022-5-18',
|
||||
'2022-5-19',
|
||||
'2022-5-20',
|
||||
'2022-5-21',
|
||||
'2022-5-22',
|
||||
'2022-5-23',
|
||||
'2022-5-24',
|
||||
'2022-5-25',
|
||||
'2022-5-26',
|
||||
'2022-5-27',
|
||||
'2022-5-28',
|
||||
'2022-5-29',
|
||||
'2022-5-30',
|
||||
'2022-5-31',
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for specific month', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.FebruaryThisYear)).toEqual([
|
||||
'2022-2-1',
|
||||
'2022-2-2',
|
||||
'2022-2-3',
|
||||
'2022-2-4',
|
||||
'2022-2-5',
|
||||
'2022-2-6',
|
||||
'2022-2-7',
|
||||
'2022-2-8',
|
||||
'2022-2-9',
|
||||
'2022-2-10',
|
||||
'2022-2-11',
|
||||
'2022-2-12',
|
||||
'2022-2-13',
|
||||
'2022-2-14',
|
||||
'2022-2-15',
|
||||
'2022-2-16',
|
||||
'2022-2-17',
|
||||
'2022-2-18',
|
||||
'2022-2-19',
|
||||
'2022-2-20',
|
||||
'2022-2-21',
|
||||
'2022-2-22',
|
||||
'2022-2-23',
|
||||
'2022-2-24',
|
||||
'2022-2-25',
|
||||
'2022-2-26',
|
||||
'2022-2-27',
|
||||
'2022-2-28',
|
||||
])
|
||||
})
|
||||
|
||||
it('should generate period keys for specific months', () => {
|
||||
for (const month of months) {
|
||||
expect(createGenerator().getDiscretePeriodKeys(month).length >= 28).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('should generate period keys for Q1', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.Q1ThisYear)).toEqual(['2022-1', '2022-2', '2022-3'])
|
||||
})
|
||||
@@ -76,6 +182,10 @@ describe('PeriodKeyGenerator', () => {
|
||||
expect(createGenerator().getDiscretePeriodKeys(Period.Q4ThisYear)).toEqual(['2022-10', '2022-11', '2022-12'])
|
||||
})
|
||||
|
||||
it('should generate a period key for this year', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.ThisYear)).toEqual('2022')
|
||||
})
|
||||
|
||||
it('should generate a period key for today', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.Today)).toEqual('2022-5-24')
|
||||
})
|
||||
@@ -104,6 +214,12 @@ describe('PeriodKeyGenerator', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.ThisMonth)).toEqual('2022-5')
|
||||
})
|
||||
|
||||
it('should generate a period key for each month', () => {
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
expect(createGenerator().getPeriodKey(months[i])).toEqual(`2022-${i + 1}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('should generate a period key for last month', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.LastMonth)).toEqual('2022-4')
|
||||
})
|
||||
@@ -129,4 +245,19 @@ describe('PeriodKeyGenerator', () => {
|
||||
|
||||
expect(error).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should convert period key to period', () => {
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-1')).toEqual(Period.JanuaryThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-2')).toEqual(Period.FebruaryThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-3')).toEqual(Period.MarchThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-4')).toEqual(Period.AprilThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-5')).toEqual(Period.MayThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-6')).toEqual(Period.JuneThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-7')).toEqual(Period.JulyThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-8')).toEqual(Period.AugustThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-9')).toEqual(Period.SeptemberThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-10')).toEqual(Period.OctoberThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-11')).toEqual(Period.NovemberThisYear)
|
||||
expect(createGenerator().convertPeriodKeyToPeriod('2022-12')).toEqual(Period.DecemberThisYear)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,28 @@ import { Period } from './Period'
|
||||
import { PeriodKeyGeneratorInterface } from './PeriodKeyGeneratorInterface'
|
||||
|
||||
export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
private readonly MONTHS = [
|
||||
Period.JanuaryThisYear,
|
||||
Period.FebruaryThisYear,
|
||||
Period.MarchThisYear,
|
||||
Period.AprilThisYear,
|
||||
Period.MayThisYear,
|
||||
Period.JuneThisYear,
|
||||
Period.JulyThisYear,
|
||||
Period.AugustThisYear,
|
||||
Period.SeptemberThisYear,
|
||||
Period.OctoberThisYear,
|
||||
Period.NovemberThisYear,
|
||||
Period.DecemberThisYear,
|
||||
]
|
||||
|
||||
convertPeriodKeyToPeriod(periodKey: string): Period {
|
||||
const date = new Date(periodKey)
|
||||
const month = this.getMonth(date)
|
||||
|
||||
return this.MONTHS[+month - 1]
|
||||
}
|
||||
|
||||
getDiscretePeriodKeys(period: Period): string[] {
|
||||
const periodKeys = []
|
||||
|
||||
@@ -26,6 +48,23 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
return this.generateMonthlyKeysRange(6, 9)
|
||||
case Period.Q4ThisYear:
|
||||
return this.generateMonthlyKeysRange(9, 12)
|
||||
case Period.ThisYear:
|
||||
return this.generateMonthlyKeysRange(0, 12)
|
||||
case Period.ThisMonth:
|
||||
return this.generateDailyKeysRange()
|
||||
case Period.JanuaryThisYear:
|
||||
case Period.FebruaryThisYear:
|
||||
case Period.MarchThisYear:
|
||||
case Period.AprilThisYear:
|
||||
case Period.MayThisYear:
|
||||
case Period.JuneThisYear:
|
||||
case Period.JulyThisYear:
|
||||
case Period.AugustThisYear:
|
||||
case Period.SeptemberThisYear:
|
||||
case Period.OctoberThisYear:
|
||||
case Period.NovemberThisYear:
|
||||
case Period.DecemberThisYear:
|
||||
return this.generateDailyKeysRange(period - 15)
|
||||
default:
|
||||
throw new Error(`Unsuporrted period: ${period}`)
|
||||
}
|
||||
@@ -49,11 +88,43 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
return this.getMonthlyKey()
|
||||
case Period.LastMonth:
|
||||
return this.getMonthlyKey(this.getLastMonthDate())
|
||||
case Period.ThisYear:
|
||||
return this.getYearlyKey()
|
||||
case Period.JanuaryThisYear:
|
||||
return this.generateMonthlyKeysRange(0, 1)[0]
|
||||
case Period.FebruaryThisYear:
|
||||
return this.generateMonthlyKeysRange(1, 2)[0]
|
||||
case Period.MarchThisYear:
|
||||
return this.generateMonthlyKeysRange(2, 3)[0]
|
||||
case Period.AprilThisYear:
|
||||
return this.generateMonthlyKeysRange(3, 4)[0]
|
||||
case Period.MayThisYear:
|
||||
return this.generateMonthlyKeysRange(4, 5)[0]
|
||||
case Period.JuneThisYear:
|
||||
return this.generateMonthlyKeysRange(5, 6)[0]
|
||||
case Period.JulyThisYear:
|
||||
return this.generateMonthlyKeysRange(6, 7)[0]
|
||||
case Period.AugustThisYear:
|
||||
return this.generateMonthlyKeysRange(7, 8)[0]
|
||||
case Period.SeptemberThisYear:
|
||||
return this.generateMonthlyKeysRange(8, 9)[0]
|
||||
case Period.OctoberThisYear:
|
||||
return this.generateMonthlyKeysRange(9, 10)[0]
|
||||
case Period.NovemberThisYear:
|
||||
return this.generateMonthlyKeysRange(10, 11)[0]
|
||||
case Period.DecemberThisYear:
|
||||
return this.generateMonthlyKeysRange(11, 12)[0]
|
||||
default:
|
||||
throw new Error(`Unsuporrted period: ${period}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getYearlyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
return this.getYear(date)
|
||||
}
|
||||
|
||||
private getMonthlyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
@@ -141,4 +212,22 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
private generateDailyKeysRange(month?: number): string[] {
|
||||
const today = new Date()
|
||||
if (month) {
|
||||
today.setMonth(month)
|
||||
}
|
||||
const numberOfDays = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
|
||||
|
||||
const keys = []
|
||||
for (let i = 1; i <= numberOfDays; i++) {
|
||||
const date = new Date()
|
||||
date.setMonth(today.getMonth())
|
||||
date.setDate(i)
|
||||
keys.push(this.getDailyKey(date))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ import { Period } from './Period'
|
||||
|
||||
export interface PeriodKeyGeneratorInterface {
|
||||
getPeriodKey(period: Period): string
|
||||
convertPeriodKeyToPeriod(periodKey: string): Period
|
||||
getDiscretePeriodKeys(period: Period): string[]
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('RedisAnalyticsStore', () => {
|
||||
expect(caughtError).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should calculate total count of activities', async () => {
|
||||
it('should calculate total count of activities by period', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValue(70)
|
||||
|
||||
expect(await createStore().calculateActivityTotalCount(AnalyticsActivity.EditingItems, Period.Yesterday)).toEqual(
|
||||
@@ -112,6 +112,14 @@ describe('RedisAnalyticsStore', () => {
|
||||
expect(redisClient.bitcount).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:period-key')
|
||||
})
|
||||
|
||||
it('should calculate total count of activities by period key', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValue(70)
|
||||
|
||||
expect(await createStore().calculateActivityTotalCount(AnalyticsActivity.EditingItems, '2022-10-03')).toEqual(70)
|
||||
|
||||
expect(redisClient.bitcount).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:2022-10-03')
|
||||
})
|
||||
|
||||
it('should calculate activity retention', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValueOnce(7).mockReturnValueOnce(10)
|
||||
|
||||
|
||||
@@ -134,9 +134,12 @@ export class RedisAnalyticsStore implements AnalyticsStoreInterface {
|
||||
})
|
||||
}
|
||||
|
||||
async calculateActivityTotalCount(activity: AnalyticsActivity, period: Period): Promise<number> {
|
||||
return this.redisClient.bitcount(
|
||||
`bitmap:action:${activity}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
)
|
||||
async calculateActivityTotalCount(activity: AnalyticsActivity, periodOrPeriodKey: Period | string): Promise<number> {
|
||||
let periodKey = periodOrPeriodKey
|
||||
if (!isNaN(+periodOrPeriodKey)) {
|
||||
periodKey = this.periodKeyGenerator.getPeriodKey(periodOrPeriodKey as Period)
|
||||
}
|
||||
|
||||
return this.redisClient.bitcount(`bitmap:action:${activity}:timespan:${periodKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('RedisStatisticsStore', () => {
|
||||
pipeline = {} as jest.Mocked<IORedis.Pipeline>
|
||||
pipeline.incr = jest.fn()
|
||||
pipeline.incrbyfloat = jest.fn()
|
||||
pipeline.set = jest.fn()
|
||||
pipeline.setbit = jest.fn()
|
||||
pipeline.exec = jest.fn()
|
||||
|
||||
@@ -92,6 +93,13 @@ describe('RedisStatisticsStore', () => {
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set a value to a measure', async () => {
|
||||
await createStore().setMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth])
|
||||
|
||||
expect(pipeline.set).toHaveBeenCalledTimes(2)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should increment measure by a value', async () => {
|
||||
await createStore().incrementMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth])
|
||||
|
||||
@@ -117,4 +125,20 @@ describe('RedisStatisticsStore', () => {
|
||||
|
||||
expect(await createStore().getMeasureAverage(StatisticsMeasure.Income, Period.Today)).toEqual(0)
|
||||
})
|
||||
|
||||
it('should retrieve a measurement total for period', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(5)
|
||||
|
||||
expect(await createStore().getMeasureTotal(StatisticsMeasure.Income, Period.Today)).toEqual(5)
|
||||
|
||||
expect(redisClient.get).toHaveBeenCalledWith('count:measure:income:timespan:period-key')
|
||||
})
|
||||
|
||||
it('should retrieve a measurement total for period key', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(5)
|
||||
|
||||
expect(await createStore().getMeasureTotal(StatisticsMeasure.Income, '2022-10-03')).toEqual(5)
|
||||
|
||||
expect(redisClient.get).toHaveBeenCalledWith('count:measure:income:timespan:2022-10-03')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,23 @@ import { StatisticsStoreInterface } from '../../Domain/Statistics/StatisticsStor
|
||||
export class RedisStatisticsStore implements StatisticsStoreInterface {
|
||||
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
|
||||
|
||||
async getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number> {
|
||||
const totalValue = await this.redisClient.get(
|
||||
`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
)
|
||||
async setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
for (const period of periods) {
|
||||
pipeline.set(`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`, value)
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async getMeasureTotal(measure: StatisticsMeasure, periodOrPeriodKey: Period | string): Promise<number> {
|
||||
let periodKey = periodOrPeriodKey
|
||||
if (!isNaN(+periodOrPeriodKey)) {
|
||||
periodKey = this.periodKeyGenerator.getPeriodKey(periodOrPeriodKey as Period)
|
||||
}
|
||||
|
||||
const totalValue = await this.redisClient.get(`count:measure:${measure}:timespan:${periodKey}`)
|
||||
|
||||
if (totalValue === null) {
|
||||
return 0
|
||||
|
||||
@@ -3,6 +3,80 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.24.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.24.4...@standardnotes/api-gateway@1.24.5) (2022-10-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.24.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.24.3...@standardnotes/api-gateway@1.24.4) (2022-10-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.24.3](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.24.2...@standardnotes/api-gateway@1.24.3) (2022-10-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.24.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.24.1...@standardnotes/api-gateway@1.24.2) (2022-10-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api-gateway:** report churn values for empty months ([f43fbf1](https://github.com/standardnotes/api-gateway/commit/f43fbf15844be05add905134dfb3e8ca90f78458))
|
||||
|
||||
## [1.24.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.24.0...@standardnotes/api-gateway@1.24.1) (2022-10-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logs for churn calculation ([2236cc3](https://github.com/standardnotes/api-gateway/commit/2236cc3828167e4b94defbde2691bba38458bd1c))
|
||||
|
||||
# [1.24.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.23.0...@standardnotes/api-gateway@1.24.0) (2022-10-03)
|
||||
|
||||
### Features
|
||||
|
||||
* add calculating monthly churn rate ([f075cd8](https://github.com/standardnotes/api-gateway/commit/f075cd8c4dfc411ba513dfec21bb84c03b238254))
|
||||
|
||||
# [1.23.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.6...@standardnotes/api-gateway@1.23.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **api-gateway:** add churn metrics to the report ([dfab849](https://github.com/standardnotes/api-gateway/commit/dfab849f48ab782c3cd2e97f52fdb72b7143002f))
|
||||
|
||||
## [1.22.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.5...@standardnotes/api-gateway@1.22.6) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.4...@standardnotes/api-gateway@1.22.5) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.3...@standardnotes/api-gateway@1.22.4) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.3](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.2...@standardnotes/api-gateway@1.22.3) (2022-09-30)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.1...@standardnotes/api-gateway@1.22.2) (2022-09-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api-gateway
|
||||
|
||||
## [1.22.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.0...@standardnotes/api-gateway@1.22.1) (2022-09-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api-gateway:** remove admin graphql endpoint from being publicly available ([0a90d98](https://github.com/standardnotes/api-gateway/commit/0a90d98c71c6023b700f852c91aedfe1ad23af55))
|
||||
|
||||
# [1.22.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.21.1...@standardnotes/api-gateway@1.22.0) (2022-09-22)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** remove muting emails by use case in favor of updating user settings ([d3f36c0](https://github.com/standardnotes/api-gateway/commit/d3f36c05dfc114098a6c231d81149ebd1a959b74))
|
||||
|
||||
## [1.21.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.21.0...@standardnotes/api-gateway@1.21.1) (2022-09-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api-gateway:** web socket connection routing ([d35de38](https://github.com/standardnotes/api-gateway/commit/d35de38289e70d707d57a859b8bf39833fa825dd))
|
||||
|
||||
# [1.21.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.20.0...@standardnotes/api-gateway@1.21.0) (2022-09-21)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -38,6 +38,8 @@ const requestReport = async (
|
||||
AnalyticsActivity.DeleteAccount,
|
||||
AnalyticsActivity.SubscriptionCancelled,
|
||||
AnalyticsActivity.SubscriptionRefunded,
|
||||
AnalyticsActivity.ExistingCustomersChurn,
|
||||
AnalyticsActivity.NewCustomersChurn,
|
||||
]
|
||||
|
||||
for (const analyticsName of thirtyDaysAnalyticsNames) {
|
||||
@@ -74,6 +76,8 @@ const requestReport = async (
|
||||
AnalyticsActivity.GeneralActivityPaidUsers,
|
||||
AnalyticsActivity.PaymentFailed,
|
||||
AnalyticsActivity.PaymentSuccess,
|
||||
AnalyticsActivity.NewCustomersChurn,
|
||||
AnalyticsActivity.ExistingCustomersChurn,
|
||||
]
|
||||
|
||||
for (const activityName of yesterdayActivityNames) {
|
||||
@@ -98,6 +102,8 @@ const requestReport = async (
|
||||
StatisticsMeasure.NotesCountFreeUsers,
|
||||
StatisticsMeasure.NotesCountPaidUsers,
|
||||
StatisticsMeasure.FilesCount,
|
||||
StatisticsMeasure.NewCustomers,
|
||||
StatisticsMeasure.TotalCustomers,
|
||||
]
|
||||
const statisticMeasures = []
|
||||
for (const statisticMeasureName of statisticMeasureNames) {
|
||||
@@ -130,6 +136,39 @@ const requestReport = async (
|
||||
}
|
||||
}
|
||||
|
||||
const monthlyPeriodKeys = periodKeyGenerator.getDiscretePeriodKeys(Period.ThisYear)
|
||||
const churnRates = []
|
||||
for (const monthPeriodKey of monthlyPeriodKeys) {
|
||||
const monthPeriod = periodKeyGenerator.convertPeriodKeyToPeriod(monthPeriodKey)
|
||||
const dailyPeriodKeys = periodKeyGenerator.getDiscretePeriodKeys(monthPeriod)
|
||||
|
||||
const totalCustomerCounts: Array<number> = []
|
||||
for (const dailyPeriodKey of dailyPeriodKeys) {
|
||||
const customersCount = await statisticsStore.getMeasureTotal(StatisticsMeasure.TotalCustomers, dailyPeriodKey)
|
||||
totalCustomerCounts.push(customersCount)
|
||||
}
|
||||
const filteredTotalCustomerCounts = totalCustomerCounts.filter((count) => !!count)
|
||||
const averageCustomersCount = filteredTotalCustomerCounts.length
|
||||
? filteredTotalCustomerCounts.reduce((total, current) => total + current, 0) / filteredTotalCustomerCounts.length
|
||||
: 0
|
||||
|
||||
const existingCustomersChurn = await analyticsStore.calculateActivityTotalCount(
|
||||
AnalyticsActivity.ExistingCustomersChurn,
|
||||
monthPeriodKey,
|
||||
)
|
||||
const newCustomersChurn = await analyticsStore.calculateActivityTotalCount(
|
||||
AnalyticsActivity.NewCustomersChurn,
|
||||
monthPeriodKey,
|
||||
)
|
||||
|
||||
const totalChurn = existingCustomersChurn + newCustomersChurn
|
||||
|
||||
churnRates.push({
|
||||
periodKey: monthPeriodKey,
|
||||
rate: averageCustomersCount ? (totalChurn / averageCustomersCount) * 100 : 0,
|
||||
})
|
||||
}
|
||||
|
||||
const event: DailyAnalyticsReportGeneratedEvent = {
|
||||
type: 'DAILY_ANALYTICS_REPORT_GENERATED',
|
||||
createdAt: new Date(),
|
||||
@@ -157,6 +196,10 @@ const requestReport = async (
|
||||
},
|
||||
},
|
||||
],
|
||||
churn: {
|
||||
periodKeys: monthlyPeriodKeys,
|
||||
values: churnRates,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api-gateway",
|
||||
"version": "1.21.0",
|
||||
"version": "1.24.5",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -29,34 +29,4 @@ export class ActionsController extends BaseHttpController {
|
||||
async methods(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callAuthServer(request, response, 'auth/methods', request.body)
|
||||
}
|
||||
|
||||
@httpGet('/failed-backups-emails/mute/:settingUuid')
|
||||
async muteFailedBackupsEmails(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callAuthServer(
|
||||
request,
|
||||
response,
|
||||
`internal/settings/email_backup/${request.params.settingUuid}/mute`,
|
||||
request.body,
|
||||
)
|
||||
}
|
||||
|
||||
@httpGet('/sign-in-emails/mute/:settingUuid')
|
||||
async muteSignInEmails(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callAuthServer(
|
||||
request,
|
||||
response,
|
||||
`internal/settings/sign_in/${request.params.settingUuid}/mute`,
|
||||
request.body,
|
||||
)
|
||||
}
|
||||
|
||||
@httpGet('/marketing-emails/mute/:settingUuid')
|
||||
async muteMarketingEmails(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callAuthServer(
|
||||
request,
|
||||
response,
|
||||
`internal/settings/marketing-emails/${request.params.settingUuid}/mute`,
|
||||
request.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,6 @@ export class PaymentsController extends BaseHttpController {
|
||||
await this.httpService.callPaymentsServer(request, response, 'admin/events/registration', request.body)
|
||||
}
|
||||
|
||||
@httpPost('/admin/graphql')
|
||||
async adminGraphql(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callPaymentsServer(request, response, 'admin/graphql', request.body)
|
||||
}
|
||||
|
||||
@httpPost('/admin/auth/login')
|
||||
async adminLogin(request: Request, response: Response): Promise<void> {
|
||||
await this.httpService.callPaymentsServer(request, response, 'admin/auth/login', request.body)
|
||||
|
||||
@@ -20,7 +20,7 @@ export class WebSocketsController extends BaseHttpController {
|
||||
await this.httpService.callAuthServer(request, response, 'sockets/tokens', request.body)
|
||||
}
|
||||
|
||||
@httpPost('/', TYPES.WebSocketAuthMiddleware)
|
||||
@httpPost('/connections', TYPES.WebSocketAuthMiddleware)
|
||||
async createWebSocketConnection(request: Request, response: Response): Promise<void> {
|
||||
if (!request.headers.connectionid) {
|
||||
this.logger.error('Could not create a websocket connection. Missing connection id header.')
|
||||
@@ -30,10 +30,15 @@ export class WebSocketsController extends BaseHttpController {
|
||||
return
|
||||
}
|
||||
|
||||
await this.httpService.callAuthServer(request, response, `sockets/${request.headers.connectionid}`, request.body)
|
||||
await this.httpService.callAuthServer(
|
||||
request,
|
||||
response,
|
||||
`sockets/connections/${request.headers.connectionid}`,
|
||||
request.body,
|
||||
)
|
||||
}
|
||||
|
||||
@httpDelete('/')
|
||||
@httpDelete('/connections')
|
||||
async deleteWebSocketConnection(request: Request, response: Response): Promise<void> {
|
||||
if (!request.headers.connectionid) {
|
||||
this.logger.error('Could not delete a websocket connection. Missing connection id header.')
|
||||
@@ -43,6 +48,11 @@ export class WebSocketsController extends BaseHttpController {
|
||||
return
|
||||
}
|
||||
|
||||
await this.httpService.callAuthServer(request, response, `sockets/${request.headers.connectionid}`, request.body)
|
||||
await this.httpService.callAuthServer(
|
||||
request,
|
||||
response,
|
||||
`sockets/connections/${request.headers.connectionid}`,
|
||||
request.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,156 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.37.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.36.4...@standardnotes/auth-server@1.37.0) (2022-10-04)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add detailed income stats ([8668fec](https://github.com/standardnotes/server/commit/8668fec33dac1598bdc4d6ca869c296ed6eaa617))
|
||||
|
||||
## [1.36.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.36.3...@standardnotes/auth-server@1.36.4) (2022-10-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
## [1.36.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.36.2...@standardnotes/auth-server@1.36.3) (2022-10-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** turn down severity of logs for predicate verification ([09b3f9a](https://github.com/standardnotes/server/commit/09b3f9a0d787d2a329f84e2d625ec8a63b4bd847))
|
||||
|
||||
## [1.36.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.36.1...@standardnotes/auth-server@1.36.2) (2022-10-03)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
## [1.36.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.36.0...@standardnotes/auth-server@1.36.1) (2022-10-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** counting active subscriptions ([e7736bb](https://github.com/standardnotes/server/commit/e7736bba250782a3967fd08c82dbf32884b5b892))
|
||||
|
||||
# [1.36.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.35.0...@standardnotes/auth-server@1.36.0) (2022-10-03)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** disallow v1 sign in for users with 004 protocol version ([6a9d479](https://github.com/standardnotes/server/commit/6a9d479f7173268bc0c79b1c7583021989be783a))
|
||||
|
||||
# [1.35.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.34.1...@standardnotes/auth-server@1.35.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking total customers count ([8a98f74](https://github.com/standardnotes/server/commit/8a98f746eb13c25f7940286aca594e2304232bdf))
|
||||
|
||||
## [1.34.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.34.0...@standardnotes/auth-server@1.34.1) (2022-09-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** fix calculating new and existing customers churn ([82bb851](https://github.com/standardnotes/server/commit/82bb85174d94a5e03f364604a1c07a9b1633920d))
|
||||
|
||||
# [1.34.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.33.0...@standardnotes/auth-server@1.34.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add measuring new customers ([b6118c1](https://github.com/standardnotes/server/commit/b6118c17e176ba0acc93b95a38e32748ac851410))
|
||||
|
||||
# [1.33.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.13...@standardnotes/auth-server@1.33.0) (2022-09-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add tracking churn activity ([39337c1](https://github.com/standardnotes/server/commit/39337c1c4f799f39672eeb8c9d050e7cbb19878a))
|
||||
|
||||
## [1.32.13](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.12...@standardnotes/auth-server@1.32.13) (2022-09-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** finding previous subscription setting for irreplacable subscription settings ([0a5b7e1](https://github.com/standardnotes/server/commit/0a5b7e13cd51ddbad40f67d629b0daf50b176fac))
|
||||
|
||||
## [1.32.12](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.11...@standardnotes/auth-server@1.32.12) (2022-09-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** reassign not replaceable subscription settings ([477f146](https://github.com/standardnotes/server/commit/477f146725c8e83b86a8224708046d0fd86bfa0b))
|
||||
|
||||
## [1.32.11](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.10...@standardnotes/auth-server@1.32.11) (2022-09-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** prevent replacing files bytes used subscription setting upon renewal ([40e6733](https://github.com/standardnotes/server/commit/40e673379bb84bd21bcc8dbcb1aa36caaa2adbf8))
|
||||
|
||||
## [1.32.10](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.9...@standardnotes/auth-server@1.32.10) (2022-09-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** exclude legacy 5 year plans from subscription length statistics ([c5a07a8](https://github.com/standardnotes/server/commit/c5a07a888aadc22f62a92a236977c266f8d8e1c0))
|
||||
|
||||
## [1.32.9](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.8...@standardnotes/auth-server@1.32.9) (2022-09-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
## [1.32.8](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.7...@standardnotes/auth-server@1.32.8) (2022-09-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** ttl for lock counter on login lockout ([54da5de](https://github.com/standardnotes/server/commit/54da5def4bbfbb4f74cbf02ae23e45103d250dd9))
|
||||
|
||||
## [1.32.7](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.6...@standardnotes/auth-server@1.32.7) (2022-09-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** allow resending canceled subscription invites ([b190931](https://github.com/standardnotes/server/commit/b19093179baaa1fb8cdf3f9d9bee20e625ed0b9b))
|
||||
|
||||
## [1.32.6](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.5...@standardnotes/auth-server@1.32.6) (2022-09-22)
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "fix(auth): subscription token ttl" ([644c52a](https://github.com/standardnotes/server/commit/644c52ae36d3720dee0712e2cb826c7e617ab7b7))
|
||||
* Revert "fix(auth): increase subscription token ttl" ([2554273](https://github.com/standardnotes/server/commit/2554273a3f85a968fed4286d109bed5413ef9908))
|
||||
* Revert "tmp(auth): disable expiring of subscription tokens" ([a8ee149](https://github.com/standardnotes/server/commit/a8ee149d7ac78775bf447ab924458b116414a15e))
|
||||
|
||||
## [1.32.5](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.4...@standardnotes/auth-server@1.32.5) (2022-09-22)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/auth-server
|
||||
|
||||
## [1.32.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.3...@standardnotes/auth-server@1.32.4) (2022-09-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** increase subscription token ttl ([07def20](https://github.com/standardnotes/server/commit/07def20f6b47f9d1c678cfe5206b924dd5e6014a))
|
||||
|
||||
## [1.32.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.2...@standardnotes/auth-server@1.32.3) (2022-09-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** subscription token ttl ([6efd336](https://github.com/standardnotes/server/commit/6efd336f3407e7204a0c5d385ea9df5c02c7e5f5))
|
||||
|
||||
## [1.32.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.1...@standardnotes/auth-server@1.32.2) (2022-09-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** add throwing an error if the subscription token was not persisted ([76cee6d](https://github.com/standardnotes/server/commit/76cee6dbad9bff041d8d5a1d4435046509c14f71))
|
||||
|
||||
## [1.32.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.0...@standardnotes/auth-server@1.32.1) (2022-09-22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** settings and subscription settings projection ([d6cf8d4](https://github.com/standardnotes/server/commit/d6cf8d400a0177ee9030a171cf2ca47ade293fd9))
|
||||
|
||||
# [1.32.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.31.2...@standardnotes/auth-server@1.32.0) (2022-09-22)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** remove muting emails by use case in favor of updating user settings ([d3f36c0](https://github.com/standardnotes/server/commit/d3f36c05dfc114098a6c231d81149ebd1a959b74))
|
||||
|
||||
## [1.31.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.31.1...@standardnotes/auth-server@1.31.2) (2022-09-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** response wrapping on web socket connection token creation ([413a276](https://github.com/standardnotes/server/commit/413a276d205d53c316f7d0af8aed422001a6c1ab))
|
||||
|
||||
## [1.31.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.31.0...@standardnotes/auth-server@1.31.1) (2022-09-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** web sockets routes ([875edce](https://github.com/standardnotes/server/commit/875edce5b1dc134b4e22702354b29303fab3c910))
|
||||
|
||||
# [1.31.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.30.1...@standardnotes/auth-server@1.31.0) (2022-09-21)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/auth-server",
|
||||
"version": "1.31.0",
|
||||
"version": "1.37.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
@@ -34,11 +34,11 @@
|
||||
"@newrelic/winston-enricher": "^4.0.0",
|
||||
"@sentry/node": "^7.3.0",
|
||||
"@standardnotes/analytics": "workspace:*",
|
||||
"@standardnotes/api": "^1.8.1",
|
||||
"@standardnotes/api": "^1.9.0",
|
||||
"@standardnotes/common": "workspace:*",
|
||||
"@standardnotes/domain-events": "workspace:*",
|
||||
"@standardnotes/domain-events-infra": "workspace:*",
|
||||
"@standardnotes/features": "^1.47.0",
|
||||
"@standardnotes/features": "^1.52.1",
|
||||
"@standardnotes/predicates": "workspace:*",
|
||||
"@standardnotes/responses": "^1.6.39",
|
||||
"@standardnotes/security": "workspace:*",
|
||||
|
||||
@@ -143,7 +143,6 @@ import { ApiGatewayOfflineAuthMiddleware } from '../Controller/ApiGatewayOffline
|
||||
import { UserEmailChangedEventHandler } from '../Domain/Handler/UserEmailChangedEventHandler'
|
||||
import { SettingsAssociationServiceInterface } from '../Domain/Setting/SettingsAssociationServiceInterface'
|
||||
import { SettingsAssociationService } from '../Domain/Setting/SettingsAssociationService'
|
||||
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
|
||||
import { SubscriptionSyncRequestedEventHandler } from '../Domain/Handler/SubscriptionSyncRequestedEventHandler'
|
||||
import {
|
||||
CrossServiceTokenData,
|
||||
@@ -163,7 +162,6 @@ import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValet
|
||||
import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount'
|
||||
import { ListedAccountCreatedEventHandler } from '../Domain/Handler/ListedAccountCreatedEventHandler'
|
||||
import { ListedAccountDeletedEventHandler } from '../Domain/Handler/ListedAccountDeletedEventHandler'
|
||||
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
|
||||
import { FileRemovedEventHandler } from '../Domain/Handler/FileRemovedEventHandler'
|
||||
import { UserDisabledSessionUserAgentLoggingEventHandler } from '../Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler'
|
||||
import { SettingInterpreterInterface } from '../Domain/Setting/SettingInterpreterInterface'
|
||||
@@ -204,7 +202,6 @@ import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUser
|
||||
import { AuthController } from '../Controller/AuthController'
|
||||
import { VerifyPredicate } from '../Domain/UseCase/VerifyPredicate/VerifyPredicate'
|
||||
import { PredicateVerificationRequestedEventHandler } from '../Domain/Handler/PredicateVerificationRequestedEventHandler'
|
||||
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
|
||||
import { PaymentFailedEventHandler } from '../Domain/Handler/PaymentFailedEventHandler'
|
||||
import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEventHandler'
|
||||
import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler'
|
||||
@@ -439,9 +436,6 @@ export class ContainerConfigLoader {
|
||||
container
|
||||
.bind<CreateOfflineSubscriptionToken>(TYPES.CreateOfflineSubscriptionToken)
|
||||
.to(CreateOfflineSubscriptionToken)
|
||||
container.bind<MuteFailedBackupsEmails>(TYPES.MuteFailedBackupsEmails).to(MuteFailedBackupsEmails)
|
||||
container.bind<MuteSignInEmails>(TYPES.MuteSignInEmails).to(MuteSignInEmails)
|
||||
container.bind<MuteMarketingEmails>(TYPES.MuteMarketingEmails).to(MuteMarketingEmails)
|
||||
container.bind<CreateValetToken>(TYPES.CreateValetToken).to(CreateValetToken)
|
||||
container.bind<CreateListedAccount>(TYPES.CreateListedAccount).to(CreateListedAccount)
|
||||
container.bind<InviteToSharedSubscription>(TYPES.InviteToSharedSubscription).to(InviteToSharedSubscription)
|
||||
|
||||
@@ -115,9 +115,6 @@ const TYPES = {
|
||||
AuthenticateSubscriptionToken: Symbol.for('AuthenticateSubscriptionToken'),
|
||||
CreateOfflineSubscriptionToken: Symbol.for('CreateOfflineSubscriptionToken'),
|
||||
AuthenticateOfflineSubscriptionToken: Symbol.for('AuthenticateOfflineSubscriptionToken'),
|
||||
MuteFailedBackupsEmails: Symbol.for('MuteFailedBackupsEmails'),
|
||||
MuteSignInEmails: Symbol.for('MuteSignInEmails'),
|
||||
MuteMarketingEmails: Symbol.for('MuteMarketingEmails'),
|
||||
CreateValetToken: Symbol.for('CreateValetToken'),
|
||||
CreateListedAccount: Symbol.for('CreateListedAccount'),
|
||||
InviteToSharedSubscription: Symbol.for('InviteToSharedSubscription'),
|
||||
|
||||
@@ -7,23 +7,16 @@ import { results } from 'inversify-express-utils'
|
||||
import { User } from '../Domain/User/User'
|
||||
import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures'
|
||||
import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting'
|
||||
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
|
||||
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
|
||||
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
|
||||
|
||||
describe('InternalController', () => {
|
||||
let getUserFeatures: GetUserFeatures
|
||||
let getSetting: GetSetting
|
||||
let muteFailedBackupsEmails: MuteFailedBackupsEmails
|
||||
let muteSignInEmails: MuteSignInEmails
|
||||
let muteMarketingEmails: MuteMarketingEmails
|
||||
|
||||
let request: express.Request
|
||||
let response: express.Response
|
||||
let user: User
|
||||
|
||||
const createController = () =>
|
||||
new InternalController(getUserFeatures, getSetting, muteFailedBackupsEmails, muteSignInEmails, muteMarketingEmails)
|
||||
const createController = () => new InternalController(getUserFeatures, getSetting)
|
||||
|
||||
beforeEach(() => {
|
||||
user = {} as jest.Mocked<User>
|
||||
@@ -35,15 +28,6 @@ describe('InternalController', () => {
|
||||
getSetting = {} as jest.Mocked<GetSetting>
|
||||
getSetting.execute = jest.fn()
|
||||
|
||||
muteFailedBackupsEmails = {} as jest.Mocked<MuteFailedBackupsEmails>
|
||||
muteFailedBackupsEmails.execute = jest.fn()
|
||||
|
||||
muteSignInEmails = {} as jest.Mocked<MuteSignInEmails>
|
||||
muteSignInEmails.execute = jest.fn()
|
||||
|
||||
muteMarketingEmails = {} as jest.Mocked<MuteMarketingEmails>
|
||||
muteMarketingEmails.execute = jest.fn()
|
||||
|
||||
request = {
|
||||
headers: {},
|
||||
body: {},
|
||||
@@ -120,83 +104,4 @@ describe('InternalController', () => {
|
||||
|
||||
expect(result.statusCode).toEqual(400)
|
||||
})
|
||||
|
||||
it('should mute failed backup emails user setting', async () => {
|
||||
request.params.settingUuid = '1-2-3'
|
||||
|
||||
muteFailedBackupsEmails.execute = jest.fn().mockReturnValue({ success: true })
|
||||
|
||||
const httpResponse = <results.JsonResult>await createController().muteFailedBackupsEmails(request)
|
||||
const result = await httpResponse.executeAsync()
|
||||
|
||||
expect(muteFailedBackupsEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
|
||||
|
||||
expect(result.statusCode).toEqual(200)
|
||||
})
|
||||
|
||||
it('should not mute failed backup emails user setting if it does not exist', async () => {
|
||||
request.params.settingUuid = '1-2-3'
|
||||
|
||||
muteFailedBackupsEmails.execute = jest.fn().mockReturnValue({ success: false })
|
||||
|
||||
const httpResponse = <results.JsonResult>await createController().muteFailedBackupsEmails(request)
|
||||
const result = await httpResponse.executeAsync()
|
||||
|
||||
expect(muteFailedBackupsEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
|
||||
|
||||
expect(result.statusCode).toEqual(404)
|
||||
})
|
||||
|
||||
it('should mute sign in emails user setting', async () => {
|
||||
request.params.settingUuid = '1-2-3'
|
||||
|
||||
muteSignInEmails.execute = jest.fn().mockReturnValue({ success: true })
|
||||
|
||||
const httpResponse = <results.JsonResult>await createController().muteSignInEmails(request)
|
||||
const result = await httpResponse.executeAsync()
|
||||
|
||||
expect(muteSignInEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
|
||||
|
||||
expect(result.statusCode).toEqual(200)
|
||||
})
|
||||
|
||||
it('should not mute sign in emails user setting if it does not exist', async () => {
|
||||
request.params.settingUuid = '1-2-3'
|
||||
|
||||
muteSignInEmails.execute = jest.fn().mockReturnValue({ success: false })
|
||||
|
||||
const httpResponse = <results.JsonResult>await createController().muteSignInEmails(request)
|
||||
const result = await httpResponse.executeAsync()
|
||||
|
||||
expect(muteSignInEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
|
||||
|
||||
expect(result.statusCode).toEqual(404)
|
||||
})
|
||||
|
||||
it('should mute marketing emails user setting', async () => {
|
||||
request.params.settingUuid = '1-2-3'
|
||||
|
||||
muteMarketingEmails.execute = jest.fn().mockReturnValue({ success: true, message: 'foobar' })
|
||||
|
||||
await createController().muteMarketingEmails(request, response)
|
||||
|
||||
expect(muteMarketingEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith('content-type', 'text/html')
|
||||
expect(response.send).toHaveBeenCalledWith('foobar')
|
||||
})
|
||||
|
||||
it('should not mute marketing emails user setting if it does not exist', async () => {
|
||||
request.params.settingUuid = '1-2-3'
|
||||
|
||||
muteMarketingEmails.execute = jest.fn().mockReturnValue({ success: false, message: 'foobar' })
|
||||
|
||||
await createController().muteMarketingEmails(request, response)
|
||||
|
||||
expect(muteMarketingEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith('content-type', 'text/html')
|
||||
expect(response.status).toHaveBeenCalledWith(404)
|
||||
expect(response.send).toHaveBeenCalledWith('foobar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { Request } from 'express'
|
||||
import { inject } from 'inversify'
|
||||
import {
|
||||
BaseHttpController,
|
||||
@@ -10,18 +10,12 @@ import {
|
||||
import TYPES from '../Bootstrap/Types'
|
||||
import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting'
|
||||
import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures'
|
||||
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
|
||||
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
|
||||
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
|
||||
|
||||
@controller('/internal')
|
||||
export class InternalController extends BaseHttpController {
|
||||
constructor(
|
||||
@inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures,
|
||||
@inject(TYPES.GetSetting) private doGetSetting: GetSetting,
|
||||
@inject(TYPES.MuteFailedBackupsEmails) private doMuteFailedBackupsEmails: MuteFailedBackupsEmails,
|
||||
@inject(TYPES.MuteSignInEmails) private doMuteSignInEmails: MuteSignInEmails,
|
||||
@inject(TYPES.MuteMarketingEmails) private doMuteMarketingEmails: MuteMarketingEmails,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -54,50 +48,4 @@ export class InternalController extends BaseHttpController {
|
||||
|
||||
return this.json(result, 400)
|
||||
}
|
||||
|
||||
@httpGet('/settings/email_backup/:settingUuid/mute')
|
||||
async muteFailedBackupsEmails(request: Request): Promise<results.JsonResult> {
|
||||
const { settingUuid } = request.params
|
||||
const result = await this.doMuteFailedBackupsEmails.execute({
|
||||
settingUuid,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
return this.json({ message: result.message })
|
||||
}
|
||||
|
||||
return this.json({ message: result.message }, 404)
|
||||
}
|
||||
|
||||
@httpGet('/settings/sign_in/:settingUuid/mute')
|
||||
async muteSignInEmails(request: Request): Promise<results.JsonResult> {
|
||||
const { settingUuid } = request.params
|
||||
const result = await this.doMuteSignInEmails.execute({
|
||||
settingUuid,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
return this.json({ message: result.message })
|
||||
}
|
||||
|
||||
return this.json({ message: result.message }, 404)
|
||||
}
|
||||
|
||||
@httpGet('/settings/marketing-emails/:settingUuid/mute')
|
||||
async muteMarketingEmails(request: Request, response: Response): Promise<void> {
|
||||
const { settingUuid } = request.params
|
||||
const result = await this.doMuteMarketingEmails.execute({
|
||||
settingUuid,
|
||||
})
|
||||
|
||||
response.setHeader('content-type', 'text/html')
|
||||
|
||||
if (result.success) {
|
||||
response.send(result.message)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
response.status(404).send(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { PaymentSuccessEvent } from '@standardnotes/domain-events'
|
||||
import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
import { AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
|
||||
import { PaymentSuccessEventHandler } from './PaymentSuccessEventHandler'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
@@ -38,14 +38,41 @@ describe('PaymentSuccessEventHandler', () => {
|
||||
event.payload = {
|
||||
userEmail: 'test@test.com',
|
||||
amount: 12.45,
|
||||
billingFrequency: 12,
|
||||
paymentType: 'initial',
|
||||
subscriptionName: 'PRO_PLAN',
|
||||
}
|
||||
})
|
||||
|
||||
it('should mark payment failed for analytics', async () => {
|
||||
it('should mark payment success for analytics', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalled()
|
||||
expect(statisticsStore.incrementMeasure).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,
|
||||
])
|
||||
})
|
||||
|
||||
it('should not mark payment failed for analytics if user is not found', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
StatisticsMeasure,
|
||||
StatisticsStoreInterface,
|
||||
} from '@standardnotes/analytics'
|
||||
import { PaymentType, SubscriptionBillingFrequency, SubscriptionName } from '@standardnotes/common'
|
||||
import { DomainEventHandlerInterface, PaymentSuccessEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
|
||||
@@ -14,6 +15,47 @@ import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
|
||||
@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.UserRepository) private userRepository: UserRepositoryInterface,
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@@ -34,10 +76,21 @@ export class PaymentSuccessEventHandler implements DomainEventHandlerInterface {
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.Income, event.payload.amount, [
|
||||
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)
|
||||
}
|
||||
|
||||
for (const measure of statisticMeasures) {
|
||||
await this.statisticsStore.incrementMeasure(measure, event.payload.amount, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('PredicateVerificationRequestedEventHandler', () => {
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.warn = jest.fn()
|
||||
logger.info = jest.fn()
|
||||
logger.debug = jest.fn()
|
||||
|
||||
event = {} as jest.Mocked<PredicateVerificationRequestedEvent>
|
||||
event.meta = {
|
||||
|
||||
@@ -23,7 +23,7 @@ export class PredicateVerificationRequestedEventHandler implements DomainEventHa
|
||||
) {}
|
||||
|
||||
async handle(event: PredicateVerificationRequestedEvent): Promise<void> {
|
||||
this.logger.info(`Received verification request of predicate: ${event.payload.predicate.name}`)
|
||||
this.logger.debug(`Received verification request of predicate: ${event.payload.predicate.name}`)
|
||||
|
||||
let userUuid = event.meta.correlation.userIdentifier
|
||||
if (event.meta.correlation.userIdentifierType === 'email') {
|
||||
@@ -55,7 +55,7 @@ export class PredicateVerificationRequestedEventHandler implements DomainEventHa
|
||||
}),
|
||||
)
|
||||
|
||||
this.logger.info(
|
||||
this.logger.debug(
|
||||
`Published predicate verification (${predicateVerificationResult}) result for: ${event.payload.predicate.name}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,20 @@ describe('SubscriptionCancelledEventHandler', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should not track statistics for subscriptions that are in a legacy 5 year plan', async () => {
|
||||
event.payload.timestamp = 1642395451516000
|
||||
|
||||
const userSubscription = {
|
||||
createdAt: 1642395451515000,
|
||||
endsAt: 1642395451515000 + 126_230_400_000_001,
|
||||
} as jest.Mocked<UserSubscription>
|
||||
userSubscriptionRepository.findBySubscriptionId = jest.fn().mockReturnValue([userSubscription])
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update subscription cancelled - user not found', async () => {
|
||||
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
|
||||
|
||||
|
||||
@@ -35,31 +35,10 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
const subscriptions = await this.userSubscriptionRepository.findBySubscriptionId(event.payload.subscriptionId)
|
||||
if (subscriptions.length !== 0) {
|
||||
const lastSubscription = subscriptions.shift() as UserSubscription
|
||||
const subscriptionLength = event.payload.timestamp - lastSubscription.createdAt
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
const lastPurchaseTime = lastSubscription.renewedAt ?? lastSubscription.updatedAt
|
||||
const remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
|
||||
const totalSubscriptionTime = lastSubscription.endsAt - lastPurchaseTime
|
||||
|
||||
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
|
||||
|
||||
await this.statisticsStore.incrementMeasure(
|
||||
StatisticsMeasure.RemainingSubscriptionTimePercentage,
|
||||
remainingSubscriptionPercentage,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await this.trackSubscriptionStatistics(event)
|
||||
|
||||
if (event.payload.offline) {
|
||||
await this.updateOfflineSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
|
||||
|
||||
@@ -76,4 +55,39 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
|
||||
private async updateOfflineSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> {
|
||||
await this.offlineUserSubscriptionRepository.updateCancelled(subscriptionId, true, timestamp)
|
||||
}
|
||||
|
||||
private async trackSubscriptionStatistics(event: SubscriptionCancelledEvent) {
|
||||
const subscriptions = await this.userSubscriptionRepository.findBySubscriptionId(event.payload.subscriptionId)
|
||||
if (subscriptions.length !== 0) {
|
||||
const lastSubscription = subscriptions.shift() as UserSubscription
|
||||
if (this.isLegacy5yearSubscriptionPlan(lastSubscription)) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptionLength = event.payload.timestamp - lastSubscription.createdAt
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
const lastPurchaseTime = lastSubscription.renewedAt ?? lastSubscription.updatedAt
|
||||
const remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
|
||||
const totalSubscriptionTime = lastSubscription.endsAt - lastPurchaseTime
|
||||
|
||||
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
|
||||
|
||||
await this.statisticsStore.incrementMeasure(
|
||||
StatisticsMeasure.RemainingSubscriptionTimePercentage,
|
||||
remainingSubscriptionPercentage,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private isLegacy5yearSubscriptionPlan(subscription: UserSubscription) {
|
||||
const fourYearsInMicroseconds = 126_230_400_000_000
|
||||
|
||||
return subscription.endsAt - subscription.createdAt > fourYearsInMicroseconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscri
|
||||
import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import { UserSubscription } from '../Subscription/UserSubscription'
|
||||
import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
|
||||
describe('SubscriptionExpiredEventHandler', () => {
|
||||
let userRepository: UserRepositoryInterface
|
||||
@@ -23,6 +25,9 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
let user: User
|
||||
let event: SubscriptionExpiredEvent
|
||||
let timestamp: number
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionExpiredEventHandler(
|
||||
@@ -30,6 +35,9 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
userSubscriptionRepository,
|
||||
offlineUserSubscriptionRepository,
|
||||
roleService,
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -50,6 +58,7 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.updateEndsAt = jest.fn()
|
||||
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
|
||||
userSubscriptionRepository.findBySubscriptionId = jest
|
||||
.fn()
|
||||
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
|
||||
@@ -72,6 +81,15 @@ describe('SubscriptionExpiredEventHandler', () => {
|
||||
offline: false,
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
logger.warn = jest.fn()
|
||||
|
||||
@@ -8,6 +8,14 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import {
|
||||
AnalyticsStoreInterface,
|
||||
AnalyticsActivity,
|
||||
Period,
|
||||
StatisticsMeasure,
|
||||
StatisticsStoreInterface,
|
||||
} from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterface {
|
||||
@@ -17,6 +25,9 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
|
||||
@inject(TYPES.OfflineUserSubscriptionRepository)
|
||||
private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface,
|
||||
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +47,21 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
|
||||
|
||||
await this.updateSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp)
|
||||
await this.removeRoleFromSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName)
|
||||
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
|
||||
await this.analyticsStore.markActivity(
|
||||
[AnalyticsActivity.SubscriptionExpired, AnalyticsActivity.ExistingCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
}
|
||||
|
||||
private async removeRoleFromSubscriptionUsers(
|
||||
|
||||
@@ -73,12 +73,14 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.incrementMeasure = jest.fn()
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1)
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(0)
|
||||
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
|
||||
userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription)
|
||||
|
||||
offlineUserSubscription = {} as jest.Mocked<OfflineUserSubscription>
|
||||
@@ -113,6 +115,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.unmarkActivity = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
@@ -132,6 +135,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
SubscriptionName.ProPlan,
|
||||
'123',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
event.payload.subscriptionName,
|
||||
user.uuid,
|
||||
)
|
||||
|
||||
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
|
||||
@@ -84,6 +85,11 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
await this.analyticsStore.unmarkActivity(
|
||||
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
|
||||
const limitedDiscountPurchased = ['limited-10', 'limited-20'].includes(event.payload.discountCode as string)
|
||||
if (limitedDiscountPurchased) {
|
||||
@@ -98,6 +104,19 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
|
||||
event.payload.timestamp - this.timer.convertDateToMicroseconds(user.createdAt),
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
await this.statisticsStore.incrementMeasure(StatisticsMeasure.NewCustomers, 1, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('SubscriptionReassignedEventHandler', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
SubscriptionName.ProPlan,
|
||||
'123',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export class SubscriptionReassignedEventHandler implements DomainEventHandlerInt
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
event.payload.subscriptionName,
|
||||
user.uuid,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import { UserSubscription } from '../Subscription/UserSubscription'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
import { AnalyticsStoreInterface } from '@standardnotes/analytics'
|
||||
import { AnalyticsActivity, AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics'
|
||||
|
||||
describe('SubscriptionRefundedEventHandler', () => {
|
||||
let userRepository: UserRepositoryInterface
|
||||
@@ -27,6 +27,7 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
let timestamp: number
|
||||
let getUserAnalyticsId: GetUserAnalyticsId
|
||||
let analyticsStore: AnalyticsStoreInterface
|
||||
let statisticsStore: StatisticsStoreInterface
|
||||
|
||||
const createHandler = () =>
|
||||
new SubscriptionRefundedEventHandler(
|
||||
@@ -36,6 +37,7 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
roleService,
|
||||
getUserAnalyticsId,
|
||||
analyticsStore,
|
||||
statisticsStore,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -56,6 +58,8 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.updateEndsAt = jest.fn()
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(1)
|
||||
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
|
||||
userSubscriptionRepository.findBySubscriptionId = jest
|
||||
.fn()
|
||||
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
|
||||
@@ -83,6 +87,10 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(true)
|
||||
|
||||
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
|
||||
statisticsStore.setMeasure = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.info = jest.fn()
|
||||
@@ -119,4 +127,33 @@ describe('SubscriptionRefundedEventHandler', () => {
|
||||
expect(roleService.removeUserRole).not.toHaveBeenCalled()
|
||||
expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should mark churn for new customer', async () => {
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).toHaveBeenCalledWith([AnalyticsActivity.NewCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
|
||||
it('should mark churn for existing customer', async () => {
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(3)
|
||||
|
||||
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 () => {
|
||||
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(3)
|
||||
analyticsStore.wasActivityDone = jest.fn().mockReturnValue(false)
|
||||
|
||||
await createHandler().handle(event)
|
||||
|
||||
expect(analyticsStore.markActivity).not.toHaveBeenCalledWith([AnalyticsActivity.ExistingCustomersChurn], 3, [
|
||||
Period.ThisMonth,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName, Uuid } from '@standardnotes/common'
|
||||
import { DomainEventHandlerInterface, SubscriptionRefundedEvent } from '@standardnotes/domain-events'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
@@ -8,7 +8,13 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
|
||||
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
|
||||
import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics'
|
||||
import {
|
||||
AnalyticsActivity,
|
||||
AnalyticsStoreInterface,
|
||||
Period,
|
||||
StatisticsMeasure,
|
||||
StatisticsStoreInterface,
|
||||
} from '@standardnotes/analytics'
|
||||
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
|
||||
|
||||
@injectable()
|
||||
@@ -21,6 +27,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
|
||||
@inject(TYPES.RoleService) private roleService: RoleServiceInterface,
|
||||
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
|
||||
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
|
||||
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
@@ -47,6 +54,8 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
|
||||
await this.markChurnActivity(analyticsId, user.uuid)
|
||||
}
|
||||
|
||||
private async removeRoleFromSubscriptionUsers(
|
||||
@@ -66,4 +75,30 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
|
||||
private async updateOfflineSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise<void> {
|
||||
await this.offlineUserSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp)
|
||||
}
|
||||
|
||||
private async markChurnActivity(analyticsId: number, userUuid: Uuid): Promise<void> {
|
||||
const existingSubscriptionsCount = await this.userSubscriptionRepository.countByUserUuid(userUuid)
|
||||
|
||||
const churnActivity =
|
||||
existingSubscriptionsCount > 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])
|
||||
}
|
||||
}
|
||||
|
||||
const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions()
|
||||
await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
Period.ThisYear,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('SubscriptionRenewedEventHandler', () => {
|
||||
|
||||
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
|
||||
analyticsStore.markActivity = jest.fn()
|
||||
analyticsStore.unmarkActivity = jest.fn()
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.warn = jest.fn()
|
||||
|
||||
@@ -66,6 +66,11 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
|
||||
Period.ThisWeek,
|
||||
Period.ThisMonth,
|
||||
])
|
||||
await this.analyticsStore.unmarkActivity(
|
||||
[AnalyticsActivity.ExistingCustomersChurn, AnalyticsActivity.NewCustomersChurn],
|
||||
analyticsId,
|
||||
[Period.Today, Period.ThisWeek, Period.ThisMonth],
|
||||
)
|
||||
}
|
||||
|
||||
private async addRoleToSubscriptionUsers(subscriptionId: number, subscriptionName: SubscriptionName): Promise<void> {
|
||||
|
||||
@@ -130,6 +130,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
SubscriptionName.ProPlan,
|
||||
'123',
|
||||
)
|
||||
|
||||
expect(settingService.createOrReplace).toHaveBeenCalledWith({
|
||||
|
||||
@@ -89,6 +89,7 @@ export class SubscriptionSyncRequestedEventHandler implements DomainEventHandler
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
event.payload.subscriptionName,
|
||||
user.uuid,
|
||||
)
|
||||
|
||||
await this.settingService.createOrReplace({
|
||||
|
||||
@@ -4,4 +4,5 @@ export type SettingDescription = {
|
||||
value: string
|
||||
sensitive: boolean
|
||||
serverEncryptionVersion: EncryptionVersion
|
||||
replaceable: boolean
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: MuteSignInEmailsOption.NotMuted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -60,6 +61,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: MuteMarketingEmailsOption.NotMuted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -68,6 +70,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: LogSessionUserAgentOption.Enabled,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
@@ -79,6 +82,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: LogSessionUserAgentOption.Disabled,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SubscriptionName } from '@standardnotes/common'
|
||||
import { User } from '../User/User'
|
||||
import { SettingFactoryInterface } from './SettingFactoryInterface'
|
||||
import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
|
||||
describe('SubscriptionSettingService', () => {
|
||||
let setting: SubscriptionSetting
|
||||
@@ -22,6 +23,7 @@ describe('SubscriptionSettingService', () => {
|
||||
let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface
|
||||
let subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface
|
||||
let settingDecrypter: SettingDecrypterInterface
|
||||
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
|
||||
let logger: Logger
|
||||
|
||||
const createService = () =>
|
||||
@@ -30,6 +32,7 @@ describe('SubscriptionSettingService', () => {
|
||||
subscriptionSettingRepository,
|
||||
subscriptionSettingsAssociationService,
|
||||
settingDecrypter,
|
||||
userSubscriptionRepository,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -51,6 +54,16 @@ describe('SubscriptionSettingService', () => {
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
|
||||
subscriptionSettingRepository.save = jest.fn().mockImplementation((setting) => setting)
|
||||
|
||||
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
|
||||
userSubscriptionRepository.findByUserUuid = jest.fn().mockReturnValue([
|
||||
{
|
||||
uuid: 's-1-2-3',
|
||||
} as jest.Mocked<UserSubscription>,
|
||||
{
|
||||
uuid: 's-2-3-4',
|
||||
} as jest.Mocked<UserSubscription>,
|
||||
])
|
||||
|
||||
subscriptionSettingsAssociationService = {} as jest.Mocked<SubscriptionSettingsAssociationServiceInterface>
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
@@ -60,6 +73,7 @@ describe('SubscriptionSettingService', () => {
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
@@ -75,7 +89,91 @@ describe('SubscriptionSettingService', () => {
|
||||
})
|
||||
|
||||
it('should create default settings for a subscription', async () => {
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan)
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
|
||||
})
|
||||
|
||||
it('should reassign existing default settings for a subscription if it is not replaceable', async () => {
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(setting)
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create default settings for a subscription if it is not replaceable and not existing', async () => {
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
|
||||
})
|
||||
|
||||
it('should create default settings for a subscription if it is not replaceable and no previous subscription existed', async () => {
|
||||
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{
|
||||
value: '0',
|
||||
sensitive: 0,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
replaceable: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
|
||||
userSubscriptionRepository.findByUserUuid = jest.fn().mockReturnValue([
|
||||
{
|
||||
uuid: '1-2-3',
|
||||
} as jest.Mocked<UserSubscription>,
|
||||
])
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
|
||||
})
|
||||
@@ -85,7 +183,11 @@ describe('SubscriptionSettingService', () => {
|
||||
.fn()
|
||||
.mockReturnValue(undefined)
|
||||
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan)
|
||||
await createService().applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription,
|
||||
SubscriptionName.PlusPlan,
|
||||
'1-2-3',
|
||||
)
|
||||
|
||||
expect(subscriptionSettingRepository.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName, Uuid } from '@standardnotes/common'
|
||||
import { SubscriptionSettingName } from '@standardnotes/settings'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
@@ -16,6 +16,7 @@ import { FindSubscriptionSettingDTO } from './FindSubscriptionSettingDTO'
|
||||
import { SubscriptionSettingRepositoryInterface } from './SubscriptionSettingRepositoryInterface'
|
||||
import { SettingFactoryInterface } from './SettingFactoryInterface'
|
||||
import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface'
|
||||
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
|
||||
|
||||
@injectable()
|
||||
export class SubscriptionSettingService implements SubscriptionSettingServiceInterface {
|
||||
@@ -26,12 +27,14 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
|
||||
@inject(TYPES.SubscriptionSettingsAssociationService)
|
||||
private subscriptionSettingAssociationService: SubscriptionSettingsAssociationServiceInterface,
|
||||
@inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface,
|
||||
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription: UserSubscription,
|
||||
subscriptionName: SubscriptionName,
|
||||
userUuid: Uuid,
|
||||
): Promise<void> {
|
||||
const defaultSettingsWithValues =
|
||||
await this.subscriptionSettingAssociationService.getDefaultSettingsAndValuesForSubscriptionName(subscriptionName)
|
||||
@@ -43,6 +46,15 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
|
||||
|
||||
for (const settingName of defaultSettingsWithValues.keys()) {
|
||||
const setting = defaultSettingsWithValues.get(settingName) as SettingDescription
|
||||
if (!setting.replaceable) {
|
||||
const existingSetting = await this.findPreviousSubscriptionSetting(settingName, userSubscription.uuid, userUuid)
|
||||
if (existingSetting !== null) {
|
||||
existingSetting.userSubscription = Promise.resolve(userSubscription)
|
||||
await this.subscriptionSettingRepository.save(existingSetting)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await this.createOrReplace({
|
||||
userSubscription,
|
||||
@@ -114,4 +126,22 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
|
||||
subscriptionSetting,
|
||||
}
|
||||
}
|
||||
|
||||
private async findPreviousSubscriptionSetting(
|
||||
settingName: SubscriptionSettingName,
|
||||
currentUserSubscriptionUuid: Uuid,
|
||||
userUuid: Uuid,
|
||||
): Promise<SubscriptionSetting | null> {
|
||||
const userSubscriptions = await this.userSubscriptionRepository.findByUserUuid(userUuid)
|
||||
const previousSubscriptions = userSubscriptions.filter(
|
||||
(subscription) => subscription.uuid !== currentUserSubscriptionUuid,
|
||||
)
|
||||
const lastSubscription = previousSubscriptions.shift()
|
||||
|
||||
if (!lastSubscription) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid(settingName, lastSubscription.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptionName } from '@standardnotes/common'
|
||||
import { SubscriptionName, Uuid } from '@standardnotes/common'
|
||||
import { UserSubscription } from '../Subscription/UserSubscription'
|
||||
|
||||
import { CreateOrReplaceSubscriptionSettingDTO } from './CreateOrReplaceSubscriptionSettingDTO'
|
||||
@@ -10,6 +10,7 @@ export interface SubscriptionSettingServiceInterface {
|
||||
applyDefaultSubscriptionSettingsForSubscription(
|
||||
userSubscription: UserSubscription,
|
||||
subscriptionName: SubscriptionName,
|
||||
userUuid: Uuid,
|
||||
): Promise<void>
|
||||
createOrReplace(dto: CreateOrReplaceSubscriptionSettingDTO): Promise<CreateOrReplaceSubscriptionSettingResponse>
|
||||
findSubscriptionSettingWithDecryptedValue(dto: FindSubscriptionSettingDTO): Promise<SubscriptionSetting | null>
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('SubscriptionSettingsAssociationService', () => {
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: 0,
|
||||
value: '107374182400',
|
||||
replaceable: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +89,7 @@ describe('SubscriptionSettingsAssociationService', () => {
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: 0,
|
||||
value: '104857600',
|
||||
replaceable: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' },
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0', replaceable: false },
|
||||
],
|
||||
]),
|
||||
],
|
||||
@@ -37,7 +37,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
|
||||
new Map([
|
||||
[
|
||||
SubscriptionSettingName.FileUploadBytesUsed,
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' },
|
||||
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0', replaceable: false },
|
||||
],
|
||||
]),
|
||||
],
|
||||
@@ -56,6 +56,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
|
||||
sensitive: false,
|
||||
serverEncryptionVersion: EncryptionVersion.Unencrypted,
|
||||
value: (await this.getFileUploadLimit(subscriptionName)).toString(),
|
||||
replaceable: true,
|
||||
})
|
||||
|
||||
return defaultSettings
|
||||
|
||||
@@ -3,6 +3,6 @@ import { Uuid } from '@standardnotes/common'
|
||||
import { SubscriptionToken } from './SubscriptionToken'
|
||||
|
||||
export interface SubscriptionTokenRepositoryInterface {
|
||||
save(subscriptionToken: SubscriptionToken): Promise<void>
|
||||
save(subscriptionToken: SubscriptionToken): Promise<boolean>
|
||||
getUserUuidByToken(token: string): Promise<Uuid | undefined>
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ export interface UserSubscriptionRepositoryInterface {
|
||||
findOneByUuid(uuid: Uuid): Promise<UserSubscription | null>
|
||||
countByUserUuid(userUuid: Uuid): Promise<number>
|
||||
findOneByUserUuid(userUuid: Uuid): Promise<UserSubscription | null>
|
||||
findByUserUuid(userUuid: Uuid): Promise<UserSubscription[]>
|
||||
findOneByUserUuidAndSubscriptionId(userUuid: Uuid, subscriptionId: number): Promise<UserSubscription | null>
|
||||
findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]>
|
||||
findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]>
|
||||
updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise<void>
|
||||
updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise<void>
|
||||
countActiveSubscriptions(): Promise<number>
|
||||
save(subscription: UserSubscription): Promise<UserSubscription>
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
|
||||
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
|
||||
inviteeSubscription,
|
||||
'PLUS_PLAN',
|
||||
'123',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export class AcceptSharedSubscriptionInvitation implements UseCaseInterface {
|
||||
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
|
||||
inviteeSubscription,
|
||||
inviteeSubscription.planName as SubscriptionName,
|
||||
invitee.uuid,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,17 +5,19 @@ import { TimerInterface } from '@standardnotes/time'
|
||||
import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface'
|
||||
|
||||
import { CreateSubscriptionToken } from './CreateSubscriptionToken'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
describe('CreateSubscriptionToken', () => {
|
||||
let subscriptionTokenRepository: SubscriptionTokenRepositoryInterface
|
||||
let cryptoNode: CryptoNode
|
||||
let timer: TimerInterface
|
||||
let logger: Logger
|
||||
|
||||
const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer)
|
||||
const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer, logger)
|
||||
|
||||
beforeEach(() => {
|
||||
subscriptionTokenRepository = {} as jest.Mocked<SubscriptionTokenRepositoryInterface>
|
||||
subscriptionTokenRepository.save = jest.fn()
|
||||
subscriptionTokenRepository.save = jest.fn().mockReturnValue(true)
|
||||
|
||||
cryptoNode = {} as jest.Mocked<CryptoNode>
|
||||
cryptoNode.generateRandomKey = jest.fn().mockReturnValueOnce('random-string')
|
||||
@@ -23,6 +25,9 @@ describe('CreateSubscriptionToken', () => {
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1)
|
||||
timer.getUTCDateNHoursAhead = jest.fn().mockReturnValue(new Date(1))
|
||||
|
||||
logger = {} as jest.Mocked<Logger>
|
||||
logger.error = jest.fn()
|
||||
})
|
||||
|
||||
it('should create an subscription token and persist it', async () => {
|
||||
@@ -36,4 +41,19 @@ describe('CreateSubscriptionToken', () => {
|
||||
expiresAt: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the subscription token was not created', async () => {
|
||||
subscriptionTokenRepository.save = jest.fn().mockReturnValue(false)
|
||||
|
||||
let caughtError = null
|
||||
try {
|
||||
await createUseCase().execute({
|
||||
userUuid: '1-2-3',
|
||||
})
|
||||
} catch (error) {
|
||||
caughtError = error
|
||||
}
|
||||
|
||||
expect(caughtError).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CryptoNode } from '@standardnotes/sncrypto-node'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Logger } from 'winston'
|
||||
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface'
|
||||
@@ -15,6 +16,7 @@ export class CreateSubscriptionToken implements UseCaseInterface {
|
||||
private subscriptionTokenRepository: SubscriptionTokenRepositoryInterface,
|
||||
@inject(TYPES.CryptoNode) private cryptoNode: CryptoNode,
|
||||
@inject(TYPES.Timer) private timer: TimerInterface,
|
||||
@inject(TYPES.Logger) private logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(dto: CreateSubscriptionTokenDTO): Promise<CreateSubscriptionTokenResponse> {
|
||||
@@ -26,7 +28,13 @@ export class CreateSubscriptionToken implements UseCaseInterface {
|
||||
expiresAt: this.timer.convertStringDateToMicroseconds(this.timer.getUTCDateNHoursAhead(3).toString()),
|
||||
}
|
||||
|
||||
await this.subscriptionTokenRepository.save(subscriptionToken)
|
||||
const subscriptionTokenWasSaved = await this.subscriptionTokenRepository.save(subscriptionToken)
|
||||
|
||||
if (!subscriptionTokenWasSaved) {
|
||||
this.logger.error(`Could not create subscription token for user ${dto.userUuid}`)
|
||||
|
||||
throw new Error('Could not create subscription token')
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptionToken,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { UserSubscription } from '../../Subscription/UserSubscription'
|
||||
import { RoleName } from '@standardnotes/common'
|
||||
import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
|
||||
import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation'
|
||||
import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
|
||||
|
||||
describe('InviteToSharedSubscription', () => {
|
||||
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
|
||||
@@ -187,7 +188,7 @@ describe('InviteToSharedSubscription', () => {
|
||||
it('should not create an invitation if it already exists', async () => {
|
||||
sharedSubscriptionInvitationRepository.findOneByInviteeAndInviterEmail = jest
|
||||
.fn()
|
||||
.mockReturnValue({} as jest.Mocked<SharedSubscriptionInvitation>)
|
||||
.mockReturnValue({ status: InvitationStatus.Sent } as jest.Mocked<SharedSubscriptionInvitation>)
|
||||
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
@@ -205,4 +206,27 @@ describe('InviteToSharedSubscription', () => {
|
||||
expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled()
|
||||
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create an invitation if it already exists but was canceled', async () => {
|
||||
sharedSubscriptionInvitationRepository.findOneByInviteeAndInviterEmail = jest
|
||||
.fn()
|
||||
.mockReturnValue({ status: InvitationStatus.Canceled } as jest.Mocked<SharedSubscriptionInvitation>)
|
||||
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
inviteeIdentifier: 'invitee@test.te',
|
||||
inviterUuid: '1-2-3',
|
||||
inviterEmail: 'inviter@test.te',
|
||||
inviterRoles: [RoleName.ProUser],
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
sharedSubscriptionInvitationUuid: '1-2-3',
|
||||
})
|
||||
|
||||
expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalled()
|
||||
|
||||
expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).toHaveBeenCalled()
|
||||
expect(domainEventPublisher.publish).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ export class InviteToSharedSubscription implements UseCaseInterface {
|
||||
dto.inviteeIdentifier,
|
||||
dto.inviterEmail,
|
||||
)
|
||||
if (existingInvitation !== null) {
|
||||
if (existingInvitation !== null && existingInvitation.status !== InvitationStatus.Canceled) {
|
||||
return {
|
||||
success: false,
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'reflect-metadata'
|
||||
import { Setting } from '../../Setting/Setting'
|
||||
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
|
||||
|
||||
import { MuteFailedBackupsEmails } from './MuteFailedBackupsEmails'
|
||||
|
||||
describe('MuteFailedBackupsEmails', () => {
|
||||
let settingRepository: SettingRepositoryInterface
|
||||
|
||||
const createUseCase = () => new MuteFailedBackupsEmails(settingRepository)
|
||||
|
||||
beforeEach(() => {
|
||||
const setting = {} as jest.Mocked<Setting>
|
||||
|
||||
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
|
||||
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting)
|
||||
settingRepository.save = jest.fn()
|
||||
})
|
||||
|
||||
it('should not succeed if extension setting is not found', async () => {
|
||||
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null)
|
||||
|
||||
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
|
||||
success: false,
|
||||
message: 'Could not find setting setting.',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update mute email setting on extension setting', async () => {
|
||||
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
|
||||
success: true,
|
||||
message: 'These emails have been muted.',
|
||||
})
|
||||
|
||||
expect(settingRepository.save).toHaveBeenCalledWith({
|
||||
value: 'muted',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { MuteFailedBackupsEmailsOption, SettingName } from '@standardnotes/settings'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
|
||||
import { UseCaseInterface } from '../UseCaseInterface'
|
||||
import { MuteFailedBackupsEmailsDTO } from './MuteFailedBackupsEmailsDTO'
|
||||
import { MuteFailedBackupsEmailsResponse } from './MuteFailedBackupsEmailsResponse'
|
||||
|
||||
@injectable()
|
||||
export class MuteFailedBackupsEmails implements UseCaseInterface {
|
||||
constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {}
|
||||
|
||||
async execute(dto: MuteFailedBackupsEmailsDTO): Promise<MuteFailedBackupsEmailsResponse> {
|
||||
const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [
|
||||
SettingName.MuteFailedBackupsEmails,
|
||||
SettingName.MuteFailedCloudBackupsEmails,
|
||||
])
|
||||
|
||||
if (setting === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not find setting setting.',
|
||||
}
|
||||
}
|
||||
|
||||
setting.value = MuteFailedBackupsEmailsOption.Muted
|
||||
|
||||
await this.settingRepository.save(setting)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'These emails have been muted.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export type MuteFailedBackupsEmailsDTO = {
|
||||
settingUuid: string
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export type MuteFailedBackupsEmailsResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'reflect-metadata'
|
||||
import { Setting } from '../../Setting/Setting'
|
||||
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
|
||||
|
||||
import { MuteMarketingEmails } from './MuteMarketingEmails'
|
||||
|
||||
describe('MuteMarketingEmails', () => {
|
||||
let settingRepository: SettingRepositoryInterface
|
||||
|
||||
const createUseCase = () => new MuteMarketingEmails(settingRepository)
|
||||
|
||||
beforeEach(() => {
|
||||
const setting = {} as jest.Mocked<Setting>
|
||||
|
||||
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
|
||||
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting)
|
||||
settingRepository.save = jest.fn()
|
||||
})
|
||||
|
||||
it('should not succeed if extension setting is not found', async () => {
|
||||
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null)
|
||||
|
||||
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
|
||||
success: false,
|
||||
message: 'Could not find setting setting.',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update mute email setting on extension setting', async () => {
|
||||
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
|
||||
success: true,
|
||||
message: 'These emails have been muted.',
|
||||
})
|
||||
|
||||
expect(settingRepository.save).toHaveBeenCalledWith({
|
||||
value: 'muted',
|
||||
serverEncryptionVersion: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { MuteMarketingEmailsOption, SettingName } from '@standardnotes/settings'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { EncryptionVersion } from '../../Encryption/EncryptionVersion'
|
||||
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
|
||||
import { UseCaseInterface } from '../UseCaseInterface'
|
||||
import { MuteMarketingEmailsDTO } from './MuteMarketingEmailsDTO'
|
||||
import { MuteMarketingEmailsResponse } from './MuteMarketingEmailsResponse'
|
||||
|
||||
@injectable()
|
||||
export class MuteMarketingEmails implements UseCaseInterface {
|
||||
constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {}
|
||||
|
||||
async execute(dto: MuteMarketingEmailsDTO): Promise<MuteMarketingEmailsResponse> {
|
||||
const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [
|
||||
SettingName.MuteMarketingEmails,
|
||||
])
|
||||
|
||||
if (setting === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not find setting setting.',
|
||||
}
|
||||
}
|
||||
|
||||
setting.value = MuteMarketingEmailsOption.Muted
|
||||
setting.serverEncryptionVersion = EncryptionVersion.Unencrypted
|
||||
|
||||
await this.settingRepository.save(setting)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'These emails have been muted.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export type MuteMarketingEmailsDTO = {
|
||||
settingUuid: string
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export type MuteMarketingEmailsResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'reflect-metadata'
|
||||
import { Setting } from '../../Setting/Setting'
|
||||
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
|
||||
|
||||
import { MuteSignInEmails } from './MuteSignInEmails'
|
||||
|
||||
describe('MuteSignInEmails', () => {
|
||||
let settingRepository: SettingRepositoryInterface
|
||||
|
||||
const createUseCase = () => new MuteSignInEmails(settingRepository)
|
||||
|
||||
beforeEach(() => {
|
||||
const setting = {} as jest.Mocked<Setting>
|
||||
|
||||
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
|
||||
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting)
|
||||
settingRepository.save = jest.fn()
|
||||
})
|
||||
|
||||
it('should not succeed if extension setting is not found', async () => {
|
||||
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null)
|
||||
|
||||
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
|
||||
success: false,
|
||||
message: 'Could not find setting setting.',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update mute email setting on extension setting', async () => {
|
||||
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
|
||||
success: true,
|
||||
message: 'These emails have been muted.',
|
||||
})
|
||||
|
||||
expect(settingRepository.save).toHaveBeenCalledWith({
|
||||
value: 'muted',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { MuteSignInEmailsOption, SettingName } from '@standardnotes/settings'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import TYPES from '../../../Bootstrap/Types'
|
||||
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
|
||||
import { UseCaseInterface } from '../UseCaseInterface'
|
||||
import { MuteSignInEmailsDTO } from './MuteSignInEmailsDTO'
|
||||
import { MuteSignInEmailsResponse } from './MuteSignInEmailsResponse'
|
||||
|
||||
@injectable()
|
||||
export class MuteSignInEmails implements UseCaseInterface {
|
||||
constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {}
|
||||
|
||||
async execute(dto: MuteSignInEmailsDTO): Promise<MuteSignInEmailsResponse> {
|
||||
const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [SettingName.MuteSignInEmails])
|
||||
|
||||
if (setting === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not find setting setting.',
|
||||
}
|
||||
}
|
||||
|
||||
setting.value = MuteSignInEmailsOption.Muted
|
||||
|
||||
await this.settingRepository.save(setting)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'These emails have been muted.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export type MuteSignInEmailsDTO = {
|
||||
settingUuid: string
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export type MuteSignInEmailsResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Setting } from '../Setting/Setting'
|
||||
import { MuteSignInEmailsOption } from '@standardnotes/settings'
|
||||
import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface'
|
||||
import { CrypterInterface } from '../Encryption/CrypterInterface'
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
|
||||
describe('SignIn', () => {
|
||||
let user: User
|
||||
@@ -50,6 +51,7 @@ describe('SignIn', () => {
|
||||
user = {
|
||||
uuid: '1-2-3',
|
||||
email: 'test@test.com',
|
||||
version: ProtocolVersion.V004,
|
||||
} as jest.Mocked<User>
|
||||
user.encryptedPassword = '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a'
|
||||
|
||||
@@ -99,7 +101,10 @@ describe('SignIn', () => {
|
||||
logger.error = jest.fn()
|
||||
})
|
||||
|
||||
it('should sign in a user', async () => {
|
||||
it('should sign in a legacy user without code verifier', async () => {
|
||||
user.version = ProtocolVersion.V003
|
||||
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
|
||||
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
email: 'test@test.te',
|
||||
@@ -124,6 +129,22 @@ describe('SignIn', () => {
|
||||
expect(domainEventPublisher.publish).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not sign in a user without code verifier', async () => {
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
email: 'test@test.te',
|
||||
password: 'qweqwe123123',
|
||||
userAgent: 'Google Chrome',
|
||||
apiVersion: '20190520',
|
||||
ephemeralSession: false,
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errorCode: 410,
|
||||
errorMessage: 'Please update your client application.',
|
||||
})
|
||||
})
|
||||
|
||||
it('should sign in a user with valid code verifier', async () => {
|
||||
expect(
|
||||
await createUseCase().execute({
|
||||
@@ -165,6 +186,7 @@ describe('SignIn', () => {
|
||||
userAgent: 'Google Chrome',
|
||||
apiVersion: '20190520',
|
||||
ephemeralSession: false,
|
||||
codeVerifier: 'test',
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
@@ -192,6 +214,7 @@ describe('SignIn', () => {
|
||||
userAgent: 'Google Chrome',
|
||||
apiVersion: '20190520',
|
||||
ephemeralSession: false,
|
||||
codeVerifier: 'test',
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
@@ -218,6 +241,7 @@ describe('SignIn', () => {
|
||||
email: 'test@test.com',
|
||||
encryptedPassword: '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a',
|
||||
uuid: '1-2-3',
|
||||
version: '004',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -234,6 +258,7 @@ describe('SignIn', () => {
|
||||
userAgent: 'Google Chrome',
|
||||
apiVersion: '20190520',
|
||||
ephemeralSession: false,
|
||||
codeVerifier: 'test',
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
@@ -249,6 +274,7 @@ describe('SignIn', () => {
|
||||
userAgent: 'Google Chrome',
|
||||
apiVersion: '20190520',
|
||||
ephemeralSession: false,
|
||||
codeVerifier: 'test',
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
@@ -284,6 +310,7 @@ describe('SignIn', () => {
|
||||
userAgent: 'Google Chrome',
|
||||
apiVersion: '20190520',
|
||||
ephemeralSession: false,
|
||||
codeVerifier: 'test',
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
|
||||
@@ -21,6 +21,8 @@ import { UseCaseInterface } from './UseCaseInterface'
|
||||
import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface'
|
||||
import { CrypterInterface } from '../Encryption/CrypterInterface'
|
||||
import { SignInDTOV2Challenged } from './SignInDTOV2Challenged'
|
||||
import { ProtocolVersion } from '@standardnotes/common'
|
||||
import { HttpStatusCode } from '@standardnotes/api'
|
||||
|
||||
@injectable()
|
||||
export class SignIn implements UseCaseInterface {
|
||||
@@ -39,7 +41,8 @@ export class SignIn implements UseCaseInterface {
|
||||
) {}
|
||||
|
||||
async execute(dto: SignInDTO): Promise<SignInResponse> {
|
||||
if (this.isCodeChallengedVersion(dto)) {
|
||||
const performingCodeChallengedSignIn = this.isCodeChallengedVersion(dto)
|
||||
if (performingCodeChallengedSignIn) {
|
||||
const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier)
|
||||
if (!validCodeVerifier) {
|
||||
this.logger.debug('Code verifier does not match')
|
||||
@@ -62,6 +65,14 @@ export class SignIn implements UseCaseInterface {
|
||||
}
|
||||
}
|
||||
|
||||
if (user.version === ProtocolVersion.V004 && !performingCodeChallengedSignIn) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: 'Please update your client application.',
|
||||
errorCode: HttpStatusCode.Gone,
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword)
|
||||
if (!passwordMatches) {
|
||||
this.logger.debug('Password does not match')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { HttpStatusCode } from '@standardnotes/api'
|
||||
|
||||
import { AuthResponse20161215 } from '../Auth/AuthResponse20161215'
|
||||
import { AuthResponse20200115 } from '../Auth/AuthResponse20200115'
|
||||
|
||||
@@ -5,4 +7,5 @@ export type SignInResponse = {
|
||||
success: boolean
|
||||
authResponse?: AuthResponse20161215 | AuthResponse20200115
|
||||
errorMessage?: string
|
||||
errorCode?: HttpStatusCode
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export class InversifyExpressAuthController extends BaseHttpController {
|
||||
message: signInResult.errorMessage,
|
||||
},
|
||||
},
|
||||
401,
|
||||
signInResult.errorCode ?? 401,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,35 +29,13 @@ export class InversifyExpressWebSocketsController extends BaseHttpController {
|
||||
super()
|
||||
}
|
||||
|
||||
@httpPost('/:connectionId', TYPES.ApiGatewayAuthMiddleware)
|
||||
async storeWebSocketsConnection(
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
|
||||
await this.addWebSocketsConnection.execute({
|
||||
userUuid: response.locals.user.uuid,
|
||||
connectionId: request.params.connectionId,
|
||||
})
|
||||
|
||||
return this.json({ success: true })
|
||||
}
|
||||
|
||||
@httpDelete('/:connectionId')
|
||||
async deleteWebSocketsConnection(
|
||||
request: Request,
|
||||
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
|
||||
await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId })
|
||||
|
||||
return this.json({ success: true })
|
||||
}
|
||||
|
||||
@httpPost('/tokens', TYPES.ApiGatewayAuthMiddleware)
|
||||
async createConnectionToken(_request: Request, response: Response): Promise<results.JsonResult> {
|
||||
const result = await this.webSocketsController.createConnectionToken({
|
||||
userUuid: response.locals.user.uuid,
|
||||
})
|
||||
|
||||
return this.json(result)
|
||||
return this.json(result.data, result.status)
|
||||
}
|
||||
|
||||
@httpPost('/tokens/validate')
|
||||
@@ -94,4 +72,26 @@ export class InversifyExpressWebSocketsController extends BaseHttpController {
|
||||
|
||||
return this.json({ authToken: result.token })
|
||||
}
|
||||
|
||||
@httpPost('/connections/:connectionId', TYPES.ApiGatewayAuthMiddleware)
|
||||
async storeWebSocketsConnection(
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
|
||||
await this.addWebSocketsConnection.execute({
|
||||
userUuid: response.locals.user.uuid,
|
||||
connectionId: request.params.connectionId,
|
||||
})
|
||||
|
||||
return this.json({ success: true })
|
||||
}
|
||||
|
||||
@httpDelete('/connections/:connectionId')
|
||||
async deleteWebSocketsConnection(
|
||||
request: Request,
|
||||
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
|
||||
await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId })
|
||||
|
||||
return this.json({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@ import { UserSubscription } from '../../Domain/Subscription/UserSubscription'
|
||||
|
||||
import { MySQLUserSubscriptionRepository } from './MySQLUserSubscriptionRepository'
|
||||
import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
|
||||
describe('MySQLUserSubscriptionRepository', () => {
|
||||
let ormRepository: Repository<UserSubscription>
|
||||
let selectQueryBuilder: SelectQueryBuilder<UserSubscription>
|
||||
let updateQueryBuilder: UpdateQueryBuilder<UserSubscription>
|
||||
let subscription: UserSubscription
|
||||
let timer: TimerInterface
|
||||
|
||||
const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository)
|
||||
const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository, timer)
|
||||
|
||||
beforeEach(() => {
|
||||
selectQueryBuilder = {} as jest.Mocked<SelectQueryBuilder<UserSubscription>>
|
||||
@@ -28,6 +30,9 @@ describe('MySQLUserSubscriptionRepository', () => {
|
||||
ormRepository = {} as jest.Mocked<Repository<UserSubscription>>
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
|
||||
ormRepository.save = jest.fn()
|
||||
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
|
||||
})
|
||||
|
||||
it('should save', async () => {
|
||||
@@ -36,6 +41,45 @@ describe('MySQLUserSubscriptionRepository', () => {
|
||||
expect(ormRepository.save).toHaveBeenCalledWith(subscription)
|
||||
})
|
||||
|
||||
it('should find all subscriptions by user uuid', async () => {
|
||||
const canceledSubscription = {
|
||||
planName: SubscriptionName.ProPlan,
|
||||
cancelled: true,
|
||||
} as jest.Mocked<UserSubscription>
|
||||
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
|
||||
|
||||
selectQueryBuilder.where = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.orderBy = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.getMany = jest.fn().mockReturnValue([canceledSubscription, subscription])
|
||||
|
||||
const result = await createRepository().findByUserUuid('123')
|
||||
|
||||
expect(selectQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid', {
|
||||
user_uuid: '123',
|
||||
})
|
||||
expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC')
|
||||
expect(selectQueryBuilder.getMany).toHaveBeenCalled()
|
||||
expect(result).toEqual([canceledSubscription, subscription])
|
||||
})
|
||||
|
||||
it('should count all active subscriptions', async () => {
|
||||
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
|
||||
|
||||
selectQueryBuilder.groupBy = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.where = jest.fn().mockReturnThis()
|
||||
selectQueryBuilder.getCount = jest.fn().mockReturnValue(2)
|
||||
|
||||
const result = await createRepository().countActiveSubscriptions()
|
||||
|
||||
expect(selectQueryBuilder.where).toHaveBeenCalledWith('ends_at > :timestamp', {
|
||||
timestamp: 123,
|
||||
})
|
||||
expect(selectQueryBuilder.groupBy).toHaveBeenCalledWith('user_uuid')
|
||||
expect(selectQueryBuilder.getCount).toHaveBeenCalled()
|
||||
expect(result).toEqual(2)
|
||||
})
|
||||
|
||||
it('should find one longest lasting uncanceled subscription by user uuid if there are canceled ones', async () => {
|
||||
const canceledSubscription = {
|
||||
planName: SubscriptionName.ProPlan,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { TimerInterface } from '@standardnotes/time'
|
||||
import { inject, injectable } from 'inversify'
|
||||
import { Repository } from 'typeorm'
|
||||
import TYPES from '../../Bootstrap/Types'
|
||||
@@ -12,8 +13,27 @@ export class MySQLUserSubscriptionRepository implements UserSubscriptionReposito
|
||||
constructor(
|
||||
@inject(TYPES.ORMUserSubscriptionRepository)
|
||||
private ormRepository: Repository<UserSubscription>,
|
||||
@inject(TYPES.Timer) private timer: TimerInterface,
|
||||
) {}
|
||||
|
||||
async countActiveSubscriptions(): Promise<number> {
|
||||
return await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
.where('ends_at > :timestamp', { timestamp: this.timer.getTimestampInMicroseconds() })
|
||||
.groupBy('user_uuid')
|
||||
.getCount()
|
||||
}
|
||||
|
||||
async findByUserUuid(userUuid: string): Promise<UserSubscription[]> {
|
||||
return await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
.where('user_uuid = :user_uuid', {
|
||||
user_uuid: userUuid,
|
||||
})
|
||||
.orderBy('ends_at', 'DESC')
|
||||
.getMany()
|
||||
}
|
||||
|
||||
async countByUserUuid(userUuid: Uuid): Promise<number> {
|
||||
return await this.ormRepository
|
||||
.createQueryBuilder()
|
||||
|
||||
@@ -15,7 +15,6 @@ describe('LockRepository', () => {
|
||||
redisClient.expire = jest.fn()
|
||||
redisClient.del = jest.fn()
|
||||
redisClient.get = jest.fn()
|
||||
redisClient.set = jest.fn()
|
||||
redisClient.setex = jest.fn()
|
||||
})
|
||||
|
||||
@@ -88,6 +87,6 @@ describe('LockRepository', () => {
|
||||
it('should update a lock counter', async () => {
|
||||
await createRepository().updateLockCounter('123', 3)
|
||||
|
||||
expect(redisClient.set).toHaveBeenCalledWith('lock:123', 3)
|
||||
expect(redisClient.setex).toHaveBeenCalledWith('lock:123', 120, 3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ export class LockRepository implements LockRepositoryInterface {
|
||||
}
|
||||
|
||||
async updateLockCounter(userIdentifier: string, counter: number): Promise<void> {
|
||||
await this.redisClient.set(`${this.PREFIX}:${userIdentifier}`, counter)
|
||||
await this.redisClient.setex(`${this.PREFIX}:${userIdentifier}`, this.failedLoginLockout, counter)
|
||||
}
|
||||
|
||||
async getLockCounter(userIdentifier: string): Promise<number> {
|
||||
|
||||
@@ -14,9 +14,9 @@ describe('RedisSubscriptionTokenRepository', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
redisClient = {} as jest.Mocked<IORedis.Redis>
|
||||
redisClient.set = jest.fn()
|
||||
redisClient.set = jest.fn().mockReturnValue('OK')
|
||||
redisClient.get = jest.fn()
|
||||
redisClient.expireat = jest.fn()
|
||||
redisClient.expireat = jest.fn().mockReturnValue(1)
|
||||
|
||||
timer = {} as jest.Mocked<TimerInterface>
|
||||
timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(1)
|
||||
@@ -45,7 +45,23 @@ describe('RedisSubscriptionTokenRepository', () => {
|
||||
expiresAt: 123,
|
||||
}
|
||||
|
||||
await createRepository().save(subscriptionToken)
|
||||
expect(await createRepository().save(subscriptionToken)).toBeTruthy()
|
||||
|
||||
expect(redisClient.set).toHaveBeenCalledWith('subscription-token:random-string', '1-2-3')
|
||||
|
||||
expect(redisClient.expireat).toHaveBeenCalledWith('subscription-token:random-string', 1)
|
||||
})
|
||||
|
||||
it('should indicate subscription token was not saved', async () => {
|
||||
redisClient.set = jest.fn().mockReturnValue(null)
|
||||
|
||||
const subscriptionToken: SubscriptionToken = {
|
||||
userUuid: '1-2-3',
|
||||
token: 'random-string',
|
||||
expiresAt: 123,
|
||||
}
|
||||
|
||||
expect(await createRepository().save(subscriptionToken)).toBeFalsy()
|
||||
|
||||
expect(redisClient.set).toHaveBeenCalledWith('subscription-token:random-string', '1-2-3')
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ export class RedisSubscriptionTokenRepository implements SubscriptionTokenReposi
|
||||
return userUuid
|
||||
}
|
||||
|
||||
async save(subscriptionToken: SubscriptionToken): Promise<void> {
|
||||
async save(subscriptionToken: SubscriptionToken): Promise<boolean> {
|
||||
const key = `${this.PREFIX}:${subscriptionToken.token}`
|
||||
const expiresAtTimestampInSeconds = this.timer.convertMicrosecondsToSeconds(subscriptionToken.expiresAt)
|
||||
|
||||
await this.redisClient.set(key, subscriptionToken.userUuid)
|
||||
await this.redisClient.expireat(key, expiresAtTimestampInSeconds)
|
||||
const wasSet = await this.redisClient.set(key, subscriptionToken.userUuid)
|
||||
const timeoutWasSet = await this.redisClient.expireat(key, expiresAtTimestampInSeconds)
|
||||
|
||||
return wasSet === 'OK' && timeoutWasSet !== 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,28 +17,31 @@ describe('SettingProjector', () => {
|
||||
serverEncryptionVersion: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sensitive: false,
|
||||
} as jest.Mocked<Setting>
|
||||
})
|
||||
|
||||
it('should create a simple projection of a setting', async () => {
|
||||
const projection = await createProjector().projectSimple(setting)
|
||||
expect(projection).toEqual({
|
||||
expect(projection).toStrictEqual({
|
||||
uuid: 'setting-uuid',
|
||||
name: 'setting-name',
|
||||
value: 'setting-value',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sensitive: false,
|
||||
})
|
||||
})
|
||||
it('should create a simple projection of list of settings', async () => {
|
||||
const projection = await createProjector().projectManySimple([setting])
|
||||
expect(projection).toEqual([
|
||||
expect(projection).toStrictEqual([
|
||||
{
|
||||
uuid: 'setting-uuid',
|
||||
name: 'setting-name',
|
||||
value: 'setting-value',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sensitive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -6,16 +6,16 @@ import { SimpleSetting } from '../Domain/Setting/SimpleSetting'
|
||||
@injectable()
|
||||
export class SettingProjector {
|
||||
async projectSimple(setting: Setting): Promise<SimpleSetting> {
|
||||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
user,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
serverEncryptionVersion,
|
||||
...rest
|
||||
} = setting
|
||||
|
||||
return rest
|
||||
return {
|
||||
uuid: setting.uuid,
|
||||
name: setting.name,
|
||||
value: setting.value,
|
||||
createdAt: setting.createdAt,
|
||||
updatedAt: setting.updatedAt,
|
||||
sensitive: setting.sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
async projectManySimple(settings: Setting[]): Promise<SimpleSetting[]> {
|
||||
return Promise.all(
|
||||
settings.map(async (setting) => {
|
||||
|
||||
@@ -17,28 +17,31 @@ describe('SubscriptionSettingProjector', () => {
|
||||
serverEncryptionVersion: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sensitive: false,
|
||||
} as jest.Mocked<SubscriptionSetting>
|
||||
})
|
||||
|
||||
it('should create a simple projection of a setting', async () => {
|
||||
const projection = await createProjector().projectSimple(setting)
|
||||
expect(projection).toEqual({
|
||||
expect(projection).toStrictEqual({
|
||||
uuid: 'setting-uuid',
|
||||
name: 'setting-name',
|
||||
value: 'setting-value',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sensitive: false,
|
||||
})
|
||||
})
|
||||
it('should create a simple projection of list of settings', async () => {
|
||||
const projection = await createProjector().projectManySimple([setting])
|
||||
expect(projection).toEqual([
|
||||
expect(projection).toStrictEqual([
|
||||
{
|
||||
uuid: 'setting-uuid',
|
||||
name: 'setting-name',
|
||||
value: 'setting-value',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
sensitive: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -6,16 +6,16 @@ import { SubscriptionSetting } from '../Domain/Setting/SubscriptionSetting'
|
||||
@injectable()
|
||||
export class SubscriptionSettingProjector {
|
||||
async projectSimple(setting: SubscriptionSetting): Promise<SimpleSubscriptionSetting> {
|
||||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
userSubscription,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
serverEncryptionVersion,
|
||||
...rest
|
||||
} = setting
|
||||
|
||||
return rest
|
||||
return {
|
||||
uuid: setting.uuid,
|
||||
name: setting.name,
|
||||
value: setting.value,
|
||||
createdAt: setting.createdAt,
|
||||
updatedAt: setting.updatedAt,
|
||||
sensitive: setting.sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
async projectManySimple(settings: SubscriptionSetting[]): Promise<SimpleSubscriptionSetting[]> {
|
||||
return Promise.all(
|
||||
settings.map(async (setting) => {
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.35.0](https://github.com/standardnotes/server/compare/@standardnotes/common@1.34.0...@standardnotes/common@1.35.0) (2022-10-04)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add detailed income stats ([8668fec](https://github.com/standardnotes/server/commit/8668fec33dac1598bdc4d6ca869c296ed6eaa617))
|
||||
|
||||
# [1.34.0](https://github.com/standardnotes/server/compare/@standardnotes/common@1.33.0...@standardnotes/common@1.34.0) (2022-10-04)
|
||||
|
||||
### Features
|
||||
|
||||
* **common:** add subscription billing frequency ([3c40ee4](https://github.com/standardnotes/server/commit/3c40ee4b4a33dffc35da148a0fa1582c08619733))
|
||||
|
||||
# [1.33.0](https://github.com/standardnotes/server/compare/@standardnotes/common@1.32.0...@standardnotes/common@1.33.0) (2022-09-19)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/common",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
5
packages/common/src/Domain/Payment/PaymentType.ts
Normal file
5
packages/common/src/Domain/Payment/PaymentType.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/* istanbul ignore file */
|
||||
export enum PaymentType {
|
||||
Initial = 'initial',
|
||||
Renewal = 'renewal',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* istanbul ignore file */
|
||||
export enum SubscriptionBillingFrequency {
|
||||
Monthly = 1,
|
||||
Annual = 12,
|
||||
}
|
||||
@@ -14,9 +14,11 @@ export * from './KeyParams/KeyParamsContent002'
|
||||
export * from './KeyParams/KeyParamsContent003'
|
||||
export * from './KeyParams/KeyParamsContent004'
|
||||
export * from './KeyParams/KeyParamsOrigination'
|
||||
export * from './Payment/PaymentType'
|
||||
export * from './Protocol/ProtocolVersion'
|
||||
export * from './Role/PaidRoles'
|
||||
export * from './Role/RoleName'
|
||||
export * from './Subscription/SubscriptionBillingFrequency'
|
||||
export * from './Subscription/SubscriptionName'
|
||||
export * from './Type/Either'
|
||||
export * from './Type/Only'
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.8.16](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.15...@standardnotes/domain-events-infra@1.8.16) (2022-10-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [1.8.15](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.14...@standardnotes/domain-events-infra@1.8.15) (2022-10-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [1.8.14](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.13...@standardnotes/domain-events-infra@1.8.14) (2022-10-03)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [1.8.13](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.12...@standardnotes/domain-events-infra@1.8.13) (2022-09-28)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
## [1.8.12](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.11...@standardnotes/domain-events-infra@1.8.12) (2022-09-21)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/domain-events-infra
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/domain-events-infra",
|
||||
"version": "1.8.12",
|
||||
"version": "1.8.16",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user