Compare commits

..

89 Commits

Author SHA1 Message Date
standardci 8c6cf9651d chore(release): publish new version
- @standardnotes/api-gateway@1.24.5
 - @standardnotes/auth-server@1.37.0
 - @standardnotes/common@1.35.0
 - @standardnotes/domain-events-infra@1.8.16
 - @standardnotes/domain-events@2.62.0
 - @standardnotes/event-store@1.3.21
 - @standardnotes/files-server@1.6.7
 - @standardnotes/predicates@1.4.4
 - @standardnotes/scheduler-server@1.10.35
 - @standardnotes/security@1.4.2
 - @standardnotes/syncing-server@1.8.19
2022-10-04 13:17:02 +00:00
Karol Sójko 8668fec33d feat(auth): add detailed income stats 2022-10-04 15:15:32 +02:00
standardci 76e34131fb chore(release): publish new version
- @standardnotes/api-gateway@1.24.4
 - @standardnotes/auth-server@1.36.4
 - @standardnotes/common@1.34.0
 - @standardnotes/domain-events-infra@1.8.15
 - @standardnotes/domain-events@2.61.1
 - @standardnotes/event-store@1.3.20
 - @standardnotes/files-server@1.6.6
 - @standardnotes/predicates@1.4.3
 - @standardnotes/scheduler-server@1.10.34
 - @standardnotes/security@1.4.1
 - @standardnotes/syncing-server@1.8.18
2022-10-04 12:17:15 +00:00
Karol Sójko 3c40ee4b4a feat(common): add subscription billing frequency 2022-10-04 14:15:45 +02:00
standardci 5abd7ae32c chore(release): publish new version
- @standardnotes/analytics@1.34.0
 - @standardnotes/api-gateway@1.24.3
 - @standardnotes/auth-server@1.36.3
 - @standardnotes/syncing-server@1.8.17
2022-10-04 11:26:29 +00:00
Karol Sójko 09b3f9a0d7 fix(auth): turn down severity of logs for predicate verification 2022-10-04 13:24:58 +02:00
Karol Sójko 19455ba6a7 feat(analytics): add new statistics measures for income 2022-10-04 13:24:58 +02:00
standardci 7d042689f0 chore(release): publish new version
- @standardnotes/api-gateway@1.24.2
2022-10-03 12:49:37 +00:00
Karol Sójko f43fbf1584 fix(api-gateway): report churn values for empty months 2022-10-03 14:47:45 +02:00
standardci 24c0cb8366 chore(release): publish new version
- @standardnotes/api-gateway@1.24.1
2022-10-03 12:15:56 +00:00
Karol Sójko 2236cc3828 fix: add debug logs for churn calculation 2022-10-03 14:14:27 +02:00
standardci 039d44718a chore(release): publish new version
- @standardnotes/analytics@1.33.0
 - @standardnotes/api-gateway@1.24.0
 - @standardnotes/auth-server@1.36.2
 - @standardnotes/domain-events-infra@1.8.14
 - @standardnotes/domain-events@2.61.0
 - @standardnotes/event-store@1.3.19
 - @standardnotes/files-server@1.6.5
 - @standardnotes/scheduler-server@1.10.33
 - @standardnotes/syncing-server@1.8.16
2022-10-03 11:22:13 +00:00
Karol Sójko f075cd8c4d feat: add calculating monthly churn rate 2022-10-03 13:19:53 +02:00
standardci ea0f3e8999 chore(release): publish new version
- @standardnotes/auth-server@1.36.1
2022-10-03 08:40:15 +00:00
Karol Sójko e7736bba25 fix(auth): counting active subscriptions 2022-10-03 10:38:31 +02:00
standardci fdf8809e13 chore(release): publish new version
- @standardnotes/auth-server@1.36.0
2022-10-03 08:33:51 +00:00
Karol Sójko 6a9d479f71 feat(auth): disallow v1 sign in for users with 004 protocol version 2022-10-03 10:31:58 +02:00
standardci 82c9637f37 chore(release): publish new version
- @standardnotes/api-gateway@1.23.0
2022-09-30 12:02:50 +00:00
Karol Sójko dfab849f48 feat(api-gateway): add churn metrics to the report 2022-09-30 14:01:15 +02:00
standardci ad60b95537 chore(release): publish new version
- @standardnotes/analytics@1.32.0
 - @standardnotes/api-gateway@1.22.6
 - @standardnotes/auth-server@1.35.0
 - @standardnotes/syncing-server@1.8.15
2022-09-30 11:49:00 +00:00
Karol Sójko 8a98f746eb feat(auth): add tracking total customers count 2022-09-30 13:47:33 +02:00
standardci 27cfd0ccf6 chore(release): publish new version
- @standardnotes/analytics@1.31.1
 - @standardnotes/api-gateway@1.22.5
 - @standardnotes/auth-server@1.34.1
 - @standardnotes/syncing-server@1.8.14
2022-09-30 09:24:21 +00:00
Karol Sójko 82bb85174d fix(auth): fix calculating new and existing customers churn 2022-09-30 11:22:46 +02:00
standardci 8ceef4acbf chore(release): publish new version
- @standardnotes/analytics@1.31.0
 - @standardnotes/api-gateway@1.22.4
 - @standardnotes/auth-server@1.34.0
 - @standardnotes/syncing-server@1.8.13
2022-09-30 08:39:59 +00:00
Karol Sójko b6118c17e1 feat(auth): add measuring new customers 2022-09-30 10:38:27 +02:00
standardci a7fb622e69 chore(release): publish new version
- @standardnotes/analytics@1.30.0
 - @standardnotes/api-gateway@1.22.3
 - @standardnotes/auth-server@1.33.0
 - @standardnotes/syncing-server@1.8.12
2022-09-30 08:30:22 +00:00
Karol Sójko 39337c1c4f feat(auth): add tracking churn activity 2022-09-30 10:28:37 +02:00
standardci 1f970aaf69 chore(release): publish new version
- @standardnotes/auth-server@1.32.13
2022-09-29 12:19:46 +00:00
Karol Sójko 0a5b7e13cd fix(auth): finding previous subscription setting for irreplacable subscription settings 2022-09-29 14:18:16 +02:00
standardci 1ce2b9eb44 chore(release): publish new version
- @standardnotes/auth-server@1.32.12
2022-09-29 11:15:07 +00:00
Karol Sójko 477f146725 fix(auth): reassign not replaceable subscription settings 2022-09-29 13:13:39 +02:00
standardci d7b02c4da9 chore(release): publish new version
- @standardnotes/auth-server@1.32.11
2022-09-28 13:36:17 +00:00
Karol Sójko 40e673379b fix(auth): prevent replacing files bytes used subscription setting upon renewal 2022-09-28 15:34:43 +02:00
standardci 6ce9a4e834 chore(release): publish new version
- @standardnotes/auth-server@1.32.10
2022-09-28 12:05:17 +00:00
Karol Sójko c5a07a888a fix(auth): exclude legacy 5 year plans from subscription length statistics 2022-09-28 14:03:49 +02:00
standardci 55587f6207 chore(release): publish new version
- @standardnotes/api-gateway@1.22.2
 - @standardnotes/auth-server@1.32.9
 - @standardnotes/domain-events-infra@1.8.13
 - @standardnotes/domain-events@2.60.7
 - @standardnotes/event-store@1.3.18
 - @standardnotes/files-server@1.6.4
 - @standardnotes/scheduler-server@1.10.32
 - @standardnotes/syncing-server@1.8.11
2022-09-28 11:33:44 +00:00
Karol Sójko 0d6b45c795 chore(deps): upgrade @standardnotes/features 2022-09-28 13:31:15 +02:00
standardci 95f64d9952 chore(release): publish new version
- @standardnotes/auth-server@1.32.8
2022-09-27 13:22:13 +00:00
Karol Sójko 54da5def4b fix(auth): ttl for lock counter on login lockout 2022-09-27 15:20:42 +02:00
standardci d2fc1e057d chore(release): publish new version
- @standardnotes/api-gateway@1.22.1
2022-09-27 10:35:15 +00:00
Karol Sójko 0a90d98c71 fix(api-gateway): remove admin graphql endpoint from being publicly available 2022-09-27 12:33:29 +02:00
standardci cc269e3b35 chore(release): publish new version
- @standardnotes/auth-server@1.32.7
2022-09-27 08:29:51 +00:00
Karol Sójko b19093179b fix(auth): allow resending canceled subscription invites 2022-09-27 10:28:13 +02:00
standardci e2cc0bc003 chore(release): publish new version
- @standardnotes/auth-server@1.32.6
2022-09-22 18:50:24 +00:00
Karol Sójko 644c52ae36 Revert "fix(auth): subscription token ttl"
This reverts commit 6efd336f34.
2022-09-22 20:48:51 +02:00
Karol Sójko 2554273a3f Revert "fix(auth): increase subscription token ttl"
This reverts commit 07def20f6b.
2022-09-22 20:48:51 +02:00
Karol Sójko a8ee149d7a Revert "tmp(auth): disable expiring of subscription tokens"
This reverts commit 053092031c.
2022-09-22 20:48:51 +02:00
standardci dcf92d58f9 chore(release): publish new version
- @standardnotes/auth-server@1.32.5
2022-09-22 18:00:23 +00:00
Karol Sójko 053092031c tmp(auth): disable expiring of subscription tokens 2022-09-22 19:58:35 +02:00
standardci c12e3eb3ec chore(release): publish new version
- @standardnotes/auth-server@1.32.4
2022-09-22 15:30:14 +00:00
Karol Sójko 07def20f6b fix(auth): increase subscription token ttl 2022-09-22 17:28:28 +02:00
standardci 6c2cca66bd chore(release): publish new version
- @standardnotes/auth-server@1.32.3
2022-09-22 14:26:30 +00:00
Karol Sójko 6efd336f34 fix(auth): subscription token ttl 2022-09-22 16:24:33 +02:00
standardci 81eb4be200 chore(release): publish new version
- @standardnotes/auth-server@1.32.2
2022-09-22 13:48:33 +00:00
Karol Sójko 76cee6dbad fix(auth): add throwing an error if the subscription token was not persisted 2022-09-22 15:46:23 +02:00
standardci dcc35a5738 chore(release): publish new version
- @standardnotes/syncing-server@1.8.10
2022-09-22 12:38:18 +00:00
Karol Sójko 5628de6445 fix(syncing-server-js): binding of sync limit 2022-09-22 14:36:47 +02:00
standardci 53bea47727 chore(release): publish new version
- @standardnotes/auth-server@1.32.1
2022-09-22 12:36:39 +00:00
Karol Sójko d6cf8d400a fix(auth): settings and subscription settings projection 2022-09-22 14:34:56 +02:00
standardci b58cc335f2 chore(release): publish new version
- @standardnotes/syncing-server@1.8.9
2022-09-22 11:56:22 +00:00
Karol Sójko 03d1bc611c fix(syncing-server): introduce upper bound for sync items limit as an env var 2022-09-22 13:54:26 +02:00
standardci a48b09cefe chore(release): publish new version
- @standardnotes/api-gateway@1.22.0
 - @standardnotes/auth-server@1.32.0
2022-09-22 11:27:42 +00:00
Karol Sójko d3f36c05df feat(auth): remove muting emails by use case in favor of updating user settings 2022-09-22 13:25:31 +02:00
standardci 488ade25ab chore(release): publish new version
- @standardnotes/auth-server@1.31.2
2022-09-21 14:40:45 +00:00
Karol Sójko 413a276d20 fix(auth): response wrapping on web socket connection token creation 2022-09-21 16:39:17 +02:00
standardci 65675a21d6 chore(release): publish new version
- @standardnotes/api-gateway@1.21.1
2022-09-21 13:56:25 +00:00
Karol Sójko d35de38289 fix(api-gateway): web socket connection routing 2022-09-21 15:54:57 +02:00
standardci 83e1baa978 chore(release): publish new version
- @standardnotes/auth-server@1.31.1
2022-09-21 13:53:16 +00:00
Karol Sójko 875edce5b1 fix(auth): web sockets routes 2022-09-21 15:51:46 +02:00
standardci 1baa504728 chore(release): publish new version
- @standardnotes/api-gateway@1.21.0
 - @standardnotes/auth-server@1.31.0
2022-09-21 11:57:48 +00:00
Karol Sójko 965ae79414 feat(auth): add creating cross service token in exchange for web socket connection token 2022-09-21 13:56:17 +02:00
standardci 7a8448c116 chore(release): publish new version
- @standardnotes/auth-server@1.30.1
2022-09-21 09:15:22 +00:00
Karol Sójko d935157ee8 fix(auth): missing injectable annotation 2022-09-21 11:13:24 +02:00
standardci 9313e6b568 chore(release): publish new version
- @standardnotes/api-gateway@1.20.0
 - @standardnotes/auth-server@1.30.0
 - @standardnotes/domain-events-infra@1.8.12
 - @standardnotes/domain-events@2.60.6
 - @standardnotes/event-store@1.3.17
 - @standardnotes/files-server@1.6.3
 - @standardnotes/scheduler-server@1.10.31
 - @standardnotes/security@1.4.0
 - @standardnotes/syncing-server@1.8.8
2022-09-21 09:00:32 +00:00
Karol Sójko 8033177f48 feat(auth): add creating web socket connection tokens 2022-09-21 10:58:39 +02:00
standardci 11011fa15d chore(release): publish new version
- @standardnotes/syncing-server@1.8.7
2022-09-20 08:01:52 +00:00
Karol Sójko c2e9f3e72b fix(syncing-server): content size calculation and add syncing upper bound for limit paramter 2022-09-20 09:59:40 +02:00
standardci f0fb7fd1cd chore(release): publish new version
- @standardnotes/files-server@1.6.2
2022-09-19 11:55:08 +00:00
Karol Sójko 15e342fd51 Merge pull request #224 from standardnotes/fs_dos
fix: add upper bound for FS file chunk upload
2022-09-19 13:53:39 +02:00
Karol Sójko dfa7e06f87 fix: add upper bound for FS file chunk upload 2022-09-19 13:44:37 +02:00
standardci a9aef5521b chore(release): publish new version
- @standardnotes/auth-server@1.29.1
 - @standardnotes/files-server@1.6.1
2022-09-19 07:59:14 +00:00
Karol Sójko a628bdc44e fix(files): uuid validator binding 2022-09-19 09:57:17 +02:00
Karol Sójko db6f966045 fix(auth): uuid validator binding 2022-09-19 09:57:10 +02:00
standardci 9b602ed405 chore(release): publish new version
- @standardnotes/api-gateway@1.19.6
 - @standardnotes/auth-server@1.29.0
 - @standardnotes/common@1.33.0
 - @standardnotes/domain-events-infra@1.8.11
 - @standardnotes/domain-events@2.60.5
 - @standardnotes/event-store@1.3.16
 - @standardnotes/files-server@1.6.0
 - @standardnotes/predicates@1.4.2
 - @standardnotes/scheduler-server@1.10.30
 - @standardnotes/security@1.3.3
 - @standardnotes/syncing-server@1.8.6
2022-09-19 07:45:26 +00:00
Karol Sójko db15457ce4 feat(files): add validating remote identifiers 2022-09-19 09:43:46 +02:00
standardci 719d8558a3 chore(release): publish new version
- @standardnotes/auth-server@1.28.4
2022-09-16 10:36:18 +00:00
Karol Sójko c207c3fc84 fix(auth): feature service spec 2022-09-16 12:34:43 +02:00
standardci 4bde4758c3 chore(release): publish new version
- @standardnotes/analytics@1.29.1
 - @standardnotes/api-gateway@1.19.5
 - @standardnotes/auth-server@1.28.3
 - @standardnotes/syncing-server@1.8.5
2022-09-16 10:19:03 +00:00
Karol Sójko 5eb957c82a fix(auth): change remaining subscription time stats to percentage 2022-09-16 12:17:34 +02:00
161 changed files with 2687 additions and 907 deletions
Generated
+30 -30
View File
@@ -2484,14 +2484,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\ }]\
]],\ ]],\
["@standardnotes/api", [\ ["@standardnotes/api", [\
["npm:1.7.2", {\ ["npm:1.9.0", {\
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.7.2-e68e7d4e63-bdfc414e6d.zip/node_modules/@standardnotes/api/",\ "packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.9.0-507434ff00-cc3feac393.zip/node_modules/@standardnotes/api/",\
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/api", "npm:1.7.2"],\ ["@standardnotes/api", "npm:1.9.0"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/encryption", "npm:1.15.2"],\ ["@standardnotes/encryption", "npm:1.15.9"],\
["@standardnotes/models", "npm:1.18.2"],\ ["@standardnotes/models", "npm:1.22.0"],\
["@standardnotes/responses", "npm:1.10.1"],\ ["@standardnotes/responses", "npm:1.10.3"],\
["@standardnotes/security", "workspace:packages/security"],\ ["@standardnotes/security", "workspace:packages/security"],\
["@standardnotes/utils", "npm:1.9.0"],\ ["@standardnotes/utils", "npm:1.9.0"],\
["reflect-metadata", "npm:0.1.13"]\ ["reflect-metadata", "npm:0.1.13"]\
@@ -2563,11 +2563,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\ ["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\
["@sentry/node", "npm:7.5.0"],\ ["@sentry/node", "npm:7.5.0"],\
["@standardnotes/analytics", "workspace:packages/analytics"],\ ["@standardnotes/analytics", "workspace:packages/analytics"],\
["@standardnotes/api", "npm:1.7.2"],\ ["@standardnotes/api", "npm:1.9.0"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\ ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\ ["@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/predicates", "workspace:packages/predicates"],\
["@standardnotes/responses", "npm:1.6.39"],\ ["@standardnotes/responses", "npm:1.6.39"],\
["@standardnotes/security", "workspace:packages/security"],\ ["@standardnotes/security", "workspace:packages/security"],\
@@ -2651,7 +2651,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\ ["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.50.0"],\ ["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/predicates", "workspace:packages/predicates"],\ ["@standardnotes/predicates", "workspace:packages/predicates"],\
["@standardnotes/security", "workspace:packages/security"],\ ["@standardnotes/security", "workspace:packages/security"],\
["@types/jest", "npm:28.1.4"],\ ["@types/jest", "npm:28.1.4"],\
@@ -2688,14 +2688,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\ }]\
]],\ ]],\
["@standardnotes/encryption", [\ ["@standardnotes/encryption", [\
["npm:1.15.2", {\ ["npm:1.15.9", {\
"packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.15.2-ef86a8281d-6e8336f1e7.zip/node_modules/@standardnotes/encryption/",\ "packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.15.9-00c7fac9f6-7595ac08ce.zip/node_modules/@standardnotes/encryption/",\
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/encryption", "npm:1.15.2"],\ ["@standardnotes/encryption", "npm:1.15.9"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/models", "npm:1.18.2"],\ ["@standardnotes/models", "npm:1.22.0"],\
["@standardnotes/responses", "npm:1.10.1"],\ ["@standardnotes/responses", "npm:1.10.3"],\
["@standardnotes/sncrypto-common", "npm:1.11.1"],\ ["@standardnotes/sncrypto-common", "npm:1.12.0"],\
["@standardnotes/utils", "npm:1.9.0"],\ ["@standardnotes/utils", "npm:1.9.0"],\
["reflect-metadata", "npm:0.1.13"]\ ["reflect-metadata", "npm:0.1.13"]\
],\ ],\
@@ -2743,10 +2743,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
],\ ],\
"linkType": "HARD"\ "linkType": "HARD"\
}],\ }],\
["npm:1.52.0", {\ ["npm:1.52.1", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.52.0-8c1adf7881-3e6014272f.zip/node_modules/@standardnotes/features/",\ "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.52.1-1fee85cf4e-ff3684399e.zip/node_modules/@standardnotes/features/",\
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/features", "npm:1.52.0"],\ ["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/auth", "npm:3.19.4"],\ ["@standardnotes/auth", "npm:3.19.4"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/security", "workspace:packages/security"],\ ["@standardnotes/security", "workspace:packages/security"],\
@@ -2808,13 +2808,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\ }]\
]],\ ]],\
["@standardnotes/models", [\ ["@standardnotes/models", [\
["npm:1.18.2", {\ ["npm:1.22.0", {\
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.18.2-56f35bb72d-88180a93e5.zip/node_modules/@standardnotes/models/",\ "packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.22.0-2cc72f987b-9928246368.zip/node_modules/@standardnotes/models/",\
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/models", "npm:1.18.2"],\ ["@standardnotes/models", "npm:1.22.0"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.52.0"],\ ["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/responses", "npm:1.10.1"],\ ["@standardnotes/responses", "npm:1.10.3"],\
["@standardnotes/utils", "npm:1.9.0"],\ ["@standardnotes/utils", "npm:1.9.0"],\
["lodash", "npm:4.17.21"],\ ["lodash", "npm:4.17.21"],\
["reflect-metadata", "npm:0.1.13"]\ ["reflect-metadata", "npm:0.1.13"]\
@@ -2851,12 +2851,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\ }]\
]],\ ]],\
["@standardnotes/responses", [\ ["@standardnotes/responses", [\
["npm:1.10.1", {\ ["npm:1.10.3", {\
"packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.1-9f82fff6c1-b84fb3f71c.zip/node_modules/@standardnotes/responses/",\ "packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.3-7cdb15f83a-4a1e31eb89.zip/node_modules/@standardnotes/responses/",\
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/responses", "npm:1.10.1"],\ ["@standardnotes/responses", "npm:1.10.3"],\
["@standardnotes/common", "workspace:packages/common"],\ ["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.52.0"],\ ["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/security", "workspace:packages/security"],\ ["@standardnotes/security", "workspace:packages/security"],\
["reflect-metadata", "npm:0.1.13"]\ ["reflect-metadata", "npm:0.1.13"]\
],\ ],\
@@ -2967,10 +2967,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\ }]\
]],\ ]],\
["@standardnotes/sncrypto-common", [\ ["@standardnotes/sncrypto-common", [\
["npm:1.11.1", {\ ["npm:1.12.0", {\
"packageLocation": "./.yarn/cache/@standardnotes-sncrypto-common-npm-1.11.1-58d12d6912-69d698abb7.zip/node_modules/@standardnotes/sncrypto-common/",\ "packageLocation": "./.yarn/cache/@standardnotes-sncrypto-common-npm-1.12.0-1a093ff006-b89a14bd23.zip/node_modules/@standardnotes/sncrypto-common/",\
"packageDependencies": [\ "packageDependencies": [\
["@standardnotes/sncrypto-common", "npm:1.11.1"],\ ["@standardnotes/sncrypto-common", "npm:1.12.0"],\
["reflect-metadata", "npm:0.1.13"]\ ["reflect-metadata", "npm:0.1.13"]\
],\ ],\
"linkType": "HARD"\ "linkType": "HARD"\
+42
View File
@@ -3,6 +3,48 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 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
* **auth:** change remaining subscription time stats to percentage ([5eb957c](https://github.com/standardnotes/server/commit/5eb957c82a8cc5fdcb6815e2cd30e49cd2b1e8ac))
# [1.29.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.28.0...@standardnotes/analytics@1.29.0) (2022-09-15) # [1.29.0](https://github.com/standardnotes/server/compare/@standardnotes/analytics@1.28.0...@standardnotes/analytics@1.29.0) (2022-09-15)
### Features ### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@standardnotes/analytics", "name": "@standardnotes/analytics",
"version": "1.29.0", "version": "1.34.0",
"engines": { "engines": {
"node": ">=14.0.0 <17.0.0" "node": ">=14.0.0 <17.0.0"
}, },
@@ -11,9 +11,12 @@ export enum AnalyticsActivity {
SubscriptionRenewed = 'subscription-renewed', SubscriptionRenewed = 'subscription-renewed',
SubscriptionRefunded = 'subscription-refunded', SubscriptionRefunded = 'subscription-refunded',
SubscriptionCancelled = 'subscription-cancelled', SubscriptionCancelled = 'subscription-cancelled',
SubscriptionExpired = 'subscription-expired',
EmailUnbackedUpData = 'email-unbacked-up-data', EmailUnbackedUpData = 'email-unbacked-up-data',
EmailBackup = 'email-backup', EmailBackup = 'email-backup',
LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased', LimitedDiscountOfferPurchased = 'limited-discount-offer-purchased',
PaymentFailed = 'payment-failed', PaymentFailed = 'payment-failed',
PaymentSuccess = 'payment-success', PaymentSuccess = 'payment-success',
NewCustomersChurn = 'new-customers-churn',
ExistingCustomersChurn = 'existing-customers-churn',
} }
@@ -12,7 +12,7 @@ export interface AnalyticsStoreInterface {
secondActivity: AnalyticsActivity secondActivity: AnalyticsActivity
secondActivityPeriodKey: string secondActivityPeriodKey: string
}): Promise<number> }): Promise<number>
calculateActivityTotalCount(activity: AnalyticsActivity, period: Period): Promise<number> calculateActivityTotalCount(activity: AnalyticsActivity, periodOrPeriodKey: Period | string): Promise<number>
calculateActivityChangesTotalCount( calculateActivityChangesTotalCount(
activity: AnalyticsActivity, activity: AnalyticsActivity,
period: Period, period: Period,
@@ -1,11 +1,21 @@
export enum StatisticsMeasure { export enum StatisticsMeasure {
Income = 'income', 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', SubscriptionLength = 'subscription-length',
RegistrationLength = 'registration-length', RegistrationLength = 'registration-length',
RegistrationToSubscriptionTime = 'registration-to-subscription-time', RegistrationToSubscriptionTime = 'registration-to-subscription-time',
SubscriptionCancelToExpireTime = 'subscription-cancel-to-expire-time', RemainingSubscriptionTimePercentage = 'remaining-subscription-time-percentage',
Refunds = 'refunds', Refunds = 'refunds',
NotesCountFreeUsers = 'notes-count-free-users', NotesCountFreeUsers = 'notes-count-free-users',
NotesCountPaidUsers = 'notes-count-paid-users', NotesCountPaidUsers = 'notes-count-paid-users',
FilesCount = 'files-count', FilesCount = 'files-count',
NewCustomers = 'new-customers',
TotalCustomers = 'total-customers',
} }
@@ -9,6 +9,7 @@ export interface StatisticsStoreInterface {
getYesterdayApplicationUsage(): Promise<Array<{ version: string; count: number }>> getYesterdayApplicationUsage(): Promise<Array<{ version: string; count: number }>>
getYesterdayOutOfSyncIncidents(): Promise<number> getYesterdayOutOfSyncIncidents(): Promise<number>
incrementMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void> 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> 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, WeekBeforeLastWeek,
ThisMonth, ThisMonth,
LastMonth, LastMonth,
ThisYear,
Last30Days, Last30Days,
Last7Days, Last7Days,
Q1ThisYear, Q1ThisYear,
Q2ThisYear, Q2ThisYear,
Q3ThisYear, Q3ThisYear,
Q4ThisYear, Q4ThisYear,
JanuaryThisYear,
FebruaryThisYear,
MarchThisYear,
AprilThisYear,
MayThisYear,
JuneThisYear,
JulyThisYear,
AugustThisYear,
SeptemberThisYear,
OctoberThisYear,
NovemberThisYear,
DecemberThisYear,
} }
@@ -3,6 +3,20 @@ import { PeriodKeyGenerator } from './PeriodKeyGenerator'
describe('PeriodKeyGenerator', () => { describe('PeriodKeyGenerator', () => {
const createGenerator = () => new 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(() => { beforeEach(() => {
jest.useFakeTimers() 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', () => { it('should generate period keys for last 7 days', () => {
expect(createGenerator().getDiscretePeriodKeys(Period.Last7Days)).toEqual([ expect(createGenerator().getDiscretePeriodKeys(Period.Last7Days)).toEqual([
'2022-5-17', '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', () => { it('should generate period keys for Q1', () => {
expect(createGenerator().getDiscretePeriodKeys(Period.Q1ThisYear)).toEqual(['2022-1', '2022-2', '2022-3']) 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']) 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', () => { it('should generate a period key for today', () => {
expect(createGenerator().getPeriodKey(Period.Today)).toEqual('2022-5-24') expect(createGenerator().getPeriodKey(Period.Today)).toEqual('2022-5-24')
}) })
@@ -104,6 +214,12 @@ describe('PeriodKeyGenerator', () => {
expect(createGenerator().getPeriodKey(Period.ThisMonth)).toEqual('2022-5') 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', () => { it('should generate a period key for last month', () => {
expect(createGenerator().getPeriodKey(Period.LastMonth)).toEqual('2022-4') expect(createGenerator().getPeriodKey(Period.LastMonth)).toEqual('2022-4')
}) })
@@ -129,4 +245,19 @@ describe('PeriodKeyGenerator', () => {
expect(error).not.toBeNull() 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' import { PeriodKeyGeneratorInterface } from './PeriodKeyGeneratorInterface'
export class PeriodKeyGenerator implements 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[] { getDiscretePeriodKeys(period: Period): string[] {
const periodKeys = [] const periodKeys = []
@@ -26,6 +48,23 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
return this.generateMonthlyKeysRange(6, 9) return this.generateMonthlyKeysRange(6, 9)
case Period.Q4ThisYear: case Period.Q4ThisYear:
return this.generateMonthlyKeysRange(9, 12) 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: default:
throw new Error(`Unsuporrted period: ${period}`) throw new Error(`Unsuporrted period: ${period}`)
} }
@@ -49,11 +88,43 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
return this.getMonthlyKey() return this.getMonthlyKey()
case Period.LastMonth: case Period.LastMonth:
return this.getMonthlyKey(this.getLastMonthDate()) 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: default:
throw new Error(`Unsuporrted period: ${period}`) throw new Error(`Unsuporrted period: ${period}`)
} }
} }
private getYearlyKey(date?: Date): string {
date = date ?? new Date()
return this.getYear(date)
}
private getMonthlyKey(date?: Date): string { private getMonthlyKey(date?: Date): string {
date = date ?? new Date() date = date ?? new Date()
@@ -141,4 +212,22 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
return keys 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 { export interface PeriodKeyGeneratorInterface {
getPeriodKey(period: Period): string getPeriodKey(period: Period): string
convertPeriodKeyToPeriod(periodKey: string): Period
getDiscretePeriodKeys(period: Period): string[] getDiscretePeriodKeys(period: Period): string[]
} }
@@ -102,7 +102,7 @@ describe('RedisAnalyticsStore', () => {
expect(caughtError).not.toBeNull() 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) redisClient.bitcount = jest.fn().mockReturnValue(70)
expect(await createStore().calculateActivityTotalCount(AnalyticsActivity.EditingItems, Period.Yesterday)).toEqual( 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') 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 () => { it('should calculate activity retention', async () => {
redisClient.bitcount = jest.fn().mockReturnValueOnce(7).mockReturnValueOnce(10) 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> { async calculateActivityTotalCount(activity: AnalyticsActivity, periodOrPeriodKey: Period | string): Promise<number> {
return this.redisClient.bitcount( let periodKey = periodOrPeriodKey
`bitmap:action:${activity}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`, 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 = {} as jest.Mocked<IORedis.Pipeline>
pipeline.incr = jest.fn() pipeline.incr = jest.fn()
pipeline.incrbyfloat = jest.fn() pipeline.incrbyfloat = jest.fn()
pipeline.set = jest.fn()
pipeline.setbit = jest.fn() pipeline.setbit = jest.fn()
pipeline.exec = jest.fn() pipeline.exec = jest.fn()
@@ -92,6 +93,13 @@ describe('RedisStatisticsStore', () => {
expect(pipeline.exec).toHaveBeenCalled() 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 () => { it('should increment measure by a value', async () => {
await createStore().incrementMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth]) 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) 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 { export class RedisStatisticsStore implements StatisticsStoreInterface {
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {} constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
async getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise<number> { async setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise<void> {
const totalValue = await this.redisClient.get( const pipeline = this.redisClient.pipeline()
`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
) 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) { if (totalValue === null) {
return 0 return 0
+96
View File
@@ -3,6 +3,102 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 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
* **auth:** add creating cross service token in exchange for web socket connection token ([965ae79](https://github.com/standardnotes/api-gateway/commit/965ae79414e25d0959f67e16dcbb054229013e1c))
# [1.20.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.6...@standardnotes/api-gateway@1.20.0) (2022-09-21)
### Features
* **auth:** add creating web socket connection tokens ([8033177](https://github.com/standardnotes/api-gateway/commit/8033177f48dc961194f24fb7daa1073b8b697b74))
## [1.19.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.5...@standardnotes/api-gateway@1.19.6) (2022-09-19)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.19.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.4...@standardnotes/api-gateway@1.19.5) (2022-09-16)
### Bug Fixes
* **auth:** change remaining subscription time stats to percentage ([5eb957c](https://github.com/standardnotes/api-gateway/commit/5eb957c82a8cc5fdcb6815e2cd30e49cd2b1e8ac))
## [1.19.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.3...@standardnotes/api-gateway@1.19.4) (2022-09-16) ## [1.19.4](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.3...@standardnotes/api-gateway@1.19.4) (2022-09-16)
**Note:** Version bump only for package @standardnotes/api-gateway **Note:** Version bump only for package @standardnotes/api-gateway
+44 -1
View File
@@ -38,6 +38,8 @@ const requestReport = async (
AnalyticsActivity.DeleteAccount, AnalyticsActivity.DeleteAccount,
AnalyticsActivity.SubscriptionCancelled, AnalyticsActivity.SubscriptionCancelled,
AnalyticsActivity.SubscriptionRefunded, AnalyticsActivity.SubscriptionRefunded,
AnalyticsActivity.ExistingCustomersChurn,
AnalyticsActivity.NewCustomersChurn,
] ]
for (const analyticsName of thirtyDaysAnalyticsNames) { for (const analyticsName of thirtyDaysAnalyticsNames) {
@@ -74,6 +76,8 @@ const requestReport = async (
AnalyticsActivity.GeneralActivityPaidUsers, AnalyticsActivity.GeneralActivityPaidUsers,
AnalyticsActivity.PaymentFailed, AnalyticsActivity.PaymentFailed,
AnalyticsActivity.PaymentSuccess, AnalyticsActivity.PaymentSuccess,
AnalyticsActivity.NewCustomersChurn,
AnalyticsActivity.ExistingCustomersChurn,
] ]
for (const activityName of yesterdayActivityNames) { for (const activityName of yesterdayActivityNames) {
@@ -94,10 +98,12 @@ const requestReport = async (
StatisticsMeasure.RegistrationLength, StatisticsMeasure.RegistrationLength,
StatisticsMeasure.SubscriptionLength, StatisticsMeasure.SubscriptionLength,
StatisticsMeasure.RegistrationToSubscriptionTime, StatisticsMeasure.RegistrationToSubscriptionTime,
StatisticsMeasure.SubscriptionCancelToExpireTime, StatisticsMeasure.RemainingSubscriptionTimePercentage,
StatisticsMeasure.NotesCountFreeUsers, StatisticsMeasure.NotesCountFreeUsers,
StatisticsMeasure.NotesCountPaidUsers, StatisticsMeasure.NotesCountPaidUsers,
StatisticsMeasure.FilesCount, StatisticsMeasure.FilesCount,
StatisticsMeasure.NewCustomers,
StatisticsMeasure.TotalCustomers,
] ]
const statisticMeasures = [] const statisticMeasures = []
for (const statisticMeasureName of statisticMeasureNames) { 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 = { const event: DailyAnalyticsReportGeneratedEvent = {
type: 'DAILY_ANALYTICS_REPORT_GENERATED', type: 'DAILY_ANALYTICS_REPORT_GENERATED',
createdAt: new Date(), createdAt: new Date(),
@@ -157,6 +196,10 @@ const requestReport = async (
}, },
}, },
], ],
churn: {
periodKeys: monthlyPeriodKeys,
values: churnRates,
},
}, },
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@standardnotes/api-gateway", "name": "@standardnotes/api-gateway",
"version": "1.19.4", "version": "1.24.5",
"engines": { "engines": {
"node": ">=16.0.0 <17.0.0" "node": ">=16.0.0 <17.0.0"
}, },
@@ -23,6 +23,7 @@ import { SubscriptionTokenAuthMiddleware } from '../Controller/SubscriptionToken
import { StatisticsMiddleware } from '../Controller/StatisticsMiddleware' import { StatisticsMiddleware } from '../Controller/StatisticsMiddleware'
import { CrossServiceTokenCacheInterface } from '../Service/Cache/CrossServiceTokenCacheInterface' import { CrossServiceTokenCacheInterface } from '../Service/Cache/CrossServiceTokenCacheInterface'
import { RedisCrossServiceTokenCache } from '../Infra/Redis/RedisCrossServiceTokenCache' import { RedisCrossServiceTokenCache } from '../Infra/Redis/RedisCrossServiceTokenCache'
import { WebSocketAuthMiddleware } from '../Controller/WebSocketAuthMiddleware'
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher') const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -85,6 +86,7 @@ export class ContainerConfigLoader {
// Middleware // Middleware
container.bind<AuthMiddleware>(TYPES.AuthMiddleware).to(AuthMiddleware) container.bind<AuthMiddleware>(TYPES.AuthMiddleware).to(AuthMiddleware)
container.bind<WebSocketAuthMiddleware>(TYPES.WebSocketAuthMiddleware).to(WebSocketAuthMiddleware)
container container
.bind<SubscriptionTokenAuthMiddleware>(TYPES.SubscriptionTokenAuthMiddleware) .bind<SubscriptionTokenAuthMiddleware>(TYPES.SubscriptionTokenAuthMiddleware)
.to(SubscriptionTokenAuthMiddleware) .to(SubscriptionTokenAuthMiddleware)
@@ -18,6 +18,7 @@ const TYPES = {
// Middleware // Middleware
StatisticsMiddleware: Symbol.for('StatisticsMiddleware'), StatisticsMiddleware: Symbol.for('StatisticsMiddleware'),
AuthMiddleware: Symbol.for('AuthMiddleware'), AuthMiddleware: Symbol.for('AuthMiddleware'),
WebSocketAuthMiddleware: Symbol.for('WebSocketAuthMiddleware'),
SubscriptionTokenAuthMiddleware: Symbol.for('SubscriptionTokenAuthMiddleware'), SubscriptionTokenAuthMiddleware: Symbol.for('SubscriptionTokenAuthMiddleware'),
// Services // Services
HTTPService: Symbol.for('HTTPService'), HTTPService: Symbol.for('HTTPService'),
@@ -0,0 +1,95 @@
import { CrossServiceTokenData } from '@standardnotes/security'
import { RoleName } from '@standardnotes/common'
import { NextFunction, Request, Response } from 'express'
import { inject, injectable } from 'inversify'
import { BaseMiddleware } from 'inversify-express-utils'
import { verify } from 'jsonwebtoken'
import { AxiosError, AxiosInstance } from 'axios'
import { Logger } from 'winston'
import TYPES from '../Bootstrap/Types'
@injectable()
export class WebSocketAuthMiddleware extends BaseMiddleware {
constructor(
@inject(TYPES.HTTPClient) private httpClient: AxiosInstance,
@inject(TYPES.AUTH_SERVER_URL) private authServerUrl: string,
@inject(TYPES.AUTH_JWT_SECRET) private jwtSecret: string,
@inject(TYPES.Logger) private logger: Logger,
) {
super()
}
async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
const authHeaderValue = request.headers.authorization as string
if (!authHeaderValue) {
response.status(401).send({
error: {
tag: 'invalid-auth',
message: 'Invalid login credentials.',
},
})
return
}
try {
const authResponse = await this.httpClient.request({
method: 'POST',
headers: {
Authorization: authHeaderValue,
Accept: 'application/json',
},
validateStatus: (status: number) => {
return status >= 200 && status < 500
},
url: `${this.authServerUrl}/sockets/tokens/validate`,
})
if (authResponse.status > 200) {
response.setHeader('content-type', authResponse.headers['content-type'])
response.status(authResponse.status).send(authResponse.data)
return
}
const crossServiceToken = authResponse.data.authToken
response.locals.authToken = crossServiceToken
const decodedToken = <CrossServiceTokenData>verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] })
response.locals.freeUser =
decodedToken.roles.length === 1 &&
decodedToken.roles.find((role) => role.name === RoleName.CoreUser) !== undefined
response.locals.userUuid = decodedToken.user.uuid
response.locals.roles = decodedToken.roles
} catch (error) {
const errorMessage = (error as AxiosError).isAxiosError
? JSON.stringify((error as AxiosError).response?.data)
: (error as Error).message
this.logger.error(
`Could not pass the request to ${this.authServerUrl}/sockets/tokens/validate on underlying service: ${errorMessage}`,
)
this.logger.debug('Response error: %O', (error as AxiosError).response ?? error)
if ((error as AxiosError).response?.headers['content-type']) {
response.setHeader('content-type', (error as AxiosError).response?.headers['content-type'] as string)
}
const errorCode =
(error as AxiosError).isAxiosError && !isNaN(+((error as AxiosError).code as string))
? +((error as AxiosError).code as string)
: 500
response.status(errorCode).send(errorMessage)
return
}
return next()
}
}
@@ -29,34 +29,4 @@ export class ActionsController extends BaseHttpController {
async methods(request: Request, response: Response): Promise<void> { async methods(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'auth/methods', request.body) 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) 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') @httpPost('/admin/auth/login')
async adminLogin(request: Request, response: Response): Promise<void> { async adminLogin(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, 'admin/auth/login', request.body) await this.httpService.callPaymentsServer(request, response, 'admin/auth/login', request.body)
@@ -15,7 +15,12 @@ export class WebSocketsController extends BaseHttpController {
super() super()
} }
@httpPost('/', TYPES.AuthMiddleware) @httpPost('/tokens', TYPES.AuthMiddleware)
async createWebSocketConnectionToken(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'sockets/tokens', request.body)
}
@httpPost('/connections', TYPES.WebSocketAuthMiddleware)
async createWebSocketConnection(request: Request, response: Response): Promise<void> { async createWebSocketConnection(request: Request, response: Response): Promise<void> {
if (!request.headers.connectionid) { if (!request.headers.connectionid) {
this.logger.error('Could not create a websocket connection. Missing connection id header.') this.logger.error('Could not create a websocket connection. Missing connection id header.')
@@ -25,10 +30,15 @@ export class WebSocketsController extends BaseHttpController {
return 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> { async deleteWebSocketConnection(request: Request, response: Response): Promise<void> {
if (!request.headers.connectionid) { if (!request.headers.connectionid) {
this.logger.error('Could not delete a websocket connection. Missing connection id header.') this.logger.error('Could not delete a websocket connection. Missing connection id header.')
@@ -38,6 +48,11 @@ export class WebSocketsController extends BaseHttpController {
return 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
View File
@@ -66,5 +66,8 @@ SENTRY_ENVIRONMENT=
VALET_TOKEN_SECRET= VALET_TOKEN_SECRET=
VALET_TOKEN_TTL= VALET_TOKEN_TTL=
WEB_SOCKET_CONNECTION_TOKEN_SECRET=
WEB_SOCKET_CONNECTION_TOKEN_TTL=
# (Optional) Analytics # (Optional) Analytics
ANALYTICS_ENABLED=false ANALYTICS_ENABLED=false
+192
View File
@@ -3,6 +3,198 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 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
* **auth:** add creating cross service token in exchange for web socket connection token ([965ae79](https://github.com/standardnotes/server/commit/965ae79414e25d0959f67e16dcbb054229013e1c))
## [1.30.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.30.0...@standardnotes/auth-server@1.30.1) (2022-09-21)
### Bug Fixes
* **auth:** missing injectable annotation ([d935157](https://github.com/standardnotes/server/commit/d935157ee8425d427fa52465e766d18e29332b5b))
# [1.30.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.29.1...@standardnotes/auth-server@1.30.0) (2022-09-21)
### Features
* **auth:** add creating web socket connection tokens ([8033177](https://github.com/standardnotes/server/commit/8033177f48dc961194f24fb7daa1073b8b697b74))
## [1.29.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.29.0...@standardnotes/auth-server@1.29.1) (2022-09-19)
### Bug Fixes
* **auth:** uuid validator binding ([db6f966](https://github.com/standardnotes/server/commit/db6f966045d51e59555740c9e009bf66b629673c))
# [1.29.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.28.4...@standardnotes/auth-server@1.29.0) (2022-09-19)
### Features
* **files:** add validating remote identifiers ([db15457](https://github.com/standardnotes/server/commit/db15457ce4eb533ec822cf93c3ed83eafe9e64d5))
## [1.28.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.28.3...@standardnotes/auth-server@1.28.4) (2022-09-16)
### Bug Fixes
* **auth:** feature service spec ([c207c3f](https://github.com/standardnotes/server/commit/c207c3fc8442eec9b8c3150f09ecccfdd6a5ed50))
## [1.28.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.28.2...@standardnotes/auth-server@1.28.3) (2022-09-16)
### Bug Fixes
* **auth:** change remaining subscription time stats to percentage ([5eb957c](https://github.com/standardnotes/server/commit/5eb957c82a8cc5fdcb6815e2cd30e49cd2b1e8ac))
## [1.28.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.28.1...@standardnotes/auth-server@1.28.2) (2022-09-16) ## [1.28.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.28.1...@standardnotes/auth-server@1.28.2) (2022-09-16)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -10,7 +10,6 @@ import '../src/Controller/SessionsController'
import '../src/Controller/UsersController' import '../src/Controller/UsersController'
import '../src/Controller/SettingsController' import '../src/Controller/SettingsController'
import '../src/Controller/FeaturesController' import '../src/Controller/FeaturesController'
import '../src/Controller/WebSocketsController'
import '../src/Controller/AdminController' import '../src/Controller/AdminController'
import '../src/Controller/InternalController' import '../src/Controller/InternalController'
import '../src/Controller/SubscriptionTokensController' import '../src/Controller/SubscriptionTokensController'
@@ -21,6 +20,7 @@ import '../src/Controller/SubscriptionSettingsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController' import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController' import '../src/Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'
import * as cors from 'cors' import * as cors from 'cors'
import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express' import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express'
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class addRenewedAtColumn1663321030000 implements MigrationInterface {
name = 'addRenewedAtColumn1663321030000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `user_subscriptions` ADD `renewed_at` bigint NULL')
}
public async down(): Promise<void> {
return
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@standardnotes/auth-server", "name": "@standardnotes/auth-server",
"version": "1.28.2", "version": "1.37.0",
"engines": { "engines": {
"node": ">=16.0.0 <17.0.0" "node": ">=16.0.0 <17.0.0"
}, },
@@ -34,11 +34,11 @@
"@newrelic/winston-enricher": "^4.0.0", "@newrelic/winston-enricher": "^4.0.0",
"@sentry/node": "^7.3.0", "@sentry/node": "^7.3.0",
"@standardnotes/analytics": "workspace:*", "@standardnotes/analytics": "workspace:*",
"@standardnotes/api": "^1.7.2", "@standardnotes/api": "^1.9.0",
"@standardnotes/common": "workspace:*", "@standardnotes/common": "workspace:*",
"@standardnotes/domain-events": "workspace:*", "@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "workspace:*", "@standardnotes/domain-events-infra": "workspace:*",
"@standardnotes/features": "^1.47.0", "@standardnotes/features": "^1.52.1",
"@standardnotes/predicates": "workspace:*", "@standardnotes/predicates": "workspace:*",
"@standardnotes/responses": "^1.6.39", "@standardnotes/responses": "^1.6.39",
"@standardnotes/security": "workspace:*", "@standardnotes/security": "workspace:*",
+35 -7
View File
@@ -130,13 +130,19 @@ import { RedisOfflineSubscriptionTokenRepository } from '../Infra/Redis/RedisOff
import { CreateOfflineSubscriptionToken } from '../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken' import { CreateOfflineSubscriptionToken } from '../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken'
import { AuthenticateOfflineSubscriptionToken } from '../Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken' import { AuthenticateOfflineSubscriptionToken } from '../Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken'
import { SubscriptionCancelledEventHandler } from '../Domain/Handler/SubscriptionCancelledEventHandler' import { SubscriptionCancelledEventHandler } from '../Domain/Handler/SubscriptionCancelledEventHandler'
import { ContentDecoder, ContentDecoderInterface, ProtocolVersion } from '@standardnotes/common' import {
ContentDecoder,
ContentDecoderInterface,
ProtocolVersion,
Uuid,
UuidValidator,
ValidatorInterface,
} from '@standardnotes/common'
import { GetUserOfflineSubscription } from '../Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription' import { GetUserOfflineSubscription } from '../Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription'
import { ApiGatewayOfflineAuthMiddleware } from '../Controller/ApiGatewayOfflineAuthMiddleware' import { ApiGatewayOfflineAuthMiddleware } from '../Controller/ApiGatewayOfflineAuthMiddleware'
import { UserEmailChangedEventHandler } from '../Domain/Handler/UserEmailChangedEventHandler' import { UserEmailChangedEventHandler } from '../Domain/Handler/UserEmailChangedEventHandler'
import { SettingsAssociationServiceInterface } from '../Domain/Setting/SettingsAssociationServiceInterface' import { SettingsAssociationServiceInterface } from '../Domain/Setting/SettingsAssociationServiceInterface'
import { SettingsAssociationService } from '../Domain/Setting/SettingsAssociationService' import { SettingsAssociationService } from '../Domain/Setting/SettingsAssociationService'
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
import { SubscriptionSyncRequestedEventHandler } from '../Domain/Handler/SubscriptionSyncRequestedEventHandler' import { SubscriptionSyncRequestedEventHandler } from '../Domain/Handler/SubscriptionSyncRequestedEventHandler'
import { import {
CrossServiceTokenData, CrossServiceTokenData,
@@ -149,13 +155,13 @@ import {
TokenEncoder, TokenEncoder,
TokenEncoderInterface, TokenEncoderInterface,
ValetTokenData, ValetTokenData,
WebSocketConnectionTokenData,
} from '@standardnotes/security' } from '@standardnotes/security'
import { FileUploadedEventHandler } from '../Domain/Handler/FileUploadedEventHandler' import { FileUploadedEventHandler } from '../Domain/Handler/FileUploadedEventHandler'
import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken' import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken'
import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount' import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount'
import { ListedAccountCreatedEventHandler } from '../Domain/Handler/ListedAccountCreatedEventHandler' import { ListedAccountCreatedEventHandler } from '../Domain/Handler/ListedAccountCreatedEventHandler'
import { ListedAccountDeletedEventHandler } from '../Domain/Handler/ListedAccountDeletedEventHandler' import { ListedAccountDeletedEventHandler } from '../Domain/Handler/ListedAccountDeletedEventHandler'
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
import { FileRemovedEventHandler } from '../Domain/Handler/FileRemovedEventHandler' import { FileRemovedEventHandler } from '../Domain/Handler/FileRemovedEventHandler'
import { UserDisabledSessionUserAgentLoggingEventHandler } from '../Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler' import { UserDisabledSessionUserAgentLoggingEventHandler } from '../Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler'
import { SettingInterpreterInterface } from '../Domain/Setting/SettingInterpreterInterface' import { SettingInterpreterInterface } from '../Domain/Setting/SettingInterpreterInterface'
@@ -196,11 +202,14 @@ import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUser
import { AuthController } from '../Controller/AuthController' import { AuthController } from '../Controller/AuthController'
import { VerifyPredicate } from '../Domain/UseCase/VerifyPredicate/VerifyPredicate' import { VerifyPredicate } from '../Domain/UseCase/VerifyPredicate/VerifyPredicate'
import { PredicateVerificationRequestedEventHandler } from '../Domain/Handler/PredicateVerificationRequestedEventHandler' import { PredicateVerificationRequestedEventHandler } from '../Domain/Handler/PredicateVerificationRequestedEventHandler'
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
import { PaymentFailedEventHandler } from '../Domain/Handler/PaymentFailedEventHandler' import { PaymentFailedEventHandler } from '../Domain/Handler/PaymentFailedEventHandler'
import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEventHandler' import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEventHandler'
import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler' import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler'
import { SubscriptionInvitesController } from '../Controller/SubscriptionInvitesController' import { SubscriptionInvitesController } from '../Controller/SubscriptionInvitesController'
import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
import { WebSocketsController } from '../Controller/WebSocketsController'
import { WebSocketServerInterface } from '@standardnotes/api'
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher') const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -264,6 +273,7 @@ export class ContainerConfigLoader {
// Controller // Controller
container.bind<AuthController>(TYPES.AuthController).to(AuthController) container.bind<AuthController>(TYPES.AuthController).to(AuthController)
container.bind<SubscriptionInvitesController>(TYPES.SubscriptionInvitesController).to(SubscriptionInvitesController) container.bind<SubscriptionInvitesController>(TYPES.SubscriptionInvitesController).to(SubscriptionInvitesController)
container.bind<WebSocketServerInterface>(TYPES.WebSocketsController).to(WebSocketsController)
// Repositories // Repositories
container.bind<SessionRepositoryInterface>(TYPES.SessionRepository).to(MySQLSessionRepository) container.bind<SessionRepositoryInterface>(TYPES.SessionRepository).to(MySQLSessionRepository)
@@ -362,6 +372,12 @@ export class ContainerConfigLoader {
container.bind(TYPES.AUTH_JWT_TTL).toConstantValue(+env.get('AUTH_JWT_TTL')) container.bind(TYPES.AUTH_JWT_TTL).toConstantValue(+env.get('AUTH_JWT_TTL'))
container.bind(TYPES.VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET', true)) container.bind(TYPES.VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET', true))
container.bind(TYPES.VALET_TOKEN_TTL).toConstantValue(+env.get('VALET_TOKEN_TTL', true)) container.bind(TYPES.VALET_TOKEN_TTL).toConstantValue(+env.get('VALET_TOKEN_TTL', true))
container
.bind(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)
.toConstantValue(env.get('WEB_SOCKET_CONNECTION_TOKEN_SECRET', true))
container
.bind(TYPES.WEB_SOCKET_CONNECTION_TOKEN_TTL)
.toConstantValue(+env.get('WEB_SOCKET_CONNECTION_TOKEN_TTL', true))
container.bind(TYPES.ENCRYPTION_SERVER_KEY).toConstantValue(env.get('ENCRYPTION_SERVER_KEY')) container.bind(TYPES.ENCRYPTION_SERVER_KEY).toConstantValue(env.get('ENCRYPTION_SERVER_KEY'))
container.bind(TYPES.ACCESS_TOKEN_AGE).toConstantValue(env.get('ACCESS_TOKEN_AGE')) container.bind(TYPES.ACCESS_TOKEN_AGE).toConstantValue(env.get('ACCESS_TOKEN_AGE'))
container.bind(TYPES.REFRESH_TOKEN_AGE).toConstantValue(env.get('REFRESH_TOKEN_AGE')) container.bind(TYPES.REFRESH_TOKEN_AGE).toConstantValue(env.get('REFRESH_TOKEN_AGE'))
@@ -420,9 +436,6 @@ export class ContainerConfigLoader {
container container
.bind<CreateOfflineSubscriptionToken>(TYPES.CreateOfflineSubscriptionToken) .bind<CreateOfflineSubscriptionToken>(TYPES.CreateOfflineSubscriptionToken)
.to(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<CreateValetToken>(TYPES.CreateValetToken).to(CreateValetToken)
container.bind<CreateListedAccount>(TYPES.CreateListedAccount).to(CreateListedAccount) container.bind<CreateListedAccount>(TYPES.CreateListedAccount).to(CreateListedAccount)
container.bind<InviteToSharedSubscription>(TYPES.InviteToSharedSubscription).to(InviteToSharedSubscription) container.bind<InviteToSharedSubscription>(TYPES.InviteToSharedSubscription).to(InviteToSharedSubscription)
@@ -441,6 +454,10 @@ export class ContainerConfigLoader {
container.bind<GetSubscriptionSetting>(TYPES.GetSubscriptionSetting).to(GetSubscriptionSetting) container.bind<GetSubscriptionSetting>(TYPES.GetSubscriptionSetting).to(GetSubscriptionSetting)
container.bind<GetUserAnalyticsId>(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId) container.bind<GetUserAnalyticsId>(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId)
container.bind<VerifyPredicate>(TYPES.VerifyPredicate).to(VerifyPredicate) container.bind<VerifyPredicate>(TYPES.VerifyPredicate).to(VerifyPredicate)
container
.bind<CreateWebSocketConnectionToken>(TYPES.CreateWebSocketConnectionToken)
.to(CreateWebSocketConnectionToken)
container.bind<CreateCrossServiceToken>(TYPES.CreateCrossServiceToken).to(CreateCrossServiceToken)
// Handlers // Handlers
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler) container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
@@ -513,6 +530,11 @@ export class ContainerConfigLoader {
container container
.bind<TokenDecoderInterface<OfflineUserTokenData>>(TYPES.OfflineUserTokenDecoder) .bind<TokenDecoderInterface<OfflineUserTokenData>>(TYPES.OfflineUserTokenDecoder)
.toConstantValue(new TokenDecoder<OfflineUserTokenData>(container.get(TYPES.AUTH_JWT_SECRET))) .toConstantValue(new TokenDecoder<OfflineUserTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
container
.bind<TokenDecoderInterface<WebSocketConnectionTokenData>>(TYPES.WebSocketConnectionTokenDecoder)
.toConstantValue(
new TokenDecoder<WebSocketConnectionTokenData>(container.get(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)),
)
container container
.bind<TokenEncoderInterface<OfflineUserTokenData>>(TYPES.OfflineUserTokenEncoder) .bind<TokenEncoderInterface<OfflineUserTokenData>>(TYPES.OfflineUserTokenEncoder)
.toConstantValue(new TokenEncoder<OfflineUserTokenData>(container.get(TYPES.AUTH_JWT_SECRET))) .toConstantValue(new TokenEncoder<OfflineUserTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
@@ -525,6 +547,11 @@ export class ContainerConfigLoader {
container container
.bind<TokenEncoderInterface<ValetTokenData>>(TYPES.ValetTokenEncoder) .bind<TokenEncoderInterface<ValetTokenData>>(TYPES.ValetTokenEncoder)
.toConstantValue(new TokenEncoder<ValetTokenData>(container.get(TYPES.VALET_TOKEN_SECRET))) .toConstantValue(new TokenEncoder<ValetTokenData>(container.get(TYPES.VALET_TOKEN_SECRET)))
container
.bind<TokenEncoderInterface<WebSocketConnectionTokenData>>(TYPES.WebSocketConnectionTokenEncoder)
.toConstantValue(
new TokenEncoder<WebSocketConnectionTokenData>(container.get(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)),
)
container.bind<AuthenticationMethodResolver>(TYPES.AuthenticationMethodResolver).to(AuthenticationMethodResolver) container.bind<AuthenticationMethodResolver>(TYPES.AuthenticationMethodResolver).to(AuthenticationMethodResolver)
container.bind<DomainEventFactory>(TYPES.DomainEventFactory).to(DomainEventFactory) container.bind<DomainEventFactory>(TYPES.DomainEventFactory).to(DomainEventFactory)
container.bind<AxiosInstance>(TYPES.HTTPClient).toConstantValue(axios.create()) container.bind<AxiosInstance>(TYPES.HTTPClient).toConstantValue(axios.create())
@@ -559,6 +586,7 @@ export class ContainerConfigLoader {
container container
.bind<StatisticsStoreInterface>(TYPES.StatisticsStore) .bind<StatisticsStoreInterface>(TYPES.StatisticsStore)
.toConstantValue(new RedisStatisticsStore(periodKeyGenerator, container.get(TYPES.Redis))) .toConstantValue(new RedisStatisticsStore(periodKeyGenerator, container.get(TYPES.Redis)))
container.bind<ValidatorInterface<Uuid>>(TYPES.UuidValidator).toConstantValue(new UuidValidator())
if (env.get('SNS_TOPIC_ARN', true)) { if (env.get('SNS_TOPIC_ARN', true)) {
container container
+8 -3
View File
@@ -6,6 +6,7 @@ const TYPES = {
// Controller // Controller
AuthController: Symbol.for('AuthController'), AuthController: Symbol.for('AuthController'),
SubscriptionInvitesController: Symbol.for('SubscriptionInvitesController'), SubscriptionInvitesController: Symbol.for('SubscriptionInvitesController'),
WebSocketsController: Symbol.for('WebSocketsController'),
// Repositories // Repositories
UserRepository: Symbol.for('UserRepository'), UserRepository: Symbol.for('UserRepository'),
SessionRepository: Symbol.for('SessionRepository'), SessionRepository: Symbol.for('SessionRepository'),
@@ -60,6 +61,8 @@ const TYPES = {
AUTH_JWT_TTL: Symbol.for('AUTH_JWT_TTL'), AUTH_JWT_TTL: Symbol.for('AUTH_JWT_TTL'),
VALET_TOKEN_SECRET: Symbol.for('VALET_TOKEN_SECRET'), VALET_TOKEN_SECRET: Symbol.for('VALET_TOKEN_SECRET'),
VALET_TOKEN_TTL: Symbol.for('VALET_TOKEN_TTL'), VALET_TOKEN_TTL: Symbol.for('VALET_TOKEN_TTL'),
WEB_SOCKET_CONNECTION_TOKEN_SECRET: Symbol.for('WEB_SOCKET_CONNECTION_TOKEN_SECRET'),
WEB_SOCKET_CONNECTION_TOKEN_TTL: Symbol.for('WEB_SOCKET_CONNECTION_TOKEN_TTL'),
ENCRYPTION_SERVER_KEY: Symbol.for('ENCRYPTION_SERVER_KEY'), ENCRYPTION_SERVER_KEY: Symbol.for('ENCRYPTION_SERVER_KEY'),
ACCESS_TOKEN_AGE: Symbol.for('ACCESS_TOKEN_AGE'), ACCESS_TOKEN_AGE: Symbol.for('ACCESS_TOKEN_AGE'),
REFRESH_TOKEN_AGE: Symbol.for('REFRESH_TOKEN_AGE'), REFRESH_TOKEN_AGE: Symbol.for('REFRESH_TOKEN_AGE'),
@@ -112,9 +115,6 @@ const TYPES = {
AuthenticateSubscriptionToken: Symbol.for('AuthenticateSubscriptionToken'), AuthenticateSubscriptionToken: Symbol.for('AuthenticateSubscriptionToken'),
CreateOfflineSubscriptionToken: Symbol.for('CreateOfflineSubscriptionToken'), CreateOfflineSubscriptionToken: Symbol.for('CreateOfflineSubscriptionToken'),
AuthenticateOfflineSubscriptionToken: Symbol.for('AuthenticateOfflineSubscriptionToken'), AuthenticateOfflineSubscriptionToken: Symbol.for('AuthenticateOfflineSubscriptionToken'),
MuteFailedBackupsEmails: Symbol.for('MuteFailedBackupsEmails'),
MuteSignInEmails: Symbol.for('MuteSignInEmails'),
MuteMarketingEmails: Symbol.for('MuteMarketingEmails'),
CreateValetToken: Symbol.for('CreateValetToken'), CreateValetToken: Symbol.for('CreateValetToken'),
CreateListedAccount: Symbol.for('CreateListedAccount'), CreateListedAccount: Symbol.for('CreateListedAccount'),
InviteToSharedSubscription: Symbol.for('InviteToSharedSubscription'), InviteToSharedSubscription: Symbol.for('InviteToSharedSubscription'),
@@ -125,6 +125,8 @@ const TYPES = {
GetSubscriptionSetting: Symbol.for('GetSubscriptionSetting'), GetSubscriptionSetting: Symbol.for('GetSubscriptionSetting'),
GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'), GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'),
VerifyPredicate: Symbol.for('VerifyPredicate'), VerifyPredicate: Symbol.for('VerifyPredicate'),
CreateWebSocketConnectionToken: Symbol.for('CreateWebSocketConnectionToken'),
CreateCrossServiceToken: Symbol.for('CreateCrossServiceToken'),
// Handlers // Handlers
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'), UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'), AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
@@ -166,6 +168,8 @@ const TYPES = {
CrossServiceTokenEncoder: Symbol.for('CrossServiceTokenEncoder'), CrossServiceTokenEncoder: Symbol.for('CrossServiceTokenEncoder'),
SessionTokenEncoder: Symbol.for('SessionTokenEncoder'), SessionTokenEncoder: Symbol.for('SessionTokenEncoder'),
ValetTokenEncoder: Symbol.for('ValetTokenEncoder'), ValetTokenEncoder: Symbol.for('ValetTokenEncoder'),
WebSocketConnectionTokenEncoder: Symbol.for('WebSocketConnectionTokenEncoder'),
WebSocketConnectionTokenDecoder: Symbol.for('WebSocketConnectionTokenDecoder'),
AuthenticationMethodResolver: Symbol.for('AuthenticationMethodResolver'), AuthenticationMethodResolver: Symbol.for('AuthenticationMethodResolver'),
DomainEventPublisher: Symbol.for('DomainEventPublisher'), DomainEventPublisher: Symbol.for('DomainEventPublisher'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'), DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),
@@ -189,6 +193,7 @@ const TYPES = {
UserSubscriptionService: Symbol.for('UserSubscriptionService'), UserSubscriptionService: Symbol.for('UserSubscriptionService'),
AnalyticsStore: Symbol.for('AnalyticsStore'), AnalyticsStore: Symbol.for('AnalyticsStore'),
StatisticsStore: Symbol.for('StatisticsStore'), StatisticsStore: Symbol.for('StatisticsStore'),
UuidValidator: Symbol.for('UuidValidator'),
} }
export default TYPES export default TYPES
@@ -7,23 +7,16 @@ import { results } from 'inversify-express-utils'
import { User } from '../Domain/User/User' import { User } from '../Domain/User/User'
import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures'
import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' 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', () => { describe('InternalController', () => {
let getUserFeatures: GetUserFeatures let getUserFeatures: GetUserFeatures
let getSetting: GetSetting let getSetting: GetSetting
let muteFailedBackupsEmails: MuteFailedBackupsEmails
let muteSignInEmails: MuteSignInEmails
let muteMarketingEmails: MuteMarketingEmails
let request: express.Request let request: express.Request
let response: express.Response let response: express.Response
let user: User let user: User
const createController = () => const createController = () => new InternalController(getUserFeatures, getSetting)
new InternalController(getUserFeatures, getSetting, muteFailedBackupsEmails, muteSignInEmails, muteMarketingEmails)
beforeEach(() => { beforeEach(() => {
user = {} as jest.Mocked<User> user = {} as jest.Mocked<User>
@@ -35,15 +28,6 @@ describe('InternalController', () => {
getSetting = {} as jest.Mocked<GetSetting> getSetting = {} as jest.Mocked<GetSetting>
getSetting.execute = jest.fn() 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 = { request = {
headers: {}, headers: {},
body: {}, body: {},
@@ -120,83 +104,4 @@ describe('InternalController', () => {
expect(result.statusCode).toEqual(400) 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 { inject } from 'inversify'
import { import {
BaseHttpController, BaseHttpController,
@@ -10,18 +10,12 @@ import {
import TYPES from '../Bootstrap/Types' import TYPES from '../Bootstrap/Types'
import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting'
import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' 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') @controller('/internal')
export class InternalController extends BaseHttpController { export class InternalController extends BaseHttpController {
constructor( constructor(
@inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures, @inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures,
@inject(TYPES.GetSetting) private doGetSetting: GetSetting, @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() super()
} }
@@ -54,50 +48,4 @@ export class InternalController extends BaseHttpController {
return this.json(result, 400) 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)
}
} }
@@ -9,43 +9,25 @@ import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser' import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
import { User } from '../Domain/User/User' import { User } from '../Domain/User/User'
import { Role } from '../Domain/Role/Role' import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/security'
import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
describe('SessionsController', () => { describe('SessionsController', () => {
let getActiveSessionsForUser: GetActiveSessionsForUser let getActiveSessionsForUser: GetActiveSessionsForUser
let authenticateRequest: AuthenticateRequest let authenticateRequest: AuthenticateRequest
let userProjector: ProjectorInterface<User>
let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
const jwtTTL = 60
let sessionProjector: ProjectorInterface<Session> let sessionProjector: ProjectorInterface<Session>
let roleProjector: ProjectorInterface<Role>
let session: Session let session: Session
let request: express.Request let request: express.Request
let response: express.Response let response: express.Response
let user: User let user: User
let role: Role let createCrossServiceToken: CreateCrossServiceToken
let getUserAnalyticsId: GetUserAnalyticsId
const createController = () => const createController = () =>
new SessionsController( new SessionsController(getActiveSessionsForUser, authenticateRequest, sessionProjector, createCrossServiceToken)
getActiveSessionsForUser,
authenticateRequest,
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
getUserAnalyticsId,
true,
jwtTTL,
)
beforeEach(() => { beforeEach(() => {
session = {} as jest.Mocked<Session> session = {} as jest.Mocked<Session>
user = {} as jest.Mocked<User> user = {} as jest.Mocked<User>
user.roles = Promise.resolve([role])
getActiveSessionsForUser = {} as jest.Mocked<GetActiveSessionsForUser> getActiveSessionsForUser = {} as jest.Mocked<GetActiveSessionsForUser>
getActiveSessionsForUser.execute = jest.fn().mockReturnValue({ sessions: [session] }) getActiveSessionsForUser.execute = jest.fn().mockReturnValue({ sessions: [session] })
@@ -53,21 +35,11 @@ describe('SessionsController', () => {
authenticateRequest = {} as jest.Mocked<AuthenticateRequest> authenticateRequest = {} as jest.Mocked<AuthenticateRequest>
authenticateRequest.execute = jest.fn() authenticateRequest.execute = jest.fn()
userProjector = {} as jest.Mocked<ProjectorInterface<User>>
userProjector.projectSimple = jest.fn().mockReturnValue({ bar: 'baz' })
roleProjector = {} as jest.Mocked<ProjectorInterface<Role>>
roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' })
sessionProjector = {} as jest.Mocked<ProjectorInterface<Session>> sessionProjector = {} as jest.Mocked<ProjectorInterface<Session>>
sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' }) sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' })
sessionProjector.projectSimple = jest.fn().mockReturnValue({ test: 'test' })
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<CrossServiceTokenData>> createCrossServiceToken = {} as jest.Mocked<CreateCrossServiceToken>
tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar') createCrossServiceToken.execute = jest.fn().mockReturnValue({ token: 'foobar' })
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 123 })
request = { request = {
params: {}, params: {},
@@ -114,75 +86,6 @@ describe('SessionsController', () => {
const httpResponseContent = await result.content.readAsStringAsync() const httpResponseContent = await result.content.readAsStringAsync()
const httpResponseJSON = JSON.parse(httpResponseContent) const httpResponseJSON = JSON.parse(httpResponseContent)
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
expect(httpResponseJSON.authToken).toEqual('foobar')
})
it('should validate a session from an incoming request - disabled analytics', async () => {
authenticateRequest.execute = jest.fn().mockReturnValue({
success: true,
user,
session,
})
request.headers.authorization = 'test'
const controller = new SessionsController(
getActiveSessionsForUser,
authenticateRequest,
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
getUserAnalyticsId,
false,
jwtTTL,
)
const httpResponse = await controller.validate(request)
expect(httpResponse).toBeInstanceOf(results.JsonResult)
const result = await httpResponse.executeAsync()
const httpResponseContent = await result.content.readAsStringAsync()
const httpResponseJSON = JSON.parse(httpResponseContent)
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
expect(httpResponseJSON.authToken).toEqual('foobar') expect(httpResponseJSON.authToken).toEqual('foobar')
}) })
@@ -12,26 +12,18 @@ import TYPES from '../Bootstrap/Types'
import { Session } from '../Domain/Session/Session' import { Session } from '../Domain/Session/Session'
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser' import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { Role } from '../Domain/Role/Role'
import { User } from '../Domain/User/User' import { User } from '../Domain/User/User'
import { ProjectorInterface } from '../Projection/ProjectorInterface' import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { SessionProjector } from '../Projection/SessionProjector' import { SessionProjector } from '../Projection/SessionProjector'
import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/security' import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { RoleName } from '@standardnotes/common'
import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@controller('/sessions') @controller('/sessions')
export class SessionsController extends BaseHttpController { export class SessionsController extends BaseHttpController {
constructor( constructor(
@inject(TYPES.GetActiveSessionsForUser) private getActiveSessionsForUser: GetActiveSessionsForUser, @inject(TYPES.GetActiveSessionsForUser) private getActiveSessionsForUser: GetActiveSessionsForUser,
@inject(TYPES.AuthenticateRequest) private authenticateRequest: AuthenticateRequest, @inject(TYPES.AuthenticateRequest) private authenticateRequest: AuthenticateRequest,
@inject(TYPES.UserProjector) private userProjector: ProjectorInterface<User>,
@inject(TYPES.SessionProjector) private sessionProjector: ProjectorInterface<Session>, @inject(TYPES.SessionProjector) private sessionProjector: ProjectorInterface<Session>,
@inject(TYPES.RoleProjector) private roleProjector: ProjectorInterface<Role>, @inject(TYPES.CreateCrossServiceToken) private createCrossServiceToken: CreateCrossServiceToken,
@inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.ANALYTICS_ENABLED) private analyticsEnabled: boolean,
@inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number,
) { ) {
super() super()
} }
@@ -56,25 +48,12 @@ export class SessionsController extends BaseHttpController {
const user = authenticateRequestResponse.user as User const user = authenticateRequestResponse.user as User
const roles = await user.roles const result = await this.createCrossServiceToken.execute({
user,
session: authenticateRequestResponse.session,
})
const authTokenData: CrossServiceTokenData = { return this.json({ authToken: result.token })
user: this.projectUser(user),
roles: this.projectRoles(roles),
}
if (this.analyticsEnabled) {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
authTokenData.analyticsId = analyticsId
}
if (authenticateRequestResponse.session !== undefined) {
authTokenData.session = this.projectSession(authenticateRequestResponse.session)
}
const authToken = this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL)
return this.json({ authToken })
} }
@httpGet('/', TYPES.AuthMiddleware, TYPES.SessionMiddleware) @httpGet('/', TYPES.AuthMiddleware, TYPES.SessionMiddleware)
@@ -93,36 +72,4 @@ export class SessionsController extends BaseHttpController {
), ),
) )
} }
private projectUser(user: User): { uuid: string; email: string } {
return <{ uuid: string; email: string }>this.userProjector.projectSimple(user)
}
private projectSession(session: Session): {
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
} {
return <
{
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
}
>this.sessionProjector.projectSimple(session)
}
private projectRoles(roles: Array<Role>): Array<{ uuid: string; name: RoleName }> {
return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role))
}
} }
@@ -4,18 +4,23 @@ import { Request, Response } from 'express'
import { results } from 'inversify-express-utils' import { results } from 'inversify-express-utils'
import { ValetTokenController } from './ValetTokenController' import { ValetTokenController } from './ValetTokenController'
import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken' import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken'
import { Uuid, ValidatorInterface } from '@standardnotes/common'
describe('ValetTokenController', () => { describe('ValetTokenController', () => {
let createValetToken: CreateValetToken let createValetToken: CreateValetToken
let uuidValidator: ValidatorInterface<Uuid>
let request: Request let request: Request
let response: Response let response: Response
const createController = () => new ValetTokenController(createValetToken) const createController = () => new ValetTokenController(createValetToken, uuidValidator)
beforeEach(() => { beforeEach(() => {
createValetToken = {} as jest.Mocked<CreateValetToken> createValetToken = {} as jest.Mocked<CreateValetToken>
createValetToken.execute = jest.fn().mockReturnValue({ success: true, valetToken: 'foobar' }) createValetToken.execute = jest.fn().mockReturnValue({ success: true, valetToken: 'foobar' })
uuidValidator = {} as jest.Mocked<ValidatorInterface<Uuid>>
uuidValidator.validate = jest.fn().mockReturnValue(true)
request = { request = {
body: { body: {
operation: 'write', operation: 'write',
@@ -42,6 +47,17 @@ describe('ValetTokenController', () => {
expect(await result.content.readAsStringAsync()).toEqual('{"success":true,"valetToken":"foobar"}') expect(await result.content.readAsStringAsync()).toEqual('{"success":true,"valetToken":"foobar"}')
}) })
it('should not create a valet token if the remote resource identifier is not a valid uuid', async () => {
uuidValidator.validate = jest.fn().mockReturnValue(false)
const httpResponse = <results.JsonResult>await createController().create(request, response)
const result = await httpResponse.executeAsync()
expect(createValetToken.execute).not.toHaveBeenCalled()
expect(result.statusCode).toEqual(400)
})
it('should create a read valet token for read only access session', async () => { it('should create a read valet token for read only access session', async () => {
response.locals.readOnlyAccess = true response.locals.readOnlyAccess = true
request.body.operation = 'read' request.body.operation = 'read'
@@ -11,12 +11,15 @@ import { CreateValetTokenPayload } from '@standardnotes/responses'
import TYPES from '../Bootstrap/Types' import TYPES from '../Bootstrap/Types'
import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken' import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken'
import { ErrorTag } from '@standardnotes/common' import { ErrorTag, Uuid, ValidatorInterface } from '@standardnotes/common'
import { ValetTokenOperation } from '@standardnotes/security' import { ValetTokenOperation } from '@standardnotes/security'
@controller('/valet-tokens', TYPES.ApiGatewayAuthMiddleware) @controller('/valet-tokens', TYPES.ApiGatewayAuthMiddleware)
export class ValetTokenController extends BaseHttpController { export class ValetTokenController extends BaseHttpController {
constructor(@inject(TYPES.CreateValetToken) private createValetKey: CreateValetToken) { constructor(
@inject(TYPES.CreateValetToken) private createValetKey: CreateValetToken,
@inject(TYPES.UuidValidator) private uuidValitor: ValidatorInterface<Uuid>,
) {
super() super()
} }
@@ -36,6 +39,20 @@ export class ValetTokenController extends BaseHttpController {
) )
} }
for (const resource of payload.resources) {
if (!this.uuidValitor.validate(resource.remoteIdentifier)) {
return this.json(
{
error: {
tag: ErrorTag.ParametersInvalid,
message: 'Invalid remote resource identifier.',
},
},
400,
)
}
}
const createValetKeyResponse = await this.createValetKey.execute({ const createValetKeyResponse = await this.createValetKey.execute({
userUuid: response.locals.user.uuid, userUuid: response.locals.user.uuid,
operation: payload.operation as ValetTokenOperation, operation: payload.operation as ValetTokenOperation,
@@ -1,65 +1,28 @@
import 'reflect-metadata' import 'reflect-metadata'
import * as express from 'express'
import { results } from 'inversify-express-utils'
import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection'
import { WebSocketsController } from './WebSocketsController' import { WebSocketsController } from './WebSocketsController'
import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection' import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
describe('WebSocketsController', () => { describe('WebSocketsController', () => {
let addWebSocketsConnection: AddWebSocketsConnection let createWebSocketConnectionToken: CreateWebSocketConnectionToken
let removeWebSocketsConnection: RemoveWebSocketsConnection
let request: express.Request
let response: express.Response
const createController = () => new WebSocketsController(addWebSocketsConnection, removeWebSocketsConnection) const createController = () => new WebSocketsController(createWebSocketConnectionToken)
beforeEach(() => { beforeEach(() => {
addWebSocketsConnection = {} as jest.Mocked<AddWebSocketsConnection> createWebSocketConnectionToken = {} as jest.Mocked<CreateWebSocketConnectionToken>
addWebSocketsConnection.execute = jest.fn() createWebSocketConnectionToken.execute = jest.fn().mockReturnValue({ token: 'foobar' })
removeWebSocketsConnection = {} as jest.Mocked<RemoveWebSocketsConnection>
removeWebSocketsConnection.execute = jest.fn()
request = {
body: {
userUuid: '1-2-3',
},
params: {},
headers: {},
} as jest.Mocked<express.Request>
request.params.connectionId = '2-3-4'
response = {
locals: {},
} as jest.Mocked<express.Response>
response.locals.user = {
uuid: '1-2-3',
}
}) })
it('should persist an established web sockets connection', async () => { it('should create a web sockets connection token', async () => {
const httpResponse = await createController().storeWebSocketsConnection(request, response) const response = await createController().createConnectionToken({ userUuid: '1-2-3' })
expect(httpResponse).toBeInstanceOf(results.JsonResult) expect(response).toEqual({
expect((<results.JsonResult>httpResponse).statusCode).toEqual(200) status: 200,
data: { token: 'foobar' },
expect(addWebSocketsConnection.execute).toHaveBeenCalledWith({
userUuid: '1-2-3',
connectionId: '2-3-4',
}) })
})
it('should remove a disconnected web sockets connection', async () => { expect(createWebSocketConnectionToken.execute).toHaveBeenCalledWith({
const httpResponse = await createController().deleteWebSocketsConnection(request) userUuid: '1-2-3',
expect(httpResponse).toBeInstanceOf(results.JsonResult)
expect((<results.JsonResult>httpResponse).statusCode).toEqual(200)
expect(removeWebSocketsConnection.execute).toHaveBeenCalledWith({
connectionId: '2-3-4',
}) })
}) })
}) })
@@ -1,45 +1,29 @@
import { Request, Response } from 'express'
import { inject } from 'inversify'
import { import {
BaseHttpController, HttpStatusCode,
controller, WebSocketConnectionTokenRequestParams,
httpDelete, WebSocketConnectionTokenResponse,
httpPost, WebSocketServerInterface,
// eslint-disable-next-line @typescript-eslint/no-unused-vars } from '@standardnotes/api'
results, import { inject, injectable } from 'inversify'
} from 'inversify-express-utils'
import TYPES from '../Bootstrap/Types' import TYPES from '../Bootstrap/Types'
import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection' import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection'
@controller('/sockets') @injectable()
export class WebSocketsController extends BaseHttpController { export class WebSocketsController implements WebSocketServerInterface {
constructor( constructor(
@inject(TYPES.AddWebSocketsConnection) private addWebSocketsConnection: AddWebSocketsConnection, @inject(TYPES.CreateWebSocketConnectionToken)
@inject(TYPES.RemoveWebSocketsConnection) private removeWebSocketsConnection: RemoveWebSocketsConnection, private createWebSocketConnectionToken: CreateWebSocketConnectionToken,
) { ) {}
super()
}
@httpPost('/:connectionId', TYPES.ApiGatewayAuthMiddleware) async createConnectionToken(
async storeWebSocketsConnection( params: WebSocketConnectionTokenRequestParams,
request: Request, ): Promise<WebSocketConnectionTokenResponse> {
response: Response, const result = await this.createWebSocketConnectionToken.execute({ userUuid: params.userUuid as string })
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
await this.addWebSocketsConnection.execute({
userUuid: response.locals.user.uuid,
connectionId: request.params.connectionId,
})
return this.json({ success: true }) return {
} status: HttpStatusCode.Success,
data: result,
@httpDelete('/:connectionId') }
async deleteWebSocketsConnection(
request: Request,
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId })
return this.json({ success: true })
} }
} }
@@ -82,6 +82,7 @@ describe('FeatureService', () => {
uuid: 'subscription-1-1-1', uuid: 'subscription-1-1-1',
createdAt: 111, createdAt: 111,
updatedAt: 222, updatedAt: 222,
renewedAt: null,
planName: SubscriptionName.PlusPlan, planName: SubscriptionName.PlusPlan,
endsAt: 555, endsAt: 555,
user: Promise.resolve(user), user: Promise.resolve(user),
@@ -95,6 +96,7 @@ describe('FeatureService', () => {
uuid: 'subscription-2-2-2', uuid: 'subscription-2-2-2',
createdAt: 222, createdAt: 222,
updatedAt: 333, updatedAt: 333,
renewedAt: null,
planName: SubscriptionName.ProPlan, planName: SubscriptionName.ProPlan,
endsAt: 777, endsAt: 777,
user: Promise.resolve(user), user: Promise.resolve(user),
@@ -108,6 +110,7 @@ describe('FeatureService', () => {
uuid: 'subscription-3-3-3-canceled', uuid: 'subscription-3-3-3-canceled',
createdAt: 111, createdAt: 111,
updatedAt: 222, updatedAt: 222,
renewedAt: null,
planName: SubscriptionName.PlusPlan, planName: SubscriptionName.PlusPlan,
endsAt: 333, endsAt: 333,
user: Promise.resolve(user), user: Promise.resolve(user),
@@ -121,6 +124,7 @@ describe('FeatureService', () => {
uuid: 'subscription-4-4-4-canceled', uuid: 'subscription-4-4-4-canceled',
createdAt: 111, createdAt: 111,
updatedAt: 222, updatedAt: 222,
renewedAt: null,
planName: SubscriptionName.PlusPlan, planName: SubscriptionName.PlusPlan,
endsAt: 333, endsAt: 333,
user: Promise.resolve(user), user: Promise.resolve(user),
@@ -240,6 +244,7 @@ describe('FeatureService', () => {
uuid: 'subscription-1-1-1', uuid: 'subscription-1-1-1',
createdAt: 111, createdAt: 111,
updatedAt: 222, updatedAt: 222,
renewedAt: null,
planName: 'non existing plan name' as SubscriptionName, planName: 'non existing plan name' as SubscriptionName,
endsAt: 555, endsAt: 555,
user: Promise.resolve(user), user: Promise.resolve(user),
@@ -1,7 +1,7 @@
import 'reflect-metadata' import 'reflect-metadata'
import { PaymentSuccessEvent } from '@standardnotes/domain-events' import { PaymentSuccessEvent } from '@standardnotes/domain-events'
import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics' import { AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics'
import { PaymentSuccessEventHandler } from './PaymentSuccessEventHandler' import { PaymentSuccessEventHandler } from './PaymentSuccessEventHandler'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
@@ -38,14 +38,41 @@ describe('PaymentSuccessEventHandler', () => {
event.payload = { event.payload = {
userEmail: 'test@test.com', userEmail: 'test@test.com',
amount: 12.45, 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) await createHandler().handle(event)
expect(analyticsStore.markActivity).toHaveBeenCalled() 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 () => { it('should not mark payment failed for analytics if user is not found', async () => {
@@ -5,6 +5,7 @@ import {
StatisticsMeasure, StatisticsMeasure,
StatisticsStoreInterface, StatisticsStoreInterface,
} from '@standardnotes/analytics' } from '@standardnotes/analytics'
import { PaymentType, SubscriptionBillingFrequency, SubscriptionName } from '@standardnotes/common'
import { DomainEventHandlerInterface, PaymentSuccessEvent } from '@standardnotes/domain-events' import { DomainEventHandlerInterface, PaymentSuccessEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
@@ -14,6 +15,47 @@ import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
@injectable() @injectable()
export class PaymentSuccessEventHandler implements DomainEventHandlerInterface { 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( constructor(
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId, @inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@@ -34,10 +76,21 @@ export class PaymentSuccessEventHandler implements DomainEventHandlerInterface {
Period.ThisMonth, Period.ThisMonth,
]) ])
await this.statisticsStore.incrementMeasure(StatisticsMeasure.Income, event.payload.amount, [ const statisticMeasures = [StatisticsMeasure.Income]
Period.Today,
Period.ThisWeek, const detailedMeasure = this.DETAILED_MEASURES.get(event.payload.subscriptionName as SubscriptionName)
Period.ThisMonth, ?.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 = {} as jest.Mocked<Logger>
logger.warn = jest.fn() logger.warn = jest.fn()
logger.info = jest.fn() logger.info = jest.fn()
logger.debug = jest.fn()
event = {} as jest.Mocked<PredicateVerificationRequestedEvent> event = {} as jest.Mocked<PredicateVerificationRequestedEvent>
event.meta = { event.meta = {
@@ -23,7 +23,7 @@ export class PredicateVerificationRequestedEventHandler implements DomainEventHa
) {} ) {}
async handle(event: PredicateVerificationRequestedEvent): Promise<void> { 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 let userUuid = event.meta.correlation.userIdentifier
if (event.meta.correlation.userIdentifierType === 'email') { 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}`, `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 () => { it('should update subscription cancelled - user not found', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null) userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
@@ -27,14 +27,6 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface, @inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
) {} ) {}
async handle(event: SubscriptionCancelledEvent): Promise<void> { async handle(event: SubscriptionCancelledEvent): Promise<void> {
if (event.payload.offline) {
await this.updateOfflineSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
return
}
await this.updateSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
const user = await this.userRepository.findOneByEmail(event.payload.userEmail) const user = await this.userRepository.findOneByEmail(event.payload.userEmail)
if (user !== null) { if (user !== null) {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid }) const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
@@ -43,25 +35,17 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
Period.ThisWeek, Period.ThisWeek,
Period.ThisMonth, 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 remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
await this.statisticsStore.incrementMeasure(
StatisticsMeasure.SubscriptionCancelToExpireTime,
remainingSubscriptionTime,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
}
} }
await this.trackSubscriptionStatistics(event)
if (event.payload.offline) {
await this.updateOfflineSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
return
}
await this.updateSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
} }
private async updateSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> { private async updateSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> {
@@ -71,4 +55,39 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
private async updateOfflineSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> { private async updateOfflineSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> {
await this.offlineUserSubscriptionRepository.updateCancelled(subscriptionId, true, timestamp) 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 { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { UserSubscription } from '../Subscription/UserSubscription' import { UserSubscription } from '../Subscription/UserSubscription'
import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
describe('SubscriptionExpiredEventHandler', () => { describe('SubscriptionExpiredEventHandler', () => {
let userRepository: UserRepositoryInterface let userRepository: UserRepositoryInterface
@@ -23,6 +25,9 @@ describe('SubscriptionExpiredEventHandler', () => {
let user: User let user: User
let event: SubscriptionExpiredEvent let event: SubscriptionExpiredEvent
let timestamp: number let timestamp: number
let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
const createHandler = () => const createHandler = () =>
new SubscriptionExpiredEventHandler( new SubscriptionExpiredEventHandler(
@@ -30,6 +35,9 @@ describe('SubscriptionExpiredEventHandler', () => {
userSubscriptionRepository, userSubscriptionRepository,
offlineUserSubscriptionRepository, offlineUserSubscriptionRepository,
roleService, roleService,
getUserAnalyticsId,
analyticsStore,
statisticsStore,
logger, logger,
) )
@@ -50,6 +58,7 @@ describe('SubscriptionExpiredEventHandler', () => {
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface> userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.updateEndsAt = jest.fn() userSubscriptionRepository.updateEndsAt = jest.fn()
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
userSubscriptionRepository.findBySubscriptionId = jest userSubscriptionRepository.findBySubscriptionId = jest
.fn() .fn()
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>]) .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
@@ -72,6 +81,15 @@ describe('SubscriptionExpiredEventHandler', () => {
offline: false, 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 = {} as jest.Mocked<Logger>
logger.info = jest.fn() logger.info = jest.fn()
logger.warn = jest.fn() logger.warn = jest.fn()
@@ -8,6 +8,14 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import {
AnalyticsStoreInterface,
AnalyticsActivity,
Period,
StatisticsMeasure,
StatisticsStoreInterface,
} from '@standardnotes/analytics'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable() @injectable()
export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterface { export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterface {
@@ -17,6 +25,9 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf
@inject(TYPES.OfflineUserSubscriptionRepository) @inject(TYPES.OfflineUserSubscriptionRepository)
private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface,
@inject(TYPES.RoleService) private roleService: RoleServiceInterface, @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, @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.updateSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp)
await this.removeRoleFromSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName) 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( private async removeRoleFromSubscriptionUsers(
@@ -73,12 +73,14 @@ describe('SubscriptionPurchasedEventHandler', () => {
statisticsStore = {} as jest.Mocked<StatisticsStoreInterface> statisticsStore = {} as jest.Mocked<StatisticsStoreInterface>
statisticsStore.incrementMeasure = jest.fn() statisticsStore.incrementMeasure = jest.fn()
statisticsStore.setMeasure = jest.fn()
timer = {} as jest.Mocked<TimerInterface> timer = {} as jest.Mocked<TimerInterface>
timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1) timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1)
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface> userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(0) userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(0)
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription)
offlineUserSubscription = {} as jest.Mocked<OfflineUserSubscription> offlineUserSubscription = {} as jest.Mocked<OfflineUserSubscription>
@@ -113,6 +115,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface> analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn() analyticsStore.markActivity = jest.fn()
analyticsStore.unmarkActivity = jest.fn()
logger = {} as jest.Mocked<Logger> logger = {} as jest.Mocked<Logger>
logger.info = jest.fn() logger.info = jest.fn()
@@ -132,6 +135,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
subscription, subscription,
SubscriptionName.ProPlan, SubscriptionName.ProPlan,
'123',
) )
}) })
@@ -76,6 +76,7 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
userSubscription, userSubscription,
event.payload.subscriptionName, event.payload.subscriptionName,
user.uuid,
) )
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid }) const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
@@ -84,6 +85,11 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
Period.ThisWeek, Period.ThisWeek,
Period.ThisMonth, 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) const limitedDiscountPurchased = ['limited-10', 'limited-20'].includes(event.payload.discountCode as string)
if (limitedDiscountPurchased) { if (limitedDiscountPurchased) {
@@ -98,6 +104,19 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte
event.payload.timestamp - this.timer.convertDateToMicroseconds(user.createdAt), event.payload.timestamp - this.timer.convertDateToMicroseconds(user.createdAt),
[Period.Today, Period.ThisWeek, Period.ThisMonth], [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( expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
subscription, subscription,
SubscriptionName.ProPlan, SubscriptionName.ProPlan,
'123',
) )
}) })
@@ -58,6 +58,7 @@ export class SubscriptionReassignedEventHandler implements DomainEventHandlerInt
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
userSubscription, userSubscription,
event.payload.subscriptionName, event.payload.subscriptionName,
user.uuid,
) )
} }
@@ -14,7 +14,7 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface'
import { UserSubscription } from '../Subscription/UserSubscription' import { UserSubscription } from '../Subscription/UserSubscription'
import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId' import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { AnalyticsStoreInterface } from '@standardnotes/analytics' import { AnalyticsActivity, AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics'
describe('SubscriptionRefundedEventHandler', () => { describe('SubscriptionRefundedEventHandler', () => {
let userRepository: UserRepositoryInterface let userRepository: UserRepositoryInterface
@@ -27,6 +27,7 @@ describe('SubscriptionRefundedEventHandler', () => {
let timestamp: number let timestamp: number
let getUserAnalyticsId: GetUserAnalyticsId let getUserAnalyticsId: GetUserAnalyticsId
let analyticsStore: AnalyticsStoreInterface let analyticsStore: AnalyticsStoreInterface
let statisticsStore: StatisticsStoreInterface
const createHandler = () => const createHandler = () =>
new SubscriptionRefundedEventHandler( new SubscriptionRefundedEventHandler(
@@ -36,6 +37,7 @@ describe('SubscriptionRefundedEventHandler', () => {
roleService, roleService,
getUserAnalyticsId, getUserAnalyticsId,
analyticsStore, analyticsStore,
statisticsStore,
logger, logger,
) )
@@ -56,6 +58,8 @@ describe('SubscriptionRefundedEventHandler', () => {
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface> userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.updateEndsAt = jest.fn() userSubscriptionRepository.updateEndsAt = jest.fn()
userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(1)
userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13)
userSubscriptionRepository.findBySubscriptionId = jest userSubscriptionRepository.findBySubscriptionId = jest
.fn() .fn()
.mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>]) .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked<UserSubscription>])
@@ -83,6 +87,10 @@ describe('SubscriptionRefundedEventHandler', () => {
analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface> analyticsStore = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn() 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 = {} as jest.Mocked<Logger>
logger.info = jest.fn() logger.info = jest.fn()
@@ -119,4 +127,33 @@ describe('SubscriptionRefundedEventHandler', () => {
expect(roleService.removeUserRole).not.toHaveBeenCalled() expect(roleService.removeUserRole).not.toHaveBeenCalled()
expect(userSubscriptionRepository.updateEndsAt).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 { DomainEventHandlerInterface, SubscriptionRefundedEvent } from '@standardnotes/domain-events'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
import { Logger } from 'winston' import { Logger } from 'winston'
@@ -8,7 +8,13 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' 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' import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
@injectable() @injectable()
@@ -21,6 +27,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
@inject(TYPES.RoleService) private roleService: RoleServiceInterface, @inject(TYPES.RoleService) private roleService: RoleServiceInterface,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId, @inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface, @inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface,
@inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface,
@inject(TYPES.Logger) private logger: Logger, @inject(TYPES.Logger) private logger: Logger,
) {} ) {}
@@ -47,6 +54,8 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
Period.ThisWeek, Period.ThisWeek,
Period.ThisMonth, Period.ThisMonth,
]) ])
await this.markChurnActivity(analyticsId, user.uuid)
} }
private async removeRoleFromSubscriptionUsers( private async removeRoleFromSubscriptionUsers(
@@ -66,4 +75,30 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter
private async updateOfflineSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise<void> { private async updateOfflineSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise<void> {
await this.offlineUserSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp) 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 = {} as jest.Mocked<AnalyticsStoreInterface>
analyticsStore.markActivity = jest.fn() analyticsStore.markActivity = jest.fn()
analyticsStore.unmarkActivity = jest.fn()
logger = {} as jest.Mocked<Logger> logger = {} as jest.Mocked<Logger>
logger.warn = jest.fn() logger.warn = jest.fn()
@@ -66,6 +66,11 @@ export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterf
Period.ThisWeek, Period.ThisWeek,
Period.ThisMonth, 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> { private async addRoleToSubscriptionUsers(subscriptionId: number, subscriptionName: SubscriptionName): Promise<void> {
@@ -130,6 +130,7 @@ describe('SubscriptionSyncRequestedEventHandler', () => {
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
subscription, subscription,
SubscriptionName.ProPlan, SubscriptionName.ProPlan,
'123',
) )
expect(settingService.createOrReplace).toHaveBeenCalledWith({ expect(settingService.createOrReplace).toHaveBeenCalledWith({
@@ -89,6 +89,7 @@ export class SubscriptionSyncRequestedEventHandler implements DomainEventHandler
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
userSubscription, userSubscription,
event.payload.subscriptionName, event.payload.subscriptionName,
user.uuid,
) )
await this.settingService.createOrReplace({ await this.settingService.createOrReplace({
@@ -4,4 +4,5 @@ export type SettingDescription = {
value: string value: string
sensitive: boolean sensitive: boolean
serverEncryptionVersion: EncryptionVersion serverEncryptionVersion: EncryptionVersion
replaceable: boolean
} }
@@ -52,6 +52,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false, sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted, serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: MuteSignInEmailsOption.NotMuted, value: MuteSignInEmailsOption.NotMuted,
replaceable: false,
}, },
], ],
[ [
@@ -60,6 +61,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false, sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted, serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: MuteMarketingEmailsOption.NotMuted, value: MuteMarketingEmailsOption.NotMuted,
replaceable: false,
}, },
], ],
[ [
@@ -68,6 +70,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false, sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted, serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: LogSessionUserAgentOption.Enabled, value: LogSessionUserAgentOption.Enabled,
replaceable: false,
}, },
], ],
]) ])
@@ -79,6 +82,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false, sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted, serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: LogSessionUserAgentOption.Disabled, value: LogSessionUserAgentOption.Disabled,
replaceable: false,
}, },
], ],
]) ])
@@ -13,6 +13,7 @@ import { SubscriptionName } from '@standardnotes/common'
import { User } from '../User/User' import { User } from '../User/User'
import { SettingFactoryInterface } from './SettingFactoryInterface' import { SettingFactoryInterface } from './SettingFactoryInterface'
import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface' import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
describe('SubscriptionSettingService', () => { describe('SubscriptionSettingService', () => {
let setting: SubscriptionSetting let setting: SubscriptionSetting
@@ -22,6 +23,7 @@ describe('SubscriptionSettingService', () => {
let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface
let subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface let subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface
let settingDecrypter: SettingDecrypterInterface let settingDecrypter: SettingDecrypterInterface
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
let logger: Logger let logger: Logger
const createService = () => const createService = () =>
@@ -30,6 +32,7 @@ describe('SubscriptionSettingService', () => {
subscriptionSettingRepository, subscriptionSettingRepository,
subscriptionSettingsAssociationService, subscriptionSettingsAssociationService,
settingDecrypter, settingDecrypter,
userSubscriptionRepository,
logger, logger,
) )
@@ -51,6 +54,16 @@ describe('SubscriptionSettingService', () => {
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null) subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
subscriptionSettingRepository.save = jest.fn().mockImplementation((setting) => setting) 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 = {} as jest.Mocked<SubscriptionSettingsAssociationServiceInterface>
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue( subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
new Map([ new Map([
@@ -60,6 +73,7 @@ describe('SubscriptionSettingService', () => {
value: '0', value: '0',
sensitive: 0, sensitive: 0,
serverEncryptionVersion: EncryptionVersion.Unencrypted, serverEncryptionVersion: EncryptionVersion.Unencrypted,
replaceable: true,
}, },
], ],
]), ]),
@@ -75,7 +89,91 @@ describe('SubscriptionSettingService', () => {
}) })
it('should create default settings for a subscription', async () => { 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) expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
}) })
@@ -85,7 +183,11 @@ describe('SubscriptionSettingService', () => {
.fn() .fn()
.mockReturnValue(undefined) .mockReturnValue(undefined)
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan) await createService().applyDefaultSubscriptionSettingsForSubscription(
userSubscription,
SubscriptionName.PlusPlan,
'1-2-3',
)
expect(subscriptionSettingRepository.save).not.toHaveBeenCalled() 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 { SubscriptionSettingName } from '@standardnotes/settings'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
import { Logger } from 'winston' import { Logger } from 'winston'
@@ -16,6 +16,7 @@ import { FindSubscriptionSettingDTO } from './FindSubscriptionSettingDTO'
import { SubscriptionSettingRepositoryInterface } from './SubscriptionSettingRepositoryInterface' import { SubscriptionSettingRepositoryInterface } from './SubscriptionSettingRepositoryInterface'
import { SettingFactoryInterface } from './SettingFactoryInterface' import { SettingFactoryInterface } from './SettingFactoryInterface'
import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface' import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
@injectable() @injectable()
export class SubscriptionSettingService implements SubscriptionSettingServiceInterface { export class SubscriptionSettingService implements SubscriptionSettingServiceInterface {
@@ -26,12 +27,14 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
@inject(TYPES.SubscriptionSettingsAssociationService) @inject(TYPES.SubscriptionSettingsAssociationService)
private subscriptionSettingAssociationService: SubscriptionSettingsAssociationServiceInterface, private subscriptionSettingAssociationService: SubscriptionSettingsAssociationServiceInterface,
@inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface, @inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface,
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
@inject(TYPES.Logger) private logger: Logger, @inject(TYPES.Logger) private logger: Logger,
) {} ) {}
async applyDefaultSubscriptionSettingsForSubscription( async applyDefaultSubscriptionSettingsForSubscription(
userSubscription: UserSubscription, userSubscription: UserSubscription,
subscriptionName: SubscriptionName, subscriptionName: SubscriptionName,
userUuid: Uuid,
): Promise<void> { ): Promise<void> {
const defaultSettingsWithValues = const defaultSettingsWithValues =
await this.subscriptionSettingAssociationService.getDefaultSettingsAndValuesForSubscriptionName(subscriptionName) await this.subscriptionSettingAssociationService.getDefaultSettingsAndValuesForSubscriptionName(subscriptionName)
@@ -43,6 +46,15 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
for (const settingName of defaultSettingsWithValues.keys()) { for (const settingName of defaultSettingsWithValues.keys()) {
const setting = defaultSettingsWithValues.get(settingName) as SettingDescription 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({ await this.createOrReplace({
userSubscription, userSubscription,
@@ -114,4 +126,22 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
subscriptionSetting, 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 { UserSubscription } from '../Subscription/UserSubscription'
import { CreateOrReplaceSubscriptionSettingDTO } from './CreateOrReplaceSubscriptionSettingDTO' import { CreateOrReplaceSubscriptionSettingDTO } from './CreateOrReplaceSubscriptionSettingDTO'
@@ -10,6 +10,7 @@ export interface SubscriptionSettingServiceInterface {
applyDefaultSubscriptionSettingsForSubscription( applyDefaultSubscriptionSettingsForSubscription(
userSubscription: UserSubscription, userSubscription: UserSubscription,
subscriptionName: SubscriptionName, subscriptionName: SubscriptionName,
userUuid: Uuid,
): Promise<void> ): Promise<void>
createOrReplace(dto: CreateOrReplaceSubscriptionSettingDTO): Promise<CreateOrReplaceSubscriptionSettingResponse> createOrReplace(dto: CreateOrReplaceSubscriptionSettingDTO): Promise<CreateOrReplaceSubscriptionSettingResponse>
findSubscriptionSettingWithDecryptedValue(dto: FindSubscriptionSettingDTO): Promise<SubscriptionSetting | null> findSubscriptionSettingWithDecryptedValue(dto: FindSubscriptionSettingDTO): Promise<SubscriptionSetting | null>
@@ -61,6 +61,7 @@ describe('SubscriptionSettingsAssociationService', () => {
sensitive: false, sensitive: false,
serverEncryptionVersion: 0, serverEncryptionVersion: 0,
value: '107374182400', value: '107374182400',
replaceable: true,
}) })
}) })
@@ -88,6 +89,7 @@ describe('SubscriptionSettingsAssociationService', () => {
sensitive: false, sensitive: false,
serverEncryptionVersion: 0, serverEncryptionVersion: 0,
value: '104857600', value: '104857600',
replaceable: true,
}) })
}) })
@@ -28,7 +28,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
new Map([ new Map([
[ [
SubscriptionSettingName.FileUploadBytesUsed, 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([ new Map([
[ [
SubscriptionSettingName.FileUploadBytesUsed, 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, sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted, serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: (await this.getFileUploadLimit(subscriptionName)).toString(), value: (await this.getFileUploadLimit(subscriptionName)).toString(),
replaceable: true,
}) })
return defaultSettings return defaultSettings
@@ -3,6 +3,6 @@ import { Uuid } from '@standardnotes/common'
import { SubscriptionToken } from './SubscriptionToken' import { SubscriptionToken } from './SubscriptionToken'
export interface SubscriptionTokenRepositoryInterface { export interface SubscriptionTokenRepositoryInterface {
save(subscriptionToken: SubscriptionToken): Promise<void> save(subscriptionToken: SubscriptionToken): Promise<boolean>
getUserUuidByToken(token: string): Promise<Uuid | undefined> getUserUuidByToken(token: string): Promise<Uuid | undefined>
} }
@@ -34,6 +34,13 @@ export class UserSubscription {
@Index('updated_at') @Index('updated_at')
declare updatedAt: number declare updatedAt: number
@Column({
name: 'renewed_at',
type: 'bigint',
nullable: true,
})
declare renewedAt: number | null
@Column({ @Column({
type: 'tinyint', type: 'tinyint',
width: 1, width: 1,
@@ -6,10 +6,12 @@ export interface UserSubscriptionRepositoryInterface {
findOneByUuid(uuid: Uuid): Promise<UserSubscription | null> findOneByUuid(uuid: Uuid): Promise<UserSubscription | null>
countByUserUuid(userUuid: Uuid): Promise<number> countByUserUuid(userUuid: Uuid): Promise<number>
findOneByUserUuid(userUuid: Uuid): Promise<UserSubscription | null> findOneByUserUuid(userUuid: Uuid): Promise<UserSubscription | null>
findByUserUuid(userUuid: Uuid): Promise<UserSubscription[]>
findOneByUserUuidAndSubscriptionId(userUuid: Uuid, subscriptionId: number): Promise<UserSubscription | null> findOneByUserUuidAndSubscriptionId(userUuid: Uuid, subscriptionId: number): Promise<UserSubscription | null>
findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]> findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]>
findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]> findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]>
updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise<void> updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise<void>
updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise<void> updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise<void>
countActiveSubscriptions(): Promise<number>
save(subscription: UserSubscription): Promise<UserSubscription> save(subscription: UserSubscription): Promise<UserSubscription>
} }
@@ -104,6 +104,7 @@ describe('AcceptSharedSubscriptionInvitation', () => {
expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith(
inviteeSubscription, inviteeSubscription,
'PLUS_PLAN', 'PLUS_PLAN',
'123',
) )
}) })
@@ -75,6 +75,7 @@ export class AcceptSharedSubscriptionInvitation implements UseCaseInterface {
await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription(
inviteeSubscription, inviteeSubscription,
inviteeSubscription.planName as SubscriptionName, inviteeSubscription.planName as SubscriptionName,
invitee.uuid,
) )
return { return {
@@ -0,0 +1,173 @@
import 'reflect-metadata'
import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
import { Role } from '../../Role/Role'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetUserAnalyticsId } from '../GetUserAnalyticsId/GetUserAnalyticsId'
import { CreateCrossServiceToken } from './CreateCrossServiceToken'
describe('CreateCrossServiceToken', () => {
let userProjector: ProjectorInterface<User>
let sessionProjector: ProjectorInterface<Session>
let roleProjector: ProjectorInterface<Role>
let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
let getUserAnalyticsId: GetUserAnalyticsId
let userRepository: UserRepositoryInterface
const jwtTTL = 60
let session: Session
let user: User
let role: Role
const createUseCase = (analyticsEnabled = true) =>
new CreateCrossServiceToken(
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
getUserAnalyticsId,
userRepository,
analyticsEnabled,
jwtTTL,
)
beforeEach(() => {
session = {} as jest.Mocked<Session>
user = {} as jest.Mocked<User>
user.roles = Promise.resolve([role])
userProjector = {} as jest.Mocked<ProjectorInterface<User>>
userProjector.projectSimple = jest.fn().mockReturnValue({ bar: 'baz' })
roleProjector = {} as jest.Mocked<ProjectorInterface<Role>>
roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' })
sessionProjector = {} as jest.Mocked<ProjectorInterface<Session>>
sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' })
sessionProjector.projectSimple = jest.fn().mockReturnValue({ test: 'test' })
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<CrossServiceTokenData>>
tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar')
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 123 })
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
})
it('should create a cross service token for user', async () => {
await createUseCase().execute({
user,
session,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
})
it('should create a cross service token for user - analytics disabled', async () => {
await createUseCase(false).execute({
user,
session,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
})
it('should create a cross service token for user without a session', async () => {
await createUseCase().execute({
user,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
user: {
bar: 'baz',
},
},
60,
)
})
it('should create a cross service token for user by user uuid', async () => {
await createUseCase().execute({
userUuid: '1-2-3',
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
user: {
bar: 'baz',
},
},
60,
)
})
it('should throw an error if user does not exist', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
let caughtError = null
try {
await createUseCase().execute({
userUuid: '1-2-3',
})
} catch (error) {
caughtError = error
}
expect(caughtError).not.toBeNull()
})
})
@@ -0,0 +1,91 @@
import { RoleName } from '@standardnotes/common'
import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Role } from '../../Role/Role'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetUserAnalyticsId } from '../GetUserAnalyticsId/GetUserAnalyticsId'
import { UseCaseInterface } from '../UseCaseInterface'
import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO'
import { CreateCrossServiceTokenResponse } from './CreateCrossServiceTokenResponse'
@injectable()
export class CreateCrossServiceToken implements UseCaseInterface {
constructor(
@inject(TYPES.UserProjector) private userProjector: ProjectorInterface<User>,
@inject(TYPES.SessionProjector) private sessionProjector: ProjectorInterface<Session>,
@inject(TYPES.RoleProjector) private roleProjector: ProjectorInterface<Role>,
@inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.ANALYTICS_ENABLED) private analyticsEnabled: boolean,
@inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number,
) {}
async execute(dto: CreateCrossServiceTokenDTO): Promise<CreateCrossServiceTokenResponse> {
let user: User | undefined | null = dto.user
if (user === undefined && dto.userUuid !== undefined) {
user = await this.userRepository.findOneByUuid(dto.userUuid)
}
if (!user) {
throw new Error(`Could not find user with uuid ${dto.userUuid}`)
}
const roles = await user.roles
const authTokenData: CrossServiceTokenData = {
user: this.projectUser(user),
roles: this.projectRoles(roles),
}
if (this.analyticsEnabled) {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
authTokenData.analyticsId = analyticsId
}
if (dto.session !== undefined) {
authTokenData.session = this.projectSession(dto.session)
}
return {
token: this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL),
}
}
private projectUser(user: User): { uuid: string; email: string } {
return <{ uuid: string; email: string }>this.userProjector.projectSimple(user)
}
private projectSession(session: Session): {
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
} {
return <
{
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
}
>this.sessionProjector.projectSimple(session)
}
private projectRoles(roles: Array<Role>): Array<{ uuid: string; name: RoleName }> {
return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role))
}
}
@@ -0,0 +1,13 @@
import { Either, Uuid } from '@standardnotes/common'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
export type CreateCrossServiceTokenDTO = Either<
{
user: User
session?: Session
},
{
userUuid: Uuid
}
>
@@ -0,0 +1,3 @@
export type CreateCrossServiceTokenResponse = {
token: string
}
@@ -5,17 +5,19 @@ import { TimerInterface } from '@standardnotes/time'
import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface' import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface'
import { CreateSubscriptionToken } from './CreateSubscriptionToken' import { CreateSubscriptionToken } from './CreateSubscriptionToken'
import { Logger } from 'winston'
describe('CreateSubscriptionToken', () => { describe('CreateSubscriptionToken', () => {
let subscriptionTokenRepository: SubscriptionTokenRepositoryInterface let subscriptionTokenRepository: SubscriptionTokenRepositoryInterface
let cryptoNode: CryptoNode let cryptoNode: CryptoNode
let timer: TimerInterface let timer: TimerInterface
let logger: Logger
const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer) const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer, logger)
beforeEach(() => { beforeEach(() => {
subscriptionTokenRepository = {} as jest.Mocked<SubscriptionTokenRepositoryInterface> subscriptionTokenRepository = {} as jest.Mocked<SubscriptionTokenRepositoryInterface>
subscriptionTokenRepository.save = jest.fn() subscriptionTokenRepository.save = jest.fn().mockReturnValue(true)
cryptoNode = {} as jest.Mocked<CryptoNode> cryptoNode = {} as jest.Mocked<CryptoNode>
cryptoNode.generateRandomKey = jest.fn().mockReturnValueOnce('random-string') cryptoNode.generateRandomKey = jest.fn().mockReturnValueOnce('random-string')
@@ -23,6 +25,9 @@ describe('CreateSubscriptionToken', () => {
timer = {} as jest.Mocked<TimerInterface> timer = {} as jest.Mocked<TimerInterface>
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1) timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1)
timer.getUTCDateNHoursAhead = jest.fn().mockReturnValue(new Date(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 () => { it('should create an subscription token and persist it', async () => {
@@ -36,4 +41,19 @@ describe('CreateSubscriptionToken', () => {
expiresAt: 1, 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 { CryptoNode } from '@standardnotes/sncrypto-node'
import { TimerInterface } from '@standardnotes/time' import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../../Bootstrap/Types' import TYPES from '../../../Bootstrap/Types'
import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface' import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface'
@@ -15,6 +16,7 @@ export class CreateSubscriptionToken implements UseCaseInterface {
private subscriptionTokenRepository: SubscriptionTokenRepositoryInterface, private subscriptionTokenRepository: SubscriptionTokenRepositoryInterface,
@inject(TYPES.CryptoNode) private cryptoNode: CryptoNode, @inject(TYPES.CryptoNode) private cryptoNode: CryptoNode,
@inject(TYPES.Timer) private timer: TimerInterface, @inject(TYPES.Timer) private timer: TimerInterface,
@inject(TYPES.Logger) private logger: Logger,
) {} ) {}
async execute(dto: CreateSubscriptionTokenDTO): Promise<CreateSubscriptionTokenResponse> { async execute(dto: CreateSubscriptionTokenDTO): Promise<CreateSubscriptionTokenResponse> {
@@ -26,7 +28,13 @@ export class CreateSubscriptionToken implements UseCaseInterface {
expiresAt: this.timer.convertStringDateToMicroseconds(this.timer.getUTCDateNHoursAhead(3).toString()), 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 { return {
subscriptionToken, subscriptionToken,
@@ -0,0 +1,25 @@
import 'reflect-metadata'
import { TokenEncoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
import { CreateWebSocketConnectionToken } from './CreateWebSocketConnectionToken'
describe('CreateWebSocketConnection', () => {
let tokenEncoder: TokenEncoderInterface<WebSocketConnectionTokenData>
const tokenTTL = 30
const createUseCase = () => new CreateWebSocketConnectionToken(tokenEncoder, tokenTTL)
beforeEach(() => {
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<WebSocketConnectionTokenData>>
tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar')
})
it('should create a web socket connection token', async () => {
const result = await createUseCase().execute({ userUuid: '1-2-3' })
expect(result.token).toEqual('foobar')
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith({ userUuid: '1-2-3' }, 30)
})
})
@@ -0,0 +1,3 @@
export type CreateWebSocketConnectionDTO = {
userUuid: string
}
@@ -0,0 +1,3 @@
export type CreateWebSocketConnectionResponse = {
token: string
}
@@ -0,0 +1,26 @@
import { TokenEncoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { UseCaseInterface } from '../UseCaseInterface'
import { CreateWebSocketConnectionDTO } from './CreateWebSocketConnectionDTO'
import { CreateWebSocketConnectionResponse } from './CreateWebSocketConnectionResponse'
@injectable()
export class CreateWebSocketConnectionToken implements UseCaseInterface {
constructor(
@inject(TYPES.WebSocketConnectionTokenEncoder)
private tokenEncoder: TokenEncoderInterface<WebSocketConnectionTokenData>,
@inject(TYPES.WEB_SOCKET_CONNECTION_TOKEN_TTL) private tokenTTL: number,
) {}
async execute(dto: CreateWebSocketConnectionDTO): Promise<CreateWebSocketConnectionResponse> {
const data: WebSocketConnectionTokenData = {
userUuid: dto.userUuid,
}
return {
token: this.tokenEncoder.encodeExpirableToken(data, this.tokenTTL),
}
}
}
@@ -11,6 +11,7 @@ import { UserSubscription } from '../../Subscription/UserSubscription'
import { RoleName } from '@standardnotes/common' import { RoleName } from '@standardnotes/common'
import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType' import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation' import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation'
import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
describe('InviteToSharedSubscription', () => { describe('InviteToSharedSubscription', () => {
let userSubscriptionRepository: UserSubscriptionRepositoryInterface let userSubscriptionRepository: UserSubscriptionRepositoryInterface
@@ -187,7 +188,7 @@ describe('InviteToSharedSubscription', () => {
it('should not create an invitation if it already exists', async () => { it('should not create an invitation if it already exists', async () => {
sharedSubscriptionInvitationRepository.findOneByInviteeAndInviterEmail = jest sharedSubscriptionInvitationRepository.findOneByInviteeAndInviterEmail = jest
.fn() .fn()
.mockReturnValue({} as jest.Mocked<SharedSubscriptionInvitation>) .mockReturnValue({ status: InvitationStatus.Sent } as jest.Mocked<SharedSubscriptionInvitation>)
expect( expect(
await createUseCase().execute({ await createUseCase().execute({
@@ -205,4 +206,27 @@ describe('InviteToSharedSubscription', () => {
expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled() expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).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.inviteeIdentifier,
dto.inviterEmail, dto.inviterEmail,
) )
if (existingInvitation !== null) { if (existingInvitation !== null && existingInvitation.status !== InvitationStatus.Canceled) {
return { return {
success: false, 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
}

Some files were not shown because too many files have changed in this diff Show More