Compare commits

..

16 Commits

Author SHA1 Message Date
Phillip Thelen 9aed479296 add semicolon 2026-03-25 11:54:56 +01:00
Phillip Thelen 47c156d9b1 add key prefix 2026-03-25 11:51:51 +01:00
Phillip Thelen e6418e4356 add space 2026-03-25 11:48:03 +01:00
Phillip Thelen 3c23989e99 use bullmq directly to schedule jobs 2026-03-25 11:37:02 +01:00
Phillip Thelen 2d1f341256 refactor redis setup into own file and use ioredis 2026-03-25 11:35:35 +01:00
Kalista Payne 44adfd611a fix(lint): no-undef 2026-03-23 19:31:12 -05:00
Kalista Payne ab50c41287 fix(tests): remove tests
These can potentially be tested in the worker's suite? They target functionality that the group leave route handles within the deletion flow
2026-03-23 17:18:01 -05:00
Kalista Payne c43abe82fe Revert "fix(deletion): handle group leave logic on app server still"
This reverts commit 9db541f4c3.
2026-03-23 08:51:53 -05:00
Kalista Payne ac0b4a324f fix(deletion): remove orphaned chat messages 2026-03-20 11:17:30 -05:00
Kalista Payne efa0a325a2 fix(text): don't break to new paragraph about Gems 2026-03-20 11:17:30 -05:00
Kalista Payne bc970d33ac fix(deletion): update delete/feedback form copy 2026-03-20 11:17:30 -05:00
Kalista Payne 09e432cf32 fix(test): adapt test for worker flow 2026-03-20 11:17:30 -05:00
Kalista Payne 40aa2e214d fix(import): bracket syntax 2026-03-20 11:17:30 -05:00
Kalista Payne 9f563b741d fix(lint): unused import 2026-03-20 11:17:30 -05:00
Kalista Payne 9db541f4c3 fix(deletion): handle group leave logic on app server still 2026-03-20 11:17:30 -05:00
Kalista Payne ce4a20e3d8 WIP(delete): remove business logic from controller 2026-03-20 11:17:30 -05:00
103 changed files with 2778 additions and 959 deletions
-1
View File
@@ -21,4 +21,3 @@ services:
timeout: 30s
start_period: 0s
retries: 30
+407 -188
View File
@@ -24,6 +24,7 @@
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"bootstrap": "^4.6.2",
"bullmq": "^5.71.1",
"compression": "^1.8.1",
"cookie-session": "^2.1.1",
"coupon-code": "^0.4.5",
@@ -48,6 +49,7 @@
"heapdump": "^0.3.15",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"ioredis": "^5.10.1",
"js2xmlparser": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^2.1.5",
@@ -57,7 +59,7 @@
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.23.0",
"mongoose": "^8.9.5",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
@@ -71,7 +73,6 @@
"pp-ipn": "^1.1.0",
"ps-tree": "^1.0.0",
"rate-limiter-flexible": "^2.4.2",
"redis": "^3.1.2",
"remove-markdown": "^0.5.0",
"rimraf": "^3.0.2",
"short-uuid": "^4.2.2",
@@ -2624,19 +2625,6 @@
"node": ">=12.0.0"
}
},
"node_modules/@google-cloud/trace-agent/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@google-cloud/trace-agent/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -2837,6 +2825,12 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw=="
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3065,14 +3059,92 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
"integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
"integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -6329,7 +6401,6 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz",
"integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
@@ -6339,15 +6410,13 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/bl/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -6363,7 +6432,6 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -6620,6 +6688,52 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bullmq": {
"version": "5.71.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.1.tgz",
"integrity": "sha512-kOBfdcsHmO6wwmIjpersoVdYQ7jkjTgky4Yop0loc7QwSdgxliSzD69U9ijZuRrkyCJwz5p5eqxeGeQkJ0YGZQ==",
"license": "MIT",
"dependencies": {
"cron-parser": "4.9.0",
"ioredis": "5.10.1",
"msgpackr": "1.11.5",
"node-abort-controller": "3.1.1",
"semver": "7.7.4",
"tslib": "2.8.1",
"uuid": "11.1.0"
}
},
"node_modules/bullmq/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bullmq/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/bullmq/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -7100,6 +7214,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/coa": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz",
@@ -7623,6 +7746,18 @@
"node": "*"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
@@ -8161,6 +8296,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
"dev": true,
"engines": {
"node": ">=0.10"
}
@@ -11246,6 +11382,18 @@
"node": ">=12"
}
},
"node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -11989,19 +12137,6 @@
"node": ">=12"
}
},
"node_modules/google-auth-library/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/google-auth-library/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -13471,6 +13606,39 @@
"node": ">=0.10.0"
}
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ioredis/node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/iota-array": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
@@ -14660,6 +14828,12 @@
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.flattendeep": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
@@ -14676,6 +14850,12 @@
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -14865,6 +15045,15 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
@@ -15575,14 +15764,128 @@
}
},
"node_modules/mongodb": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dev": true,
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
"integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz",
"integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==",
"license": "MIT",
"dependencies": {
"tr46": "^5.0.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"dev": true,
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/mongoose": {
"version": "8.9.7",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.7.tgz",
"integrity": "sha512-mvNXmU0V8qZzMR/qoK2mjT4Ti2ALdtfS0teK+twxhlGkwzOD76V02/zWajTu2MJ7QyEmZe9OWvnJsIY0iAuX3Q==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.1",
"kareem": "2.6.3",
"mongodb": "~6.12.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/mongodb": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz",
"integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.9",
"bson": "^6.10.1",
"mongodb-connection-string-url": "^3.0.0"
},
"engines": {
"node": ">=16.20.1"
@@ -15593,7 +15896,7 @@
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.3.2",
"snappy": "^7.2.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
@@ -15620,72 +15923,6 @@
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongoose": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz",
"integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.4",
"kareem": "2.6.3",
"mongodb": "~6.20.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -15745,56 +15982,6 @@
"integrity": "sha512-jSTz73B/+pGTTvhu5Ym8xsG6+QqaWab53UXnXdNNlTijTdLvcHABCLJXudQiJxob5N1Mzr5EOSx5ziwn2sihPQ==",
"dev": true
},
"node_modules/monk/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/monk/node_modules/mongodb": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
@@ -15885,6 +16072,37 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multimatch": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz",
@@ -16151,6 +16369,12 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw=="
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -16222,6 +16446,21 @@
"ms": "^2.1.1"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-loggly-bulk": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/node-loggly-bulk/-/node-loggly-bulk-4.0.1.tgz",
@@ -17157,11 +17396,10 @@
}
},
"node_modules/optional-require": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.10.tgz",
"integrity": "sha512-0r3OB9EIQsP+a5HVATHq2ExIy2q/Vaffoo4IAikW1spCYswhLxqWQS0i3GwS3AdY/OIP4SWZHLGz8CMU558PGw==",
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz",
"integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"require-at": "^1.0.6"
},
@@ -18336,29 +18574,6 @@
"node": ">=0.10.0"
}
},
"node_modules/redis": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
"integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
"dependencies": {
"denque": "^1.5.0",
"redis-commands": "^1.7.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-redis"
}
},
"node_modules/redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@ -18643,7 +18858,6 @@
"resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz",
"integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=4"
}
@@ -18942,7 +19156,6 @@
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"sparse-bitfield": "^3.0.3"
@@ -19876,6 +20089,12 @@
"node": "*"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/static-extend": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
+3 -2
View File
@@ -19,6 +19,7 @@
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"bootstrap": "^4.6.2",
"bullmq": "^5.71.1",
"compression": "^1.8.1",
"cookie-session": "^2.1.1",
"coupon-code": "^0.4.5",
@@ -43,6 +44,7 @@
"heapdump": "^0.3.15",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"ioredis": "^5.10.1",
"js2xmlparser": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^2.1.5",
@@ -52,7 +54,7 @@
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.23.0",
"mongoose": "^8.9.5",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
@@ -66,7 +68,6 @@
"pp-ipn": "^1.1.0",
"ps-tree": "^1.0.0",
"rate-limiter-flexible": "^2.4.2",
"redis": "^3.1.2",
"remove-markdown": "^0.5.0",
"rimraf": "^3.0.2",
"short-uuid": "^4.2.2",
+560
View File
@@ -0,0 +1,560 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analyticsService', () => {
beforeEach(() => {
sandbox.stub(Amplitude.prototype, 'track').returns(Promise.resolve());
});
afterEach(() => {
sandbox.restore();
});
describe('#getServiceByEnvironment', () => {
it('returns mock methods when not in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
expect(analyticsService.getAnalyticsServiceByEnvironment())
.to.equal(analyticsService.mockAnalyticsService);
});
it('returns real methods when in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
expect(analyticsService.getAnalyticsServiceByEnvironment().track)
.to.equal(analyticsService.track);
expect(analyticsService.getAnalyticsServiceByEnvironment().trackPurchase)
.to.equal(analyticsService.trackPurchase);
});
});
describe('#track', () => {
let eventType; let
data;
beforeEach(() => {
eventType = 'Cron';
data = {
category: 'behavior',
uuid: 'unique-user-id',
resting: true,
cronCount: 5,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about event', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
category: 'behavior',
resting: true,
cronCount: 5,
},
});
}));
it('sends english item name for gear if itemKey is provided', () => {
data.itemKey = 'headAccessory_special_foxEars';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Fox Ears',
},
});
});
});
it('sends english item name for egg if itemKey is provided', () => {
data.itemKey = 'Wolf';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Wolf Egg',
},
});
});
});
it('sends english item name for food if itemKey is provided', () => {
data.itemKey = 'Cake_Skeleton';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Bare Bones Cake',
},
});
});
});
it('sends english item name for hatching potion if itemKey is provided', () => {
data.itemKey = 'Golden';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Golden Hatching Potion',
},
});
});
});
it('sends english item name for quest if itemKey is provided', () => {
data.itemKey = 'atom1';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Attack of the Mundane, Part 1: Dish Disaster!',
},
});
});
});
it('sends english item name for purchased spell if itemKey is provided', () => {
data.itemKey = 'seafoam';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Seafoam',
},
});
});
});
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
balance: 12,
loginIncentives: 1,
preferences: {
analyticsConsent: true,
},
};
data.user = user;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
balance: 12,
balanceGemAmount: 48,
loginIncentives: 1,
},
});
});
});
});
});
describe('#trackPurchase', () => {
let data;
beforeEach(() => {
data = {
uuid: 'user-id',
sku: 'paypal-checkout',
paymentMethod: 'PayPal',
itemPurchased: 'Gems',
purchaseValue: 8,
purchaseType: 'checkout',
gift: false,
quantity: 1,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about purchase', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
gift: false,
itemPurchased: 'Gems',
paymentMethod: 'PayPal',
purchaseType: 'checkout',
quantity: 1,
sku: 'paypal-checkout',
},
});
}));
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
preferences: {
analyticsConsent: true,
},
};
data.user = user;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
},
});
});
});
});
});
describe('mockAnalyticsService', () => {
it('has stubbed track method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('track');
});
it('has stubbed trackPurchase method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase');
});
});
});
+136 -116
View File
@@ -13,6 +13,7 @@ import { cron, cronWrapper } from '../../../../website/server/libs/cron';
import { model as User } from '../../../../website/server/models/user';
import * as Tasks from '../../../../website/server/models/task';
import common from '../../../../website/common';
import * as analytics from '../../../../website/server/libs/analyticsService';
import { model as Group } from '../../../../website/server/models/group';
const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime();
@@ -40,17 +41,20 @@ describe('cron', async () => {
},
},
});
sinon.spy(analytics, 'track');
});
afterEach(async () => {
if (clock !== null) clock.restore();
analytics.track.restore();
});
it('updates user.preferences.timezoneOffsetAtLastCron', async () => {
const timezoneUtcOffsetFromUserPrefs = -1;
await cron({
user, tasksByType, daysMissed, timezoneUtcOffsetFromUserPrefs,
user, tasksByType, daysMissed, analytics, timezoneUtcOffsetFromUserPrefs,
});
expect(user.preferences.timezoneOffsetAtLastCron).to.equal(1);
@@ -59,7 +63,7 @@ describe('cron', async () => {
it('resets user.items.lastDrop.count', async () => {
user.items.lastDrop.count = 4;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.items.lastDrop.count).to.equal(0);
});
@@ -67,11 +71,26 @@ describe('cron', async () => {
it('increments user cron count', async () => {
const cronCountBefore = user.flags.cronCount;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore);
});
it('calls analytics', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
it('calls analytics when user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
describe('end of the month perks', async () => {
beforeEach(async () => {
user.purchased.plan.customerId = 'subscribedId';
@@ -82,7 +101,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-12-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(2);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -93,7 +112,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-11-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(4);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -103,7 +122,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -112,7 +131,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
user.purchased.plan.dateUpdated = undefined;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -123,7 +142,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
@@ -131,7 +150,7 @@ describe('cron', async () => {
it('resets plan.dateUpdated on a new month', async () => {
const currentMonth = moment().startOf('month');
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(moment(user.purchased.plan.dateUpdated).startOf('month').isSame(currentMonth)).to.eql(true);
});
@@ -139,7 +158,7 @@ describe('cron', async () => {
it('increments plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.count).to.equal(1);
});
@@ -147,7 +166,7 @@ describe('cron', async () => {
it('increments plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.cumulativeCount).to.equal(1);
});
@@ -156,7 +175,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(2, 'months').toDate();
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.count).to.equal(2);
});
@@ -165,7 +184,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(3, 'months').toDate();
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.cumulativeCount).to.equal(3);
});
@@ -177,7 +196,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(1);
@@ -187,7 +206,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -195,7 +214,7 @@ describe('cron', async () => {
it('does not reset plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.customerId).to.exist;
});
@@ -206,7 +225,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.customerId).to.not.exist;
@@ -245,7 +264,7 @@ describe('cron', async () => {
// Add 2 days so that we're sure we're not affected by any start-of-month effects
// e.g., from time zone oddness.
await cron({
user: user1, tasksByType, daysMissed,
user: user1, tasksByType, daysMissed, analytics,
});
expect(user1.purchased.plan.consecutive.count).to.equal(1);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -257,7 +276,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user1, tasksByType, daysMissed,
user: user1, tasksByType, daysMissed, analytics,
});
expect(user1.purchased.plan.consecutive.count).to.equal(10);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -292,7 +311,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed,
user: user3, tasksByType, daysMissed, analytics,
});
expect(user3.purchased.plan.consecutive.count).to.equal(1);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -304,7 +323,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed,
user: user3, tasksByType, daysMissed, analytics,
});
expect(user3.purchased.plan.consecutive.count).to.equal(10);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -339,7 +358,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user6, tasksByType, daysMissed,
user: user6, tasksByType, daysMissed, analytics,
});
expect(user6.purchased.plan.consecutive.count).to.equal(1);
expect(user6.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -372,7 +391,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed,
user: user12, tasksByType, daysMissed, analytics,
});
expect(user12.purchased.plan.consecutive.count).to.equal(1);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -384,7 +403,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed,
user: user12, tasksByType, daysMissed, analytics,
});
expect(user12.purchased.plan.consecutive.count).to.equal(10);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -420,7 +439,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed,
user: user3g, tasksByType, daysMissed, analytics,
});
expect(user3g.purchased.plan.consecutive.count).to.equal(1);
expect(user3g.purchased.plan.cumulativeCount).to.equal(1);
@@ -433,7 +452,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed,
user: user3g, tasksByType, daysMissed, analytics,
});
// subscription has been erased by now
expect(user3g.purchased.plan.consecutive.count).to.equal(0);
@@ -452,7 +471,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -463,14 +482,14 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
it('does not reset plan.dateUpdated on a new month', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.dateUpdated).to.be.empty;
});
@@ -478,7 +497,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.count).to.equal(0);
});
@@ -486,7 +505,7 @@ describe('cron', async () => {
it('does not increment plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.cumulativeCount).to.equal(0);
});
@@ -494,7 +513,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(0);
});
@@ -502,7 +521,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0);
});
@@ -511,7 +530,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -519,7 +538,7 @@ describe('cron', async () => {
it('does nothing to plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.customerId).to.not.exist;
});
@@ -545,7 +564,7 @@ describe('cron', async () => {
it('should make uncompleted todos redder', async () => {
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore);
});
@@ -554,7 +573,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.todos[0].value).to.equal(valueBefore);
});
@@ -563,7 +582,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.history.todos).to.be.lengthOf(1);
@@ -589,7 +608,7 @@ describe('cron', async () => {
expect(user.tasksOrder.todos).to.be.lengthOf(3);
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
// user.tasksOrder.todos should be filtered while tasks by type remains unchanged
@@ -616,7 +635,7 @@ describe('cron', async () => {
const original = user.tasksOrder.todos; // Preserve the original order
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
let listsAreEqual = true;
@@ -656,7 +675,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].isDue).to.be.false;
});
@@ -667,7 +686,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().toDate();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].isDue).to.exist;
});
@@ -677,14 +696,14 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].nextDue.length).to.eql(6);
});
it('should add history', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].history).to.be.lengthOf(1);
});
@@ -692,7 +711,7 @@ describe('cron', async () => {
it('should set tasks completed to false', async () => {
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -701,7 +720,7 @@ describe('cron', async () => {
user.preferences.sleep = true;
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -710,7 +729,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -720,7 +739,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -730,7 +749,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -740,7 +759,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.be.lessThan(hpBefore);
});
@@ -751,7 +770,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(hpBefore);
});
@@ -765,7 +784,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
cronOverride({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -778,7 +797,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -789,7 +808,7 @@ describe('cron', async () => {
let hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
const hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp;
@@ -797,7 +816,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: true });
tasksByType.dailys[0].checklist.push({ title: 'test2', completed: false });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
const hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp;
@@ -810,7 +829,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(progress.down).to.equal(-1);
@@ -822,7 +841,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(progress.down).to.equal(0);
@@ -843,7 +862,7 @@ describe('cron', async () => {
tasksByType.dailys[1].frequency = 'daily';
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(48);
@@ -867,7 +886,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = false;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -878,7 +897,7 @@ describe('cron', async () => {
tasksByType.habits[0].up = false;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -890,7 +909,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].value).to.equal(1);
@@ -909,7 +928,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -922,7 +941,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -936,7 +955,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -945,7 +964,7 @@ describe('cron', async () => {
// should reset
daysMissed = 8;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -969,7 +988,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -983,7 +1002,7 @@ describe('cron', async () => {
// should reset after user CDS
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1007,7 +1026,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1017,7 +1036,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1041,7 +1060,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1065,7 +1084,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1079,7 +1098,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1088,7 +1107,7 @@ describe('cron', async () => {
// should reset
daysMissed = 32;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1113,7 +1132,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1137,7 +1156,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1147,7 +1166,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1180,7 +1199,7 @@ describe('cron', async () => {
user.stats.lvl = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.history.exp).to.have.lengthOf(1);
@@ -1193,7 +1212,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.achievements.perfect).to.equal(1);
@@ -1205,7 +1224,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = false;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.achievements.perfect).to.equal(0);
@@ -1219,7 +1238,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1237,7 +1256,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1261,7 +1280,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1288,7 +1307,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1314,7 +1333,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1341,7 +1360,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1362,7 +1381,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1382,7 +1401,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1401,7 +1420,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.mp).to.be.greaterThan(mpBefore);
@@ -1417,7 +1436,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.mp).to.equal(mpBefore);
@@ -1430,7 +1449,7 @@ describe('cron', async () => {
user.stats.mp = 120;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.mp).to.equal(common.statsComputed(user).maxMP);
@@ -1463,7 +1482,7 @@ describe('cron', async () => {
it('resets user progress', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.party.quest.progress.up).to.equal(0);
expect(user.party.quest.progress.down).to.equal(0);
@@ -1472,7 +1491,7 @@ describe('cron', async () => {
it('applies the user progress', async () => {
const progress = await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(progress.down).to.equal(-1);
});
@@ -1510,19 +1529,19 @@ describe('cron', async () => {
describe('login incentives', async () => {
it('increments incentive counter each cron', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
user.lastCron = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(2);
});
it('pushes a notification of the day\'s incentive each cron', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.notifications.length).to.eql(1);
expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE');
@@ -1530,13 +1549,13 @@ describe('cron', async () => {
it('replaces previous notifications', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
const filteredNotifications = user.notifications.filter(n => n.type === 'LOGIN_INCENTIVE');
@@ -1547,7 +1566,7 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if days are skipped in between', async () => {
daysMissed = 3;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
});
@@ -1555,14 +1574,14 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
});
it('awards user bard robes if login incentive is 1', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
expect(user.items.gear.owned.armor_special_bardRobes).to.eql(true);
@@ -1572,7 +1591,7 @@ describe('cron', async () => {
it('awards user incentive backgrounds if login incentive is 2', async () => {
user.loginIncentives = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(2);
expect(user.purchased.background.blue).to.eql(true);
@@ -1586,7 +1605,7 @@ describe('cron', async () => {
it('awards user Bard Hat if login incentive is 3', async () => {
user.loginIncentives = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(3);
expect(user.items.gear.owned.head_special_bardHat).to.eql(true);
@@ -1596,7 +1615,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 4', async () => {
user.loginIncentives = 3;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(4);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1606,7 +1625,7 @@ describe('cron', async () => {
it('awards user a Chocolate, Meat and Pink Contton Candy if login incentive is 5', async () => {
user.loginIncentives = 4;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(5);
@@ -1620,7 +1639,7 @@ describe('cron', async () => {
it('awards user moon quest if login incentive is 7', async () => {
user.loginIncentives = 6;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(7);
expect(user.items.quests.moon1).to.eql(1);
@@ -1630,7 +1649,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 10', async () => {
user.loginIncentives = 9;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(10);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1640,7 +1659,7 @@ describe('cron', async () => {
it('awards user a Strawberry, Patato and Blue Contton Candy if login incentive is 14', async () => {
user.loginIncentives = 13;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(14);
@@ -1654,7 +1673,7 @@ describe('cron', async () => {
it('awards user a bard instrument if login incentive is 18', async () => {
user.loginIncentives = 17;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(18);
expect(user.items.gear.owned.weapon_special_bardInstrument).to.eql(true);
@@ -1664,7 +1683,7 @@ describe('cron', async () => {
it('awards user second moon quest if login incentive is 22', async () => {
user.loginIncentives = 21;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(22);
expect(user.items.quests.moon2).to.eql(1);
@@ -1674,7 +1693,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 26', async () => {
user.loginIncentives = 25;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(26);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1684,7 +1703,7 @@ describe('cron', async () => {
it('awards user Fish, Milk, Rotten Meat and Honey if login incentive is 30', async () => {
user.loginIncentives = 29;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(30);
@@ -1699,7 +1718,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 35', async () => {
user.loginIncentives = 34;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(35);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1709,7 +1728,7 @@ describe('cron', async () => {
it('awards user the third moon quest if login incentive is 40', async () => {
user.loginIncentives = 39;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(40);
expect(user.items.quests.moon3).to.eql(1);
@@ -1719,7 +1738,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 45', async () => {
user.loginIncentives = 44;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(45);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1729,7 +1748,7 @@ describe('cron', async () => {
it('awards user a saddle if login incentive is 50', async () => {
user.loginIncentives = 49;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(50);
expect(user.items.food.Saddle).to.eql(1);
@@ -1747,6 +1766,7 @@ describe('cron wrapper', () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analytics;
});
afterEach(() => {
+22 -25
View File
@@ -1,9 +1,9 @@
/* eslint-disable global-require */
import got from 'got';
import nconf from 'nconf';
import requireAgain from 'require-again';
import { TAVERN_ID } from '../../../../website/server/models/group';
import { defer } from '../../../helpers/api-unit.helper';
import worker from '../../../../website/server/libs/worker';
function getUser () {
return {
@@ -127,7 +127,7 @@ describe('emails', () => {
let sendTxn = null;
beforeEach(() => {
sandbox.stub(got, 'post').returns(defer().promise);
sandbox.stub(worker, 'sendJob').returns(defer().promise);
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('IS_PROD').returns(true);
@@ -149,13 +149,12 @@ describe('emails', () => {
};
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
},
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
},
}));
});
@@ -168,7 +167,7 @@ describe('emails', () => {
};
sendTxn(mailingInfo, emailType);
expect(got.post).not.to.be.called;
expect(worker.sendJob).not.to.be.called;
});
it('throws error when mail target is only a string', async () => {
@@ -233,13 +232,12 @@ describe('emails', () => {
const mailingInfo = getUser();
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
},
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
},
}));
});
@@ -253,15 +251,14 @@ describe('emails', () => {
const variables = [];
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
&& value[0].vars[1].name === 'RECIPIENT_UNSUB_URL', 'matches personal variables'),
},
},
}));
});
-100
View File
@@ -1,100 +0,0 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import { model as User } from '../../../../website/server/models/user';
import { RegistrationEventModel } from '../../../../website/server/models/analytics/registrationEvent';
import { SubscriptionEventModel } from '../../../../website/server/models/analytics/subscriptionEvent';
describe('localAnalytics', () => {
let user;
let localAnalytics;
before(() => {
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('ANALYTICS_DB').returns('analytics');
nconfGetStub.withArgs('DISABLE_LOCAL_ANALYTICS').returns(false);
localAnalytics = requireAgain('../../../../website/server/libs/localAnalytics');
});
beforeEach(async () => {
user = new User({
auth: {
local: {
username: 'username',
email: 'email@example.com',
},
},
registeredThrough: 'habitica-web',
});
});
describe('trackRegistrationEvent', () => {
afterEach(async () => {
await RegistrationEventModel.deleteMany({});
});
it('creates a registration event when a user registers', async () => {
user._id = '00000000-0000-0000-0000-000000000001';
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.1' });
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(1);
expect(registrationEvents[0]).to.have.property('userId', user._id);
expect(registrationEvents[0]).to.have.property('ipAddress', '127.0.0.1');
});
it('saves the correct data to the database', async () => {
user._id = '00000000-0000-0000-0000-000000000002';
user.auth.google = { id: 'abc', emails: [{ value: 'email@example.com' }] };
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.2' });
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress', '127.0.0.2');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
});
describe('trackSubscriptionEvent', () => {
afterEach(async () => {
await SubscriptionEventModel.deleteMany({});
});
it('creates a subscription event when a user subscribes', async () => {
user._id = '00000000-0000-0000-0000-000000000003';
await localAnalytics.trackSubscriptionEvent({
eventType: 'subscribed',
user,
paymentMethod: 'stripe',
customerId: 'cus_123',
planId: 'plan_123',
});
const subscriptionEvents = await SubscriptionEventModel.find({ userId: user._id });
expect(subscriptionEvents).to.have.lengthOf(1);
expect(subscriptionEvents[0]).to.have.property('userId', user._id);
expect(subscriptionEvents[0]).to.have.property('eventType', 'subscribed');
expect(subscriptionEvents[0]).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvents[0]).to.have.property('customerId', 'cus_123');
expect(subscriptionEvents[0]).to.have.property('planId', 'plan_123');
});
it('creates a subscription event with cancellation reason when a user cancels', async () => {
user._id = '00000000-0000-0000-0000-000000000004';
await localAnalytics.trackSubscriptionEvent({
eventType: 'cancelled',
user,
paymentMethod: 'stripe',
customerId: 'cus_456',
planId: 'plan_456',
cancellationReason: 'No longer needed',
});
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvent).to.have.property('customerId', 'cus_456');
expect(subscriptionEvent).to.have.property('planId', 'plan_456');
expect(subscriptionEvent).to.have.property('cancellationReason', 'No longer needed');
});
});
});
+45 -62
View File
@@ -3,6 +3,7 @@ import moment from 'moment';
import * as sender from '../../../../../website/server/libs/email';
import common from '../../../../../website/common';
import api from '../../../../../website/server/libs/payments/payments';
import * as analytics from '../../../../../website/server/libs/analyticsService';
import * as notifications from '../../../../../website/server/libs/pushNotifications';
import { model as User } from '../../../../../website/server/models/user';
import { translate as t } from '../../../../helpers/api-integration/v3';
@@ -12,7 +13,6 @@ import {
import * as worldState from '../../../../../website/server/libs/worldState';
import { TransactionModel } from '../../../../../website/server/models/transaction';
import { REPEATING_EVENTS } from '../../../../../website/common/script/content/constants/events';
import { SubscriptionEventModel } from '../../../../../website/server/models/analytics/subscriptionEvent';
describe('payments/index', () => {
let user;
@@ -36,6 +36,8 @@ describe('payments/index', () => {
sandbox.stub(sender, 'sendTxn');
sandbox.stub(user, 'sendMessage');
sandbox.stub(analytics.mockAnalyticsService, 'trackPurchase');
sandbox.stub(analytics.mockAnalyticsService, 'track');
sandbox.stub(notifications, 'sendNotification');
data = {
@@ -95,16 +97,6 @@ describe('payments/index', () => {
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: recipient._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'subscribed');
expect(subscriptionEvent).to.have.property('userId', recipient._id);
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('adds extra months to an existing subscription', async () => {
recipient.purchased.plan = plan;
@@ -306,6 +298,28 @@ describe('payments/index', () => {
expect(notifications.sendNotification).to.be.calledOnce;
});
it('tracks subscription purchase as gift', async () => {
await api.createSubscription(data);
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: true,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('No Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEventList').returns([]);
@@ -441,16 +455,6 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCreated).to.exist;
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('ipAddress');
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('sets plan.dateCreated if it did not previously exist', async () => {
expect(user.purchased.plan.dateCreated).to.not.exist;
@@ -539,24 +543,29 @@ describe('payments/index', () => {
expect(sender.sendTxn).to.be.calledWith(data.user, 'subscription-begins');
});
context('Upgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
it('tracks subscription purchase', async () => {
await api.createSubscription(data);
await api.createSubscription(data);
data.sub.key = 'basic_6mo';
data.updatedFrom = { key: 'basic_earned' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_6mo' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'upgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: false,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('Upgrades subscription', () => {
it('from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
@@ -599,23 +608,6 @@ describe('payments/index', () => {
});
context('Downgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
data.sub.key = 'basic_earned';
data.updatedFrom = { key: 'basic_6mo' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_earned' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'downgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('from basic_6mo to basic_earned', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
@@ -1144,15 +1136,6 @@ describe('payments/index', () => {
expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days
});
it('tracks subscription events', async () => {
await api.cancelSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('userId', user._id);
});
it('adds extraMonths to dateTerminated value', async () => {
user.purchased.plan.extraMonths = 2;
@@ -0,0 +1,50 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analytics middleware', () => {
let res; let req; let
next;
const pathToAnalyticsMiddleware = '../../../../website/server/middlewares/analytics';
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('attaches analytics object to res', () => {
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics).to.exist;
});
it('attaches stubbed methods for non-prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase);
});
it('attaches real methods for prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase);
});
});
@@ -0,0 +1,19 @@
import {
generateUser,
requester,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /analytics/track/:eventName', () => {
it('calls res.analytics', async () => {
const user = await generateUser();
sandbox.spy(analytics, 'track');
const requestWithHeaders = requester(user, { 'x-client': 'habitica-web' });
await requestWithHeaders.post('/analytics/track/eventName', { data: 'example' }, { 'x-client': 'habitica-web' });
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('eventName', sandbox.match({ data: 'example' }));
sandbox.restore();
});
});
@@ -193,23 +193,6 @@ describe('POST /groups/:groupId/quests/force-start', () => {
expect(questingGroup.quest.members[notInPartyUser._id]).to.not.exist;
});
it('removes users who have been deleted from quest.members', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
await partyMembers[0].del('/user', {
password: 'password',
});
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await sleep(0.5);
await questingGroup.sync();
expect(questingGroup.quest.members[partyMembers[0]._id]).to.not.exist;
});
it('removes users who don\'t have true value in quest.members from quest.members', async () => {
const partyMemberThatRejects = partyMembers[1];
const partyMemberThatIgnores = partyMembers[2];
@@ -1,13 +1,7 @@
import {
each,
map,
} from 'lodash';
import {
checkExistence,
createAndPopulateGroup,
generateGroup,
generateUser,
generateChallenge,
translate as t,
} from '../../../../helpers/api-integration/v3';
import {
@@ -15,6 +9,7 @@ import {
sha1Encrypt as sha1EncryptPassword,
} from '../../../../../website/server/libs/password';
import * as email from '../../../../../website/server/libs/email';
import sendJob from '../../../../../website/server/libs/worker';
const DELETE_CONFIRMATION = 'DELETE';
@@ -47,12 +42,13 @@ describe('DELETE /user', () => {
});
});
it('deletes the user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
it('sends deletion job to worker', async () => {
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
it('returns an error if excessive feedback is supplied', async () => {
@@ -84,53 +80,6 @@ describe('DELETE /user', () => {
});
});
it('deletes the user\'s tasks', async () => {
await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await user.sync();
// gets the user's tasks ids
const ids = [];
each(user.tasksOrder, idsForOrder => {
ids.push(...idsForOrder);
});
expect(ids.length).to.be.above(0); // make sure the user has some task to delete
await user.del('/user', {
password,
});
await Promise.all(map(ids, id => expect(checkExistence('tasks', id)).to.eventually.eql(false)));
});
it('reduces memberCount in challenges user is linked to', async () => {
const populatedGroup = await createAndPopulateGroup({
members: 2,
});
const { group } = populatedGroup;
const authorizedUser = populatedGroup.members[1];
const challenge = await generateChallenge(populatedGroup.groupLeader, group);
await populatedGroup.groupLeader.post(`/challenges/${challenge._id}/join`);
await authorizedUser.post(`/challenges/${challenge._id}/join`);
await challenge.sync();
expect(challenge.memberCount).to.eql(2);
await authorizedUser.del('/user', {
password,
});
await challenge.sync();
expect(challenge.memberCount).to.eql(1);
});
it('sends feedback to the admin email', async () => {
sandbox.spy(email, 'sendTxn');
@@ -158,10 +107,10 @@ describe('DELETE /user', () => {
});
it('deletes the user with a legacy sha1 password', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
const textPassword = 'mySecretPassword';
const salt = sha1MakeSalt();
const sha1HashedPassword = sha1EncryptPassword(textPassword, salt);
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.updateOne({
'auth.local.hashed_password': sha1HashedPassword,
@@ -179,7 +128,8 @@ describe('DELETE /user', () => {
await user.del('/user', {
password: textPassword,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
context('last member of a party', () => {
@@ -213,11 +163,12 @@ describe('DELETE /user', () => {
});
it('deletes a Google user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
});
@@ -232,12 +183,13 @@ describe('DELETE /user', () => {
});
});
it('deletes a Apple user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
it('deletes an Apple user', async () => {
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
});
});
@@ -1,6 +1,7 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /user/sleep', () => {
let user;
@@ -22,4 +23,15 @@ describe('POST /user/sleep', () => {
await user.sync();
expect(user.preferences.sleep).to.be.false;
});
it('sends sleep status to analytics service', async () => {
sandbox.spy(analytics, 'track');
await user.post('/user/sleep');
await user.sync();
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('sleep', sandbox.match.has('status', user.preferences.sleep));
sandbox.restore();
});
});
@@ -9,7 +9,6 @@ import {
} from '../../../../../helpers/api-integration/v3';
import { ApiUser } from '../../../../../helpers/api-integration/api-classes';
import { encrypt } from '../../../../../../website/server/libs/encryption';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
function generateRandomUserName () {
return (Date.now() + uuid()).substring(0, 20);
@@ -42,25 +41,6 @@ describe('POST /user/auth/local/register', () => {
expect(user.newUser).to.eql(true);
});
it('tracks a registration event', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
const password = 'password';
const user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'local');
});
it('registers a new user and sets verifiedUsername to true', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
@@ -7,7 +7,6 @@ import {
getProperty,
} from '../../../../../helpers/api-integration/v3';
import apiErrorMessages from '../../../../../../website/common/script/errors/apiErrorMessages';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
describe('POST /user/auth/social', () => {
let api;
@@ -66,19 +65,6 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('tracks a registration event', async () => {
const socialUser = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: socialUser.id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', socialUser.id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -245,17 +231,6 @@ describe('POST /user/auth/social', () => {
expect(response.newUser).to.be.false;
});
it('does not track a registration event for existing users', async () => {
const beforeEvents = await RegistrationEventModel.find({ userId: user._id });
await user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(beforeEvents.length);
});
it('does not log into other account if social auth already exists', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
+10 -1
View File
@@ -13,6 +13,7 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buy', () => {
let user;
const analytics = { track () {} };
beforeEach(() => {
user = generateUser({
@@ -31,6 +32,12 @@ describe('shared.ops.buy', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('returns error when key is not provided', async () => {
@@ -44,8 +51,10 @@ describe('shared.ops.buy', () => {
it('buys health potion', async () => {
user.stats.hp = 30;
await buy(user, { params: { key: 'potion' } });
await buy(user, { params: { key: 'potion' } }, analytics);
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('adds equipment to inventory', async () => {
+7 -3
View File
@@ -29,9 +29,10 @@ describe('shared.ops.buyArmoire', () => {
const YIELD_EQUIPMENT = 0.5;
const YIELD_FOOD = 0.7;
const YIELD_EXP = 0.9;
const analytics = { track () {} };
async function buyArmoire (_user, _req) {
const buyOp = new BuyArmoireOperation(_user, _req);
async function buyArmoire (_user, _req, _analytics) {
const buyOp = new BuyArmoireOperation(_user, _req, _analytics);
return buyOp.purchase();
}
@@ -49,10 +50,12 @@ describe('shared.ops.buyArmoire', () => {
user.items.food = {};
sandbox.stub(randomValFns, 'trueRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
randomValFns.trueRandom.restore();
analytics.track.restore();
});
context('failure conditions', () => {
@@ -144,7 +147,7 @@ describe('shared.ops.buyArmoire', () => {
expect(_.size(user.items.gear.owned)).to.equal(2);
await buyArmoire(user, {});
await buyArmoire(user, {}, analytics);
expect(_.size(user.items.gear.owned)).to.equal(3);
@@ -152,6 +155,7 @@ describe('shared.ops.buyArmoire', () => {
expect(armoireCount).to.eql(_.size(getFullArmoire()) - 2);
expect(user.stats.gp).to.eql(100);
expect(analytics.track).to.be.calledTwice;
});
});
});
+12 -3
View File
@@ -1,5 +1,6 @@
/* eslint-disable camelcase */
import sinon from 'sinon'; // eslint-disable-line no-shadow
import {
generateUser,
} from '../../../helpers/common.helper';
@@ -10,14 +11,15 @@ import i18n from '../../../../website/common/script/i18n';
import { BuyGemOperation } from '../../../../website/common/script/ops/buy/buyGem';
import planGemLimits from '../../../../website/common/script/libs/planGemLimits';
async function buyGem (user, req) {
const buyOp = new BuyGemOperation(user, req);
async function buyGem (user, req, analytics) {
const buyOp = new BuyGemOperation(user, req, analytics);
return buyOp.purchase();
}
describe('shared.ops.buyGem', () => {
let user;
const analytics = { track () {} };
const goldPoints = 40;
const gemsBought = 40;
const userGemAmount = 10;
@@ -33,16 +35,23 @@ describe('shared.ops.buyGem', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Gems', () => {
it('purchases gems', async () => {
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } });
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } }, analytics);
expect(message).to.equal(i18n.t('plusGem', { count: 1 }));
expect(user.balance).to.equal(userGemAmount + 0.25);
expect(user.purchased.plan.gemsBought).to.equal(1);
expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate);
expect(analytics.track).to.be.calledOnce;
});
it('purchases gems with a different language than the default', async () => {
+10 -3
View File
@@ -10,9 +10,10 @@ import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyHealthPotion', () => {
let user;
const analytics = { track () {} };
async function buyHealthPotion (_user, _req) {
const buyOp = new BuyHealthPotionOperation(_user, _req);
async function buyHealthPotion (_user, _req, _analytics) {
const buyOp = new BuyHealthPotionOperation(_user, _req, _analytics);
return buyOp.purchase();
}
@@ -31,13 +32,19 @@ describe('shared.ops.buyHealthPotion', () => {
},
stats: { gp: 200 },
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Potion', () => {
it('recovers 15 hp', async () => {
user.stats.hp = 30;
await buyHealthPotion(user, {});
await buyHealthPotion(user, {}, analytics);
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('does not increase hp above 50', async () => {
+9 -5
View File
@@ -13,14 +13,15 @@ import {
import i18n from '../../../../website/common/script/i18n';
import { errorMessage } from '../../../../website/common/script/libs/errorMessage';
async function buyGear (user, req) {
const buyOp = new BuyMarketGearOperation(user, req);
async function buyGear (user, req, analytics) {
const buyOp = new BuyMarketGearOperation(user, req, analytics);
return buyOp.purchase();
}
describe('shared.ops.buyMarketGear', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -46,12 +47,14 @@ describe('shared.ops.buyMarketGear', () => {
sinon.stub(shared, 'randomVal');
sinon.stub(shared.onboarding, 'checkOnboardingStatus');
sinon.stub(shared.fns, 'predictableRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
shared.randomVal.restore();
shared.fns.predictableRandom.restore();
shared.onboarding.checkOnboardingStatus.restore();
analytics.track.restore();
if (clock) {
clock.restore();
@@ -62,7 +65,7 @@ describe('shared.ops.buyMarketGear', () => {
it('adds equipment to inventory', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } });
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
expect(user.items.gear.owned).to.eql({
weapon_warrior_0: true,
@@ -89,12 +92,13 @@ describe('shared.ops.buyMarketGear', () => {
eyewear_special_whiteHalfMoon: true,
eyewear_special_yellowHalfMoon: true,
});
expect(analytics.track).to.be.calledOnce;
});
it('adds the onboarding achievement to the user and checks the onboarding status', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } });
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
expect(user.addAchievement).to.be.calledOnce;
expect(user.addAchievement).to.be.calledWith('purchasedEquipment');
@@ -107,7 +111,7 @@ describe('shared.ops.buyMarketGear', () => {
user.stats.gp = 31;
user.achievements.purchasedEquipment = true;
await buyGear(user, { params: { key: 'armor_warrior_1' } });
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
expect(user.addAchievement).to.not.be.called;
});
+5 -2
View File
@@ -14,6 +14,7 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyMysterySet', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -26,9 +27,11 @@ describe('shared.ops.buyMysterySet', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -90,7 +93,7 @@ describe('shared.ops.buyMysterySet', () => {
context('successful purchases', () => {
it('buys Steampunk Accessories Set', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '301404' } });
await buyMysterySet(user, { params: { key: '301404' } }, analytics);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true);
@@ -103,7 +106,7 @@ describe('shared.ops.buyMysterySet', () => {
it('buys mystery set if it is available', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-16'));
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '201601' } });
await buyMysterySet(user, { params: { key: '201601' } }, analytics);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('shield_mystery_201601', true);
+5 -2
View File
@@ -12,9 +12,10 @@ describe('shared.ops.buyQuestGems', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGemOperation(_user, _req);
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGemOperation(_user, _req, _analytics);
return buyOp.purchase();
}
@@ -24,11 +25,13 @@ describe('shared.ops.buyQuestGems', () => {
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sinon.useFakeTimers(new Date('2024-01-16'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
+17 -8
View File
@@ -12,15 +12,21 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyQuest', () => {
let user;
const analytics = { track () {} };
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req);
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('buys a Quest scroll', async () => {
@@ -29,11 +35,12 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
});
}, analytics);
expect(user.items.quests).to.eql({
dilatoryDistress1: 1,
});
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('if a user\'s count of a quest scroll is negative, it will be reset to 0 before incrementing when they buy a new one.', async () => {
@@ -42,9 +49,10 @@ describe('shared.ops.buyQuest', () => {
user.items.quests[key] = -1;
await buyQuest(user, {
params: { key },
});
}, analytics);
expect(user.items.quests[key]).to.equal(1);
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('buys a Quest scroll with the right quantity if a string is passed for quantity', async () => {
@@ -53,13 +61,13 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
});
}, analytics);
await buyQuest(user, {
params: {
key: 'dilatoryDistress1',
},
quantity: '3',
});
}, analytics);
expect(user.items.quests).to.eql({
dilatoryDistress1: 4,
@@ -74,7 +82,7 @@ describe('shared.ops.buyQuest', () => {
key: 'dilatoryDistress1',
},
quantity: 'a',
});
}, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -179,11 +187,12 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress3',
},
});
}, analytics);
expect(user.items.quests).to.eql({
dilatoryDistress3: 1,
});
expect(user.stats.gp).to.equal(100);
expect(analytics.track).to.be.calledOnce;
});
});
+11 -5
View File
@@ -14,17 +14,20 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buySpecialSpell', () => {
let user;
let clock;
const analytics = { track () {} };
async function buySpecialSpell (_user, _req) {
const buyOp = new BuySpellOperation(_user, _req);
async function buySpecialSpell (_user, _req, _analytics) {
const buyOp = new BuySpellOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -75,7 +78,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'thankyou',
},
});
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
@@ -86,6 +89,7 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('buys a limited card when it is available', async () => {
@@ -97,7 +101,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'nye',
},
});
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.nye).to.equal(1);
@@ -108,6 +112,7 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the card is not currently available', async () => {
@@ -135,7 +140,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'seafoam',
},
});
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.seafoam).to.equal(1);
@@ -146,6 +151,7 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the spell is not currently available', async () => {
+10 -3
View File
@@ -13,15 +13,21 @@ import { BuyHourglassMountOperation } from '../../../../website/common/script/op
describe('common.ops.hourglassPurchase', () => {
let user;
const analytics = { track () {} };
async function buyMount (_user, _req) {
const buyOp = new BuyHourglassMountOperation(_user, _req);
async function buyMount (_user, _req, _analytics) {
const buyOp = new BuyHourglassMountOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('failure conditions', () => {
@@ -125,11 +131,12 @@ describe('common.ops.hourglassPurchase', () => {
it('buys a pet', async () => {
user.purchased.plan.consecutive.trinkets = 2;
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } });
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } }, analytics);
expect(message).to.eql(i18n.t('hourglassPurchase'));
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.items.pets).to.eql({ 'MantisShrimp-Base': 5 });
expect(analytics.track).to.be.calledOnce;
});
it('buys a mount', async () => {
+8 -4
View File
@@ -17,17 +17,20 @@ describe('shared.ops.purchase', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
before(() => {
user = generateUser({ 'stats.class': 'rogue' });
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sandbox.useFakeTimers(new Date('2024-01-10'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
@@ -184,10 +187,11 @@ describe('shared.ops.purchase', () => {
const type = 'eggs';
const key = 'Wolf';
await purchase(user, { params: { type, key } });
await purchase(user, { params: { type, key } }, analytics);
expect(user.items[type][key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
expect(analytics.track).to.be.calledOnce;
});
it('purchases hatchingPotions', async () => {
@@ -328,7 +332,7 @@ describe('shared.ops.purchase', () => {
const key = 'Wolf';
try {
await purchase(user, { params: { type, key }, quantity: 'jamboree' });
await purchase(user, { params: { type, key }, quantity: 'jamboree' }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -341,7 +345,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: -2 });
await purchase(user, { params: { type, key }, quantity: -2 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -354,7 +358,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: 2.9 });
await purchase(user, { params: { type, key }, quantity: 2.9 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -40,6 +40,7 @@ function _requestMaker (user, method, additionalSets = {}) {
|| route.indexOf('/paypal') === 0
|| route.indexOf('/amazon') === 0
|| route.indexOf('/stripe') === 0
|| route.indexOf('/analytics') === 0
) {
url += `${route}`;
} else {
@@ -198,6 +198,7 @@ import dailyIcon from '@/assets/svg/daily.svg?raw';
import todoIcon from '@/assets/svg/todo.svg?raw';
import rewardIcon from '@/assets/svg/reward.svg?raw';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
export default {
@@ -437,6 +438,14 @@ export default {
return false;
},
changeMirrorPreference (newVal) {
Analytics.track({
eventName: 'mirror tasks',
eventAction: 'mirror tasks',
eventCategory: 'behavior',
hitType: 'event',
mirror: newVal,
group: this.group._id,
}, { trackOnClient: true });
const groupsToMirror = this.user.preferences.tasks.mirrorGroupTasks || [];
if (newVal) { // we're turning copy ON for this group
groupsToMirror.push(this.group._id);
@@ -240,6 +240,7 @@
<script>
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import notifications from '@/mixins/notifications';
import closeX from '../ui/closeX';
@@ -275,6 +276,11 @@ export default {
this.$store.state.party.data = party;
this.user.party._id = party._id;
Analytics.updateUser({
partyID: party._id,
partySize: 1,
});
this.$root.$emit('bv::hide::modal', 'create-party-modal');
await this.$router.push('/party');
},
@@ -314,6 +314,7 @@ import extend from 'lodash/extend';
import groupUtilities from '@/mixins/groupsUtilities';
import styleHelper from '@/mixins/styleHelper';
import { mapGetters } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import participantListModal from './participantListModal';
import groupFormModal from './groupFormModal';
import groupGemsModal from '@/components/groups/groupGemsModal';
@@ -559,6 +560,7 @@ export default {
if (this.isParty) {
data.type = 'party';
Analytics.updateUser({ partySize: null, partyID: null });
this.$store.state.partyMembers = [];
}
@@ -334,6 +334,7 @@ import orderBy from 'lodash/orderBy';
import * as quests from '@/../../common/script/content/quests';
import getItemInfo from '@/../../common/script/libs/getItemInfo';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import navigationBack from '@/assets/svg/navigation_back.svg?raw';
import questDialogContent from '../shops/quests/questDialogContent';
@@ -420,6 +421,11 @@ export default {
async questInit () {
this.loading = true;
Analytics.updateUser({
partyID: this.group._id,
partySize: this.group.memberCount,
});
const groupId = this.group._id || this.user.party._id;
const key = this.selectedQuest;
@@ -123,6 +123,7 @@
<script>
import orderBy from 'lodash/orderBy';
import * as Analytics from '@/libs/analytics';
import { mapGetters, mapActions } from '@/libs/store';
import MemberDetails from '../memberDetails';
import createPartyModal from '../groups/createPartyModal';
@@ -235,8 +236,22 @@ export default {
},
async createOrInviteParty () {
if (this.user.party._id) {
await Analytics.track({
eventName: 'Header Party CTA',
eventAction: 'Header Party CTA',
eventCategory: 'behavior',
hitType: 'event',
state: 'Find Party Members',
});
this.$router.push('/looking-for-party');
} else {
await Analytics.track({
eventName: 'Header Party CTA',
eventAction: 'Header Party CTA',
eventCategory: 'behavior',
hitType: 'event',
state: 'Get Started',
});
this.$root.$emit('bv::show::modal', 'create-party-modal');
}
},
@@ -114,6 +114,7 @@ import { mapState } from '@/libs/store';
import notifications from '@/mixins/notifications';
import guide from '@/mixins/guide';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
import yesterdailyModal from './tasks/yesterdailyModal';
import newStuff from './news/modal';
@@ -647,6 +648,15 @@ export default {
// Reset daily analytics actions
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 0);
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 0);
} else {
// Note a failed cron event, for our records and investigation
Analytics.track({
eventName: 'cron failed',
eventAction: 'cron failed',
eventCategory: 'behavior',
hitType: 'event',
responseCode: response.status,
}, { trackOnClient: true });
}
// Sync
@@ -433,6 +433,9 @@ import lockableLabel from '@/components/tasks/modal-controls/lockableLabel';
import notificationsMixin from '@/mixins/notifications';
import paymentsMixin from '@/mixins/payments';
// analytics
import * as Analytics from '@/libs/analytics';
export default {
components: {
selectTranslatedArray,
@@ -533,6 +536,16 @@ export default {
this.close();
},
submit () {
if (this.paymentData.group && !this.paymentData.newGroup) {
Analytics.track({
hitType: 'event',
eventName: 'group plan upgrade',
eventAction: 'group plan upgrade',
eventCategory: 'behavior',
demographics: this.upgradedGroup.demographics,
type: this.paymentData.group.type,
}, { trackOnClient: true });
}
this.paymentData = {};
this.$root.$emit('bv::hide::modal', 'payments-success-modal');
},
+11
View File
@@ -6,6 +6,7 @@ import { mapState } from '@/libs/store';
import encodeParams from '@/libs/encodeParams';
import notificationsMixin from '@/mixins/notifications';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
const STRIPE_PUB_KEY = import.meta.env.STRIPE_PUB_KEY;
@@ -206,6 +207,16 @@ export default {
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
throw checkoutSessionResult.error;
}
if (paymentType === 'groupPlan') {
Analytics.track({
hitType: 'event',
eventName: 'group plan create',
eventAction: 'group plan create',
eventCategory: 'behavior',
demographics: appState.newGroup.demographics,
type: appState.newGroup.type,
}, { trackOnClient: true });
}
} catch (err) {
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${err.message}`);
+10
View File
@@ -3,6 +3,7 @@ import Vue from 'vue';
import scoreTask from '@/../../common/script/ops/scoreTask';
import notifications from './notifications';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
export default {
@@ -57,6 +58,15 @@ export default {
const tasksScoredCount = getLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT);
if (!tasksScoredCount || tasksScoredCount < 2) {
Analytics.track({
eventName: 'task scored',
eventAction: 'task scored',
eventCategory: 'behavior',
hitType: 'event',
uuid: user._id,
taskType: task.type,
direction,
}, { trackOnClient: true });
if (!tasksScoredCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 1);
} else {
@@ -67,7 +67,7 @@
<div
v-once
class="feedback"
class="feedback mt-3"
v-html="$t('feedback')"
>
</div>
+2
View File
@@ -130,6 +130,7 @@ import PrivacyBanner from '@/components/header/banners/privacy';
import AppFooter from '@/components/appFooter';
import notificationsDisplay from '@/components/notifications';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import BuyModal from '@/components/shops/buyModal.vue';
import SelectMembersModal from '@/components/selectMembersModal.vue';
import notifications from '@/mixins/notifications';
@@ -275,6 +276,7 @@ export default {
}
}
Analytics.updateUser();
return this.loadAllTranslations();
}).then(() => {
this.$store.state.isUserLoaded = true;
+10
View File
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import * as Analytics from '@/libs/analytics';
import getStore from '@/store';
import handleRedirect from './handleRedirect';
@@ -317,6 +318,15 @@ router.beforeEach(async (to, from, next) => {
router.app.$root.$emit('update-party');
}
if (to.name === 'lookingForParty') {
Analytics.track({
hitType: 'event',
eventName: 'View Find Members',
eventAction: 'View Find Members',
eventCategory: 'behavior',
}, { trackOnClient: true });
}
// Redirect old guild urls
if (to.hash.indexOf('#/options/groups/guilds/') !== -1) {
const splits = to.hash.split('/');
+8
View File
@@ -1,5 +1,6 @@
import axios from 'axios';
import Vue from 'vue';
import * as Analytics from '@/libs/analytics';
export async function getChat (store, payload) {
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat?limit=400`);
@@ -16,6 +17,13 @@ export async function postChat (store, payload) {
url += `?previousMsg=${payload.previousMsg}`;
}
if (group.type === 'party') {
Analytics.updateUser({
partyID: group.id,
partySize: group.memberCount,
});
}
const response = await axios.post(url, {
message: payload.message,
});
@@ -1,6 +1,7 @@
import axios from 'axios';
import omit from 'lodash/omit';
import findIndex from 'lodash/findIndex';
import * as Analytics from '@/libs/analytics';
import { loadAsyncResource } from '@/libs/asyncResource';
export async function getPublicGuilds (store, payload) {
@@ -73,6 +74,7 @@ export async function join (store, payload) {
if (invitationI !== -1) invitations.parties.splice(invitationI, 1);
user.party._id = groupId;
Analytics.updateUser({ partyID: groupId });
// load the party members so that they get shown in the header
store.dispatch('party:getMembers');
}
@@ -18,6 +18,7 @@ import * as shops from './shops';
import * as snackbars from './snackbars';
import * as worldState from './worldState';
import * as news from './news';
import * as analytics from './analytics';
import * as faq from './faq';
import * as blockers from './blockers';
@@ -43,6 +44,7 @@ const actions = flattenAndNamespace({
snackbars,
worldState,
news,
analytics,
faq,
blockers,
});
@@ -1,6 +1,26 @@
import axios from 'axios';
import * as Analytics from '@/libs/analytics';
// export async function initQuest (store) {
// }
export async function sendAction (store, payload) { // eslint-disable-line import/prefer-default-export, max-len
// @TODO: Maybe move this to server
let partyData = {};
if (store.state.party && store.state.party.data) {
partyData = {
partyID: store.state.party.data._id,
partySize: store.state.party.data.memberCount,
};
} else {
partyData = {
partyID: store.state.user.data.party._id,
partySize: store.state.partyMembers.data.length,
};
}
Analytics.updateUser(partyData);
const response = await axios.post(`/api/v4/groups/${payload.groupId}/${payload.action}`);
// @TODO: Update user?
+10
View File
@@ -3,6 +3,7 @@ import Vue from 'vue';
import compact from 'lodash/compact';
import omit from 'lodash/omit';
import { loadAsyncResource } from '@/libs/asyncResource';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
export function fetchUserTasks (store, options = {}) {
@@ -111,6 +112,15 @@ export async function create (store, createdTask) {
}
const tasksCreatedCount = getLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT);
if (!tasksCreatedCount || tasksCreatedCount < 2) {
const uuid = store.state.user.data._id;
Analytics.track({
eventName: 'task created',
eventAction: 'task created',
eventCategory: 'behavior',
hitType: 'event',
uuid,
taskType: taskRes.type,
}, { trackOnClient: true });
if (!tasksCreatedCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 1);
} else {
+4
View File
@@ -159,6 +159,10 @@ export default defineConfig({
target: DEV_BASE_URL,
changeOrigin: true,
},
'^/analytics': {
target: DEV_BASE_URL,
changeOrigin: true,
},
}
}
})
+3 -3
View File
@@ -33,7 +33,7 @@
"resetAccPop": "Start over, removing all levels, gold, gear, history, and tasks.",
"deleteAccount": "Delete Account",
"deleteAccPop": "Cancel and remove your Habitica account.",
"feedback": "If you'd like to give us feedback, please enter it below - we'd love to hear your feedback! It will be anonymous unless you choose to enter your contact details. Don't speak English well? No problem! Use the language you prefer.",
"feedback": "We'd love to hear your feedback! If you'd like to share any, enter it below. It will be anonymous unless you choose to include your contact details.",
"feedbackPlaceholder": "Add your feedback",
"dataExport": "Data Export",
"saveData": "Here are a few options for saving your data.",
@@ -82,8 +82,8 @@
"resetText2": "Another option is using an <b>Orb of Rebirth</b>, which will reset everything else while preserving your Tasks and Equipment.",
"resetTextLocal": "If you're absolutely certain, type your password into the text box below.",
"resetTextSocial": "If you're absolutely certain, type <b>\"<%= magicWord %>\"</b> into the text box below.",
"deleteLocalAccountText": "<b>Are you sure?</b> This will delete your account forever, and it can never be restored! You will need to register a new account to use Habitica again. Banked or spent Gems will not be refunded. If you're absolutely certain, type your password into the text box below.",
"deleteSocialAccountText": "<b>Are you sure?</b> This will delete your account forever, and it can never be restored! You will need to register a new account to use Habitica again. Banked or spent Gems will not be refunded. If you're absolutely certain, type <b>\"<%= magicWord %>\"</b> into the text box below.",
"deleteLocalAccountText": "<b>Are you sure?</b> This action is permanent. Deleting your account will remove all of your data, and it cannot be recovered. Gems will not be refunded.<br><br>Please allow up to 24 hours for account deletion to complete, and up to 30 days for analytics data to be removed if you opted in. Once complete, you'll be able to register for a new Habitica account using your previous login information.<br><br>To continue, type your password below.",
"deleteSocialAccountText": "<b>Are you sure?</b> This action is permanent. Deleting your account will remove all of your data, and it cannot be recovered. Gems will not be refunded.<br><br>Please allow up to 24 hours for account deletion to complete, and up to 30 days for analytics data to be removed if you opted in. Once complete, you'll be able to register for a new Habitica account using your previous login information.<br><br>To continue, type <%= magicWord %> below.",
"API": "API",
"APICopied": "API token copied to clipboard.",
"APITokenTitle": "API Token",
+13 -1
View File
@@ -3,8 +3,10 @@ import isFunction from 'lodash/isFunction';
import min from 'lodash/min';
import reduce from 'lodash/reduce';
import filter from 'lodash/filter';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
import size from 'lodash/size';
import moment from 'moment';
import content from '../content/index';
import i18n from '../i18n';
import { daysSince } from '../cron';
@@ -26,7 +28,7 @@ function trueRandom () {
return Math.random();
}
export default function randomDrop (user, options, req = {}) {
export default function randomDrop (user, options, req = {}, analytics) {
let acceptableDrops;
let drop;
let dropMultiplier;
@@ -155,5 +157,15 @@ export default function randomDrop (user, options, req = {}) {
user._tmp.drop = drop;
user.items.lastDrop.date = Number(new Date());
user.items.lastDrop.count += 1;
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('dropped item', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: drop.key,
category: 'behavior',
headers: req.headers,
});
}
}
}
+12 -1
View File
@@ -1,3 +1,5 @@
import pick from 'lodash/pick';
export function hasCompletedOnboarding (user) {
return (
user.achievements.createdTask === true
@@ -14,9 +16,18 @@ export function onOnboardingComplete (user) {
}
// Add notification and awards (server)
export function checkOnboardingStatus (user) {
export function checkOnboardingStatus (user, req, analytics) {
if (hasCompletedOnboarding(user) && user.addNotification) {
user.addNotification('ONBOARDING_COMPLETE');
if (analytics) {
analytics.track('onboarding complete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
}
onOnboardingComplete(user);
}
}
@@ -1,5 +1,7 @@
/* eslint-disable max-classes-per-file */
import get from 'lodash/get';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import {
NotAuthorized,
@@ -13,10 +15,12 @@ export class AbstractBuyOperation {
/**
* @param {User} user - the User-Object
* @param {Request} req - the Request-Object
* @param {analytics} analytics
*/
constructor (user, req) {
constructor (user, req, analytics) {
this.user = user;
this.req = req || {};
this.analytics = analytics;
const quantity = get(req, 'quantity');
@@ -83,6 +87,10 @@ export class AbstractBuyOperation {
throw new NotImplementedError('executeChanges');
}
analyticsData () { // eslint-disable-line class-methods-use-this
throw new NotImplementedError('sendToAnalytics');
}
async purchase () {
if (!this.multiplePurchaseAllowed() && this.quantity > 1) {
throw new NotAuthorized(this.i18n('messageNotAbleToBuyInBulk'));
@@ -90,10 +98,34 @@ export class AbstractBuyOperation {
this.extractAndValidateParams(this.user, this.req);
const resultObj = await this.executeChanges(this.user, this.item, this.req);
const resultObj = await this.executeChanges(this.user, this.item, this.req, this.analytics);
if (this.analytics) {
this.sendToAnalytics(this.analyticsData());
}
return resultObj;
}
analyticsLabel () { // eslint-disable-line class-methods-use-this
return 'buy';
}
sendToAnalytics (additionalData = {}) {
// spread-operator produces an "unexpected token" error
const analyticsData = merge(additionalData, {
user: pick(this.user, ['preferences', 'registeredThrough']),
uuid: this.user._id,
category: 'behavior',
headers: this.req.headers,
});
if (this.multiplePurchaseAllowed()) {
analyticsData.quantityPurchased = this.quantity;
}
this.analytics.track(this.analyticsLabel(), analyticsData);
}
}
export class AbstractGoldItemOperation extends AbstractBuyOperation {
@@ -117,6 +149,15 @@ export class AbstractGoldItemOperation extends AbstractBuyOperation {
user.stats.gp -= itemValue * this.quantity;
}
analyticsData () {
return {
itemKey: this.getItemKey(this.item),
itemType: this.getItemType(this.item),
currency: 'Gold',
goldCost: this.getItemValue(this.item),
};
}
}
export class AbstractGemItemOperation extends AbstractBuyOperation {
@@ -138,6 +179,15 @@ export class AbstractGemItemOperation extends AbstractBuyOperation {
await updateUserBalance(user, -(itemValue * this.quantity), 'spend', item.key, item.text());
}
analyticsData () {
return {
itemKey: this.getItemKey(this.item),
itemType: this.getItemType(this.item),
currency: 'Gems',
gemCost: this.getItemValue(this.item) * 4,
};
}
}
export class AbstractHourglassItemOperation extends AbstractBuyOperation {
@@ -152,4 +202,11 @@ export class AbstractHourglassItemOperation extends AbstractBuyOperation {
async subtractCurrency (user, item) { // eslint-disable-line class-methods-use-this
await updateUserHourglasses(user, -1, 'spend', item.key);
}
analyticsData () {
return {
itemKey: this.item.key,
currency: 'Hourglass',
};
}
}
+15 -14
View File
@@ -24,6 +24,7 @@ import { BuyHourglassMountOperation } from './buyMount';
export default async function buy (
user,
req = {},
analytics,
options = { quantity: 1, hourglass: false },
) {
const key = get(req, 'params.key');
@@ -41,35 +42,35 @@ export default async function buy (
switch (type) {
case 'armoire': {
const buyOp = new BuyArmoireOperation(user, req);
const buyOp = new BuyArmoireOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
}
case 'backgrounds':
if (!hourglass) throw new BadRequest(errorMessage('useUnlockForCosmetics'));
buyRes = await hourglassPurchase(user, req);
buyRes = await hourglassPurchase(user, req, analytics);
break;
case 'mystery':
buyRes = await buyMysterySet(user, req);
buyRes = await buyMysterySet(user, req, analytics);
break;
case 'potion': {
const buyOp = new BuyHealthPotionOperation(user, req);
const buyOp = new BuyHealthPotionOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
}
case 'gems': {
const buyOp = new BuyGemOperation(user, req);
const buyOp = new BuyGemOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
}
case 'quests': {
if (hourglass) {
buyRes = await hourglassPurchase(user, req, quantity);
buyRes = await hourglassPurchase(user, req, analytics, quantity);
} else {
const buyOp = new BuyQuestWithGemOperation(user, req);
const buyOp = new BuyQuestWithGemOperation(user, req, analytics);
buyRes = await buyOp.purchase();
}
@@ -80,36 +81,36 @@ export default async function buy (
case 'food':
case 'gear':
case 'bundles':
buyRes = await purchaseOp(user, req);
buyRes = await purchaseOp(user, req, analytics);
break;
case 'mounts': {
const buyOp = new BuyHourglassMountOperation(user, req);
const buyOp = new BuyHourglassMountOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
}
case 'pets':
if (key === 'Gryphatrice-Jubilant') {
const buyOp = new BuyPetWithGemOperation(user, req);
const buyOp = new BuyPetWithGemOperation(user, req, analytics);
buyRes = await buyOp.purchase();
} else {
buyRes = hourglassPurchase(user, req);
buyRes = hourglassPurchase(user, req, analytics);
}
break;
case 'quest': {
const buyOp = new BuyQuestWithGoldOperation(user, req);
const buyOp = new BuyQuestWithGoldOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
}
case 'special': {
const buyOp = new BuySpellOperation(user, req);
const buyOp = new BuySpellOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
}
default: {
const buyOp = new BuyMarketGearOperation(user, req);
const buyOp = new BuyMarketGearOperation(user, req, analytics);
buyRes = await buyOp.purchase();
break;
@@ -69,6 +69,19 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
];
}
_trackDropAnalytics (user, key) {
this.analytics.track(
'Enchanted Armoire',
{
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: key,
category: 'behavior',
headers: this.req.headers,
},
);
}
_gearResult (user, eligibleEquipment) {
const emptied = eligibleEquipment.length === 1;
eligibleEquipment.sort();
@@ -92,6 +105,10 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
removeItemByPath(user, `gear.flat.${drop.key}`);
if (this.analytics) {
this._trackDropAnalytics(user, drop.key);
}
const armoireResp = {
type: 'gear',
dropKey: drop.key,
@@ -117,6 +134,9 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
user.items.food[drop.key] += 1;
if (user.markModified) user.markModified('items.food');
if (this.analytics) {
this._trackDropAnalytics(user, drop.key);
}
return {
message: this.i18n('armoireFood', {
image: `<span class="Pet_Food_${drop.key} pull-left"></span>`,
+4
View File
@@ -70,4 +70,8 @@ export class BuyGemOperation extends AbstractGoldItemOperation { // eslint-disab
this.i18n('plusGem', { count: this.quantity }),
];
}
analyticsLabel () { // eslint-disable-line class-methods-use-this
return 'purchase gems';
}
}
@@ -60,7 +60,7 @@ export class BuyMarketGearOperation extends AbstractGoldItemOperation { // eslin
}
}
executeChanges (user, item, req) {
executeChanges (user, item, req, analytics) {
let message;
if (user.preferences.autoEquip) {
@@ -70,7 +70,7 @@ export class BuyMarketGearOperation extends AbstractGoldItemOperation { // eslin
if (!user.achievements.purchasedEquipment && user.addAchievement) {
user.addAchievement('purchasedEquipment');
checkOnboardingStatus(user, req);
checkOnboardingStatus(user, req, analytics);
}
removePinnedGearAddPossibleNewOnes(user, `gear.flat.${item.key}`, item.key);
@@ -49,4 +49,10 @@ export class BuyHourglassMountOperation extends AbstractHourglassItemOperation {
message,
];
}
analyticsData () {
const data = super.analyticsData();
data.itemType = 'mounts';
return data;
}
}
+14 -1
View File
@@ -1,5 +1,6 @@
import get from 'lodash/get';
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import content from '../../content/index';
import {
@@ -12,7 +13,7 @@ import updateUserHourglasses from '../updateUserHourglasses';
import { removeItemByPath } from '../pinnedGearUtils';
import getItemInfo from '../../libs/getItemInfo';
export default async function buyMysterySet (user, req = {}) {
export default async function buyMysterySet (user, req = {}, analytics) {
const key = get(req, 'params.key');
if (!key) throw new BadRequest(errorMessage('missingKeyParam'));
@@ -34,6 +35,18 @@ export default async function buyMysterySet (user, req = {}) {
const itemInfo = getItemInfo(user, 'mystery_set', mysterySet);
removeItemByPath(user, itemInfo.path);
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: mysterySet.key,
itemType: 'Subscriber Gear',
currency: 'Hourglass',
category: 'behavior',
headers: req.headers,
});
}
// Here we need to trigger vue reactivity through reassign object
user.items.gear.owned = {
...user.items.gear.owned,
@@ -1,6 +1,7 @@
import get from 'lodash/get';
import includes from 'lodash/includes';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import content from '../../content/index';
import {
@@ -12,7 +13,7 @@ import getItemInfo from '../../libs/getItemInfo';
import { removeItemByPath } from '../pinnedGearUtils';
import updateUserHourglasses from '../updateUserHourglasses';
export default async function purchaseHourglass (user, req = {}, quantity = 1) {
export default async function purchaseHourglass (user, req = {}, analytics, quantity = 1) {
const key = get(req, 'params.key');
if (!key) throw new BadRequest(errorMessage('missingKeyParam'));
@@ -93,6 +94,18 @@ export default async function purchaseHourglass (user, req = {}, quantity = 1) {
}
}
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: key,
itemType: type,
currency: 'Hourglass',
category: 'behavior',
headers: req.headers,
});
}
return [
{ items: user.items, purchasedPlanConsecutive: user.purchased.plan.consecutive },
i18n.t('hourglassPurchase', req.language),
+14 -1
View File
@@ -76,7 +76,7 @@ async function purchaseItem (user, item, price, type, key) {
const acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'gear', 'bundles'];
const singlePurchaseTypes = ['gear'];
export default async function purchase (user, req = {}) {
export default async function purchase (user, req = {}, analytics) {
const type = get(req.params, 'type');
const key = get(req.params, 'key');
@@ -130,6 +130,19 @@ export default async function purchase (user, req = {}) {
await purchaseItem(user, item, price, type, key);
}
/* eslint-enable no-await-in-loop */
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: key,
itemType: type,
currency: 'Gems',
gemCost: price * 4,
quantityPurchased: quantity,
category: 'behavior',
headers: req.headers,
});
}
return [
pick(user, splitWhitespace('items balance')),
+15 -3
View File
@@ -34,18 +34,19 @@ async function resetClass (user, req = {}) {
return balanceRemoved;
}
export default async function changeClass (user, req = {}) {
export default async function changeClass (user, req = {}, analytics) {
const klass = get(req, 'query.class');
let balanceRemoved = 0;
// user.flags.classSelected is set to false after the user paid the 3 gems
if (user.stats.lvl < 10) {
throw new NotAuthorized(i18n.t('lvl10ChangeClass', req.language));
} else if (!klass) {
// if no class is specified, reset points and set user.flags.classSelected to false.
// User will have paid 3 gems and will be prompted to select class.
await resetClass(user, req);
balanceRemoved = await resetClass(user, req);
} else if (klass === 'warrior' || klass === 'rogue' || klass === 'wizard' || klass === 'healer') {
if (user.flags.classSelected) {
await resetClass(user, req);
balanceRemoved = await resetClass(user, req);
}
user.stats.class = klass;
@@ -66,6 +67,17 @@ export default async function changeClass (user, req = {}) {
if (user.markModified) user.markModified('items.gear.owned');
removePinnedItemsByOwnedGear(user);
if (analytics) {
analytics.track('change class', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
class: klass,
currency: balanceRemoved === 0 ? 'Free' : 'Gems',
category: 'behavior',
headers: req.headers,
});
}
} else {
// if invalid class is specified, throw an error.
throw new BadRequest(i18n.t('invalidClass', req.language));
+15 -2
View File
@@ -2,7 +2,9 @@ import forEach from 'lodash/forEach';
import findIndex from 'lodash/findIndex';
import get from 'lodash/get';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import upperFirst from 'lodash/upperFirst';
import moment from 'moment';
import i18n from '../i18n';
import content from '../content/index';
import {
@@ -34,7 +36,7 @@ function evolve (user, pet, req) {
}, req.language);
}
export default function feed (user, req = {}) {
export default function feed (user, req = {}, analytics) {
let pet = get(req, 'params.pet');
const foodK = get(req, 'params.food');
let amount = Number(get(req.query, 'amount', 1));
@@ -114,7 +116,7 @@ export default function feed (user, req = {}) {
if (!user.achievements.fedPet && user.addAchievement) {
user.addAchievement('fedPet');
checkOnboardingStatus(user, req);
checkOnboardingStatus(user, req, analytics);
}
}
@@ -139,6 +141,17 @@ export default function feed (user, req = {}) {
}
});
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('pet feed', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
foodKey: food.key,
petKey: pet.key,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items.pets[pet.key],
message,
+14 -2
View File
@@ -2,7 +2,9 @@ import findIndex from 'lodash/findIndex';
import forEach from 'lodash/forEach';
import get from 'lodash/get';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import upperFirst from 'lodash/upperFirst';
import moment from 'moment';
import i18n from '../i18n';
import content from '../content/index';
import {
@@ -13,7 +15,7 @@ import {
import { errorMessage } from '../libs/errorMessage';
import { checkOnboardingStatus } from '../libs/onboarding';
export default function hatch (user, req = {}) {
export default function hatch (user, req = {}, analytics) {
const egg = get(req, 'params.egg');
const hatchingPotion = get(req, 'params.hatchingPotion');
@@ -55,7 +57,7 @@ export default function hatch (user, req = {}) {
if (!user.achievements.hatchedPet && user.addAchievement) {
user.addAchievement('hatchedPet');
checkOnboardingStatus(user, req);
checkOnboardingStatus(user, req, analytics);
}
if (content.dropEggs[egg]) {
@@ -150,6 +152,16 @@ export default function hatch (user, req = {}) {
});
}
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('pet hatch', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
petKey: pet,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items,
i18n.t('messageHatched', req.language),
+18 -1
View File
@@ -1,4 +1,5 @@
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../i18n';
import { capByLevel } from '../statHelpers';
import {
@@ -12,15 +13,31 @@ import updateUserBalance from './updateUserBalance';
const USERSTATSLIST = ['per', 'int', 'con', 'str', 'points', 'gp', 'exp', 'mp'];
export default async function rebirth (user, tasks = [], req = {}) {
export default async function rebirth (user, tasks = [], req = {}, analytics) {
const notFree = !isFreeRebirth(user);
if (user.balance < 1.5 && notFree) {
throw new NotAuthorized(i18n.t('notEnoughGems', req.language));
}
const analyticsData = {
uuid: user._id,
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
};
if (notFree) {
await updateUserBalance(user, -1.5, 'rebirth');
analyticsData.currency = 'Gems';
analyticsData.gemCost = 6;
} else {
analyticsData.currency = 'Free';
analyticsData.gemCost = 0;
}
if (analytics) {
analyticsData.headers = req.headers;
analytics.track('Rebirth', analyticsData);
}
const lvl = capByLevel(user.stats.lvl);
+13 -1
View File
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import content from '../content/index';
import { mountMasterProgress } from '../count';
import i18n from '../i18n';
@@ -6,7 +7,7 @@ import {
} from '../libs/errors';
import updateUserBalance from './updateUserBalance';
export default async function releaseMounts (user, req = {}) {
export default async function releaseMounts (user, req = {}, analytics) {
if (user.balance < 1) {
throw new NotAuthorized(i18n.t('notEnoughGems', req.language));
}
@@ -41,6 +42,17 @@ export default async function releaseMounts (user, req = {}) {
user.achievements.mountMasterCount += 1;
}
if (analytics) {
analytics.track('release mounts', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
currency: 'Gems',
gemCost: 4,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items.mounts,
i18n.t('mountsReleased'),
+13 -1
View File
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import content from '../content/index';
import { beastMasterProgress } from '../count';
import i18n from '../i18n';
@@ -6,7 +7,7 @@ import {
} from '../libs/errors';
import updateUserBalance from './updateUserBalance';
export default function releasePets (user, req = {}) {
export default function releasePets (user, req = {}, analytics) {
if (user.balance < 1) {
throw new NotAuthorized(i18n.t('notEnoughGems', req.language));
}
@@ -41,6 +42,17 @@ export default function releasePets (user, req = {}) {
user.achievements.beastMasterCount += 1;
}
if (analytics) {
analytics.track('release pets', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
currency: 'Gems',
gemCost: 4,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items.pets,
i18n.t('petsReleased'),
+13 -1
View File
@@ -1,11 +1,12 @@
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../i18n';
import {
NotAuthorized,
} from '../libs/errors';
import updateUserBalance from './updateUserBalance';
export default async function reroll (user, tasks = [], req = {}) {
export default async function reroll (user, tasks = [], req = {}, analytics) {
if (user.balance < 1) {
throw new NotAuthorized(i18n.t('notEnoughGems', req.language));
}
@@ -21,6 +22,17 @@ export default async function reroll (user, tasks = [], req = {}) {
}
});
if (analytics) {
analytics.track('Fortify Potion', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
currency: 'Gems',
gemCost: 4,
category: 'behavior',
headers: req.headers,
});
}
return [
{ user, tasks },
i18n.t('fortifyComplete'),
+12 -1
View File
@@ -1,4 +1,5 @@
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import reduce from 'lodash/reduce';
import each from 'lodash/each';
import i18n from '../i18n';
@@ -12,7 +13,7 @@ import predictableRandom from '../fns/predictableRandom';
import { removePinnedGearByClass, addPinnedGearByClass, addPinnedGear } from './pinnedGearUtils';
import getItemInfo from '../libs/getItemInfo';
export default function revive (user, req = {}) {
export default function revive (user, req = {}, analytics) {
if (user.stats.hp > 0) {
throw new NotAuthorized(i18n.t('cannotRevive', req.language));
}
@@ -109,6 +110,16 @@ export default function revive (user, req = {}) {
message = i18n.t('messageLostItem', { itemText: item.text(req.language) }, req.language);
}
if (analytics) {
analytics.track('Death', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
lostItem,
category: 'behavior',
headers: req.headers,
});
}
return [
user.items,
message,
+2 -2
View File
@@ -225,7 +225,7 @@ function _updateLastHistoryEntry (lastHistoryEntry, task, direction, times) {
}
}
export default function scoreTask (options = {}, req = {}) {
export default function scoreTask (options = {}, req = {}, analytics) {
const {
user, task, direction, times = 1, cron = false,
} = options;
@@ -425,7 +425,7 @@ export default function scoreTask (options = {}, req = {}) {
if (!user.achievements.completedTask && cron === false && direction === 'up' && user.addAchievement) {
user.addAchievement('completedTask');
checkOnboardingStatus(user, req);
checkOnboardingStatus(user, req, analytics);
}
return delta;
+14 -1
View File
@@ -1,4 +1,17 @@
export function sleep (user) {
import pick from 'lodash/pick';
export function sleep (user, req = {}, analytics) {
user.preferences.sleep = !user.preferences.sleep;
if (analytics) {
analytics.track('sleep', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
status: user.preferences.sleep,
category: 'behavior',
headers: req.headers,
});
}
return [user.preferences.sleep];
}
+15 -1
View File
@@ -1,4 +1,5 @@
import get from 'lodash/get';
import pick from 'lodash/pick';
import setWith from 'lodash/setWith';
import i18n from '../i18n';
import { NotAuthorized, BadRequest } from '../libs/errors';
@@ -207,7 +208,7 @@ function buildResponse ({ purchased, preference, items }, ownsAlready, language)
// If item is already purchased -> equip it
// Otherwise unlock it
// @TODO refactor and take as parameter the set name, for single items use the buy ops
export default async function unlock (user, req = {}) {
export default async function unlock (user, req = {}, analytics) {
const path = get(req.query, 'path');
if (!path) {
@@ -318,6 +319,19 @@ export default async function unlock (user, req = {}) {
if (!unlockedAlready) {
await updateUserBalance(user, -cost, 'spend', path);
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
itemKey: path,
itemType: 'customization',
currency: 'Gems',
gemCost: cost / 0.25,
category: 'behavior',
headers: req.headers,
});
}
}
return buildResponse(user, unlockedAlready, req.language);
@@ -1,5 +1,6 @@
import validator from 'validator';
import moment from 'moment';
import pick from 'lodash/pick';
import sortBy from 'lodash/sortBy';
import nconf from 'nconf';
import {
@@ -126,6 +127,14 @@ api.loginLocal = {
user.auth.timestamps.updated = new Date();
await user.save();
res.analytics.track('login', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
type: 'local',
uuid: user._id,
headers: req.headers,
});
return loginRes(user, req, res);
},
};
@@ -1,6 +1,7 @@
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import reduce from 'lodash/reduce';
import times from 'lodash/times';
import { authWithHeaders, authWithSession } from '../../middlewares/auth';
@@ -289,6 +290,19 @@ api.createChallenge = {
};
response.group = getChallengeGroupResponse(group);
res.analytics.track('challenge create', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: response._id,
groupID: group._id,
groupName: group.privacy === 'private' ? null : group.name,
groupType: group._id === TAVERN_ID ? 'tavern' : group.type,
prize: response.prize,
headers: req.headers,
});
res.respond(201, response);
},
};
@@ -345,6 +359,18 @@ api.joinChallenge = {
const chalLeader = await User.findById(response.leader).select(nameFields).exec();
response.leader = chalLeader ? chalLeader.toJSON({ minimize: true }) : null;
res.analytics.track('challenge join', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
groupID: group._id,
groupName: group.privacy === 'private' ? null : group.name,
groupType: group._id === TAVERN_ID ? 'tavern' : group.type,
headers: req.headers,
});
res.respond(200, response);
},
};
@@ -384,6 +410,18 @@ api.leaveChallenge = {
// Unlink challenge's tasks from user's tasks and save the challenge
await challenge.unlinkTasks(user, keep);
res.analytics.track('challenge leave', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
groupID: challenge.group._id,
groupName: challenge.group.privacy === 'private' ? null : challenge.group.name,
groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type,
headers: req.headers,
});
res.respond(200, {});
},
};
@@ -857,6 +895,19 @@ api.deleteChallenge = {
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({ broken: 'CHALLENGE_DELETED' });
res.analytics.track('challenge delete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
groupID: challenge.group._id,
groupName: challenge.group.privacy === 'private' ? null : challenge.group.name,
groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type,
prize: challenge.prize,
headers: req.headers,
});
res.respond(200, {});
},
};
@@ -905,6 +956,20 @@ api.selectChallengeWinner = {
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({ broken: 'CHALLENGE_CLOSED', winner });
res.analytics.track('challenge close', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
challengeWinnerID: winner._id,
groupID: challenge.group._id,
groupName: challenge.group.privacy === 'private' ? null : challenge.group.name,
groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type,
prize: challenge.prize,
headers: req.headers,
});
res.respond(200, {});
},
};
+32
View File
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import moment from 'moment';
import nconf from 'nconf';
import { authWithHeaders, chatPrivilegesRequired } from '../../middlewares/auth';
@@ -22,6 +23,9 @@ import { getMatchesByWordArray } from '../../libs/stringUtils';
import bannedSlurs from '../../libs/bannedSlurs';
import { apiError } from '../../libs/apiError';
import highlightMentions from '../../libs/highlightMentions';
import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService';
const analytics = getAnalyticsServiceByEnvironment();
const ACCOUNT_MIN_CHAT_AGE = Number(nconf.get('ACCOUNT_MIN_CHAT_AGE'));
@@ -183,6 +187,13 @@ api.postChat = {
// Check if account is newer than the minimum age for chat participation
if (moment().diff(user.auth.timestamps.created, 'minutes') < ACCOUNT_MIN_CHAT_AGE) {
analytics.track('chat age error', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
throw new BadRequest(res.t('chatTemporarilyUnavailable'));
}
@@ -228,6 +239,27 @@ api.postChat = {
await Promise.all(toSave);
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
groupType: group.type,
privacy: group.privacy,
headers: req.headers,
};
if (mentions) {
analyticsObject.mentionsCount = mentions.length;
} else {
analyticsObject.mentionsCount = 0;
}
if (group.privacy === 'public') {
analyticsObject.groupName = group.name;
}
res.analytics.track('group chat', analyticsObject);
if (chatUpdated) {
res.respond(200, { chat: chatRes.chat });
} else {
@@ -5,6 +5,7 @@ import findIndex from 'lodash/findIndex';
import includes from 'lodash/includes';
import isArray from 'lodash/isArray';
import mergeWith from 'lodash/mergeWith';
import pick from 'lodash/pick';
import uniqBy from 'lodash/uniqBy';
import nconf from 'nconf';
import moment from 'moment';
@@ -165,6 +166,25 @@ api.createGroup = {
profile: { name: user.profile.name },
};
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: true,
groupId: savedGroup._id,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
invited: false,
};
if (savedGroup.privacy === 'public') {
analyticsObject.groupName = savedGroup.name;
}
res.analytics.track('join group', analyticsObject);
res.respond(201, response); // do not remove chat flags data as we've just created the group
},
};
@@ -197,6 +217,19 @@ api.createGroupPlan = {
const results = await Promise.all([user.save(), group.save()]);
const savedGroup = results[1];
res.analytics.track('join group', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: true,
groupId: savedGroup._id,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
invited: false,
});
// do not remove chat flags data as we've just created the group
const groupResponse = savedGroup.toJSON();
// the leader is the authenticated user
@@ -552,6 +585,7 @@ api.joinGroup = {
if (!group) throw new NotFound(res.t('groupNotFound'));
let isUserInvited = false;
const seekingParty = Boolean(user.party.seeking);
if (group.type === 'party') {
// Check if was invited to party
@@ -676,6 +710,20 @@ api.joinGroup = {
promises.push(group.save());
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: false,
groupId: group._id,
groupType: group.type,
privacy: group.privacy,
headers: req.headers,
invited: isUserInvited,
seekingParty: group.type === 'party' ? seekingParty : null,
};
promises = await Promise.all(promises);
if (group.hasNotCancelled()) {
@@ -689,6 +737,8 @@ api.joinGroup = {
response.leader = leader.toJSON({ minimize: true });
}
res.analytics.track('join group', analyticsObject);
res.respond(200, response);
},
};
@@ -1,4 +1,5 @@
import escapeRegExp from 'lodash/escapeRegExp';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import {
model as User,
@@ -733,6 +734,17 @@ api.transferGems = {
}
res.respond(200, {});
if (res.analytics) {
res.analytics.track('transfer gems', {
user: pick(sender, ['preferences', 'registeredThrough']),
uuid: sender._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
quantity: gemAmount,
});
}
},
};
@@ -1,7 +1,9 @@
import each from 'lodash/each';
import every from 'lodash/every';
import isBoolean from 'lodash/isBoolean';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService';
import {
model as Group,
basicFields as basicGroupFields,
@@ -22,6 +24,8 @@ import { apiError } from '../../libs/apiError';
import { questActivityWebhook } from '../../libs/webhook';
import { model as UserHistory } from '../../models/userHistory';
const analytics = getAnalyticsServiceByEnvironment();
const questScrolls = common.content.quests;
function canStartQuestAutomatically (group) {
@@ -162,6 +166,17 @@ api.inviteToQuest = {
quest,
});
// track that the inviting user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
category: 'behavior',
headers: req.headers,
owner: true,
questName: questKey,
response: 'accept',
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withQuestInviteResponse(group.quest.key, 'invite')
.commit();
@@ -216,6 +231,17 @@ api.acceptQuest = {
res.respond(200, savedGroup.quest);
// track that a user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: false,
response: 'accept',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withQuestInviteResponse(group.quest.key, 'accept')
.commit();
@@ -271,6 +297,16 @@ api.rejectQuest = {
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: false,
response: 'reject',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withQuestInviteResponse(group.quest.key, 'reject')
.commit();
@@ -324,6 +360,16 @@ api.forceStart = {
]);
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: user._id === group.quest.leader,
response: 'force-start',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
});
},
};
@@ -1,6 +1,7 @@
import assign from 'lodash/assign';
import find from 'lodash/find';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth';
import {
@@ -329,6 +330,17 @@ api.createChallengeTasks = {
// If adding tasks to a challenge -> sync users
if (challenge) challenge.addTasks(tasks);
tasks.forEach(task => {
res.analytics.track('challenge task created', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
challengeID: challenge._id,
});
});
},
};
@@ -688,6 +700,17 @@ api.updateTask = {
task: savedTask,
});
}
if (group) {
res.analytics.track('task edit', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
groupID: group._id,
});
}
},
};
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import isUUID from 'validator/lib/isUUID';
import { authWithHeaders } from '../../../middlewares/auth';
import * as Tasks from '../../../models/task';
@@ -60,6 +61,18 @@ api.createGroupTasks = {
const tasks = await createTasks(req, res, { user, group });
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
tasks.forEach(task => {
res.analytics.track('team task created', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
groupID: group._id,
headers: req.headers,
});
});
},
};
@@ -238,6 +251,16 @@ api.assignTask = {
await Promise.all(promises);
res.respond(200, task);
res.analytics.track('task assign', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
groupID: group._id,
headers: req.headers,
});
},
};
+35 -36
View File
@@ -9,10 +9,6 @@ import {
BadRequest,
NotAuthorized,
} from '../../libs/errors';
import {
basicFields as basicGroupFields,
model as Group,
} from '../../models/group';
import * as Tasks from '../../models/task';
import * as passwordUtils from '../../libs/password';
import {
@@ -22,10 +18,12 @@ import {
getUserInfo,
sendTxn,
} from '../../libs/email';
import worker from '../../libs/worker';
import * as inboxLib from '../../libs/inbox';
import * as userLib from '../../libs/user';
import { model as UserHistory } from '../../models/userHistory';
const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
const DELETE_CONFIRMATION = 'DELETE';
@@ -297,21 +295,6 @@ api.deleteUser = {
throw new NotAuthorized(res.t('cannotDeleteActiveAccount'));
}
const types = ['party', 'guilds'];
const groupFields = basicGroupFields.concat(' leader memberCount purchased');
const groupsUserIsMemberOf = await Group.getGroups({ user, types, groupFields });
const groupLeavePromises = groupsUserIsMemberOf.map(group => group.leave(user, 'remove-all'));
await Promise.all(groupLeavePromises);
await Tasks.Task.deleteMany({
userId: user._id,
}).exec();
await user.deleteOne();
if (feedback) {
sendTxn({ email: TECH_ASSISTANCE_EMAIL }, 'admin-feedback', [
{ name: 'PROFILE_NAME', content: user.profile.name },
@@ -323,6 +306,15 @@ api.deleteUser = {
]);
}
worker.sendJob('deleteUser', {
identifier: user._id,
data: {
userId: user._id,
deleteAccount: true,
deleteAmplitude: true,
},
});
res.respond(200, {});
},
};
@@ -432,7 +424,7 @@ api.sleep = {
url: '/user/sleep',
async handler (req, res) {
const { user } = res.locals;
const sleepRes = common.ops.sleep(user, req);
const sleepRes = common.ops.sleep(user, req, res.analytics);
await user.save();
res.respond(200, ...sleepRes);
},
@@ -491,7 +483,10 @@ api.buy = {
let quantity = 1;
if (req.body.quantity) quantity = req.body.quantity;
req.quantity = quantity;
const buyRes = await common.ops.buy(user, req);
if (OFFICIAL_PLATFORMS.indexOf(req.headers['x-client']) === -1) {
res.analytics = undefined;
}
const buyRes = await common.ops.buy(user, req, res.analytics);
await user.save();
@@ -546,7 +541,7 @@ api.buyGear = {
url: '/user/buy-gear/:key',
async handler (req, res) {
const { user } = res.locals;
const buyGearRes = await common.ops.buy(user, req);
const buyGearRes = await common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyGearRes);
},
@@ -588,7 +583,10 @@ api.buyArmoire = {
const { user } = res.locals;
req.type = 'armoire';
req.params.key = 'armoire';
const buyArmoireResponse = await common.ops.buy(user, req);
if (OFFICIAL_PLATFORMS.indexOf(req.headers['x-client']) === -1) {
res.analytics = undefined;
}
const buyArmoireResponse = await common.ops.buy(user, req, res.analytics);
await user.save();
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withArmoire(buyArmoireResponse[0].armoire.dropKey || 'experience')
@@ -631,7 +629,7 @@ api.buyHealthPotion = {
const { user } = res.locals;
req.type = 'potion';
req.params.key = 'potion';
const buyHealthPotionResponse = await common.ops.buy(user, req);
const buyHealthPotionResponse = await common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyHealthPotionResponse);
},
@@ -673,7 +671,7 @@ api.buyMysterySet = {
async handler (req, res) {
const { user } = res.locals;
req.type = 'mystery';
const buyMysterySetRes = await common.ops.buy(user, req);
const buyMysterySetRes = await common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyMysterySetRes);
},
@@ -716,7 +714,7 @@ api.buyQuest = {
async handler (req, res) {
const { user } = res.locals;
req.type = 'quest';
const buyQuestRes = await common.ops.buy(user, req);
const buyQuestRes = await common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyQuestRes);
},
@@ -803,7 +801,7 @@ api.hatch = {
url: '/user/hatch/:egg/:hatchingPotion',
async handler (req, res) {
const { user } = res.locals;
const hatchRes = common.ops.hatch(user, req);
const hatchRes = common.ops.hatch(user, req, res.analytics);
await user.save();
@@ -901,7 +899,7 @@ api.feed = {
url: '/user/feed/:pet/:food',
async handler (req, res) {
const { user } = res.locals;
const feedRes = common.ops.feed(user, req);
const feedRes = common.ops.feed(user, req, res.analytics);
await user.save();
@@ -949,7 +947,7 @@ api.changeClass = {
url: '/user/change-class',
async handler (req, res) {
const { user } = res.locals;
const changeClassRes = await common.ops.changeClass(user, req);
const changeClassRes = await common.ops.changeClass(user, req, res.analytics);
await user.save();
res.respond(200, ...changeClassRes);
},
@@ -1025,7 +1023,7 @@ api.purchase = {
if (req.body.quantity) quantity = req.body.quantity;
req.quantity = quantity;
const purchaseRes = await common.ops.buy(user, req);
const purchaseRes = await common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...purchaseRes);
},
@@ -1068,6 +1066,7 @@ api.userPurchaseHourglass = {
const purchaseHourglassRes = await common.ops.buy(
user,
req,
res.analytics,
{ quantity, hourglass: true },
);
await user.save();
@@ -1164,7 +1163,7 @@ api.userOpenMysteryItem = {
url: '/user/open-mystery-item',
async handler (req, res) {
const { user } = res.locals;
const openMysteryItemRes = common.ops.openMysteryItem(user, req);
const openMysteryItemRes = common.ops.openMysteryItem(user, req, res.analytics);
await user.save();
res.respond(200, ...openMysteryItemRes);
},
@@ -1196,7 +1195,7 @@ api.userReleasePets = {
url: '/user/release-pets',
async handler (req, res) {
const { user } = res.locals;
const releasePetsRes = await common.ops.releasePets(user, req);
const releasePetsRes = await common.ops.releasePets(user, req, res.analytics);
await user.save();
res.respond(200, ...releasePetsRes);
},
@@ -1245,7 +1244,7 @@ api.userReleaseBoth = {
url: '/user/release-both',
async handler (req, res) {
const { user } = res.locals;
const releaseBothRes = common.ops.releaseBoth(user, req);
const releaseBothRes = common.ops.releaseBoth(user, req, res.analytics);
await user.save();
res.respond(200, ...releaseBothRes);
},
@@ -1281,7 +1280,7 @@ api.userReleaseMounts = {
url: '/user/release-mounts',
async handler (req, res) {
const { user } = res.locals;
const releaseMountsRes = await common.ops.releaseMounts(user, req);
const releaseMountsRes = await common.ops.releaseMounts(user, req, res.analytics);
await user.save();
res.respond(200, ...releaseMountsRes);
},
@@ -1357,7 +1356,7 @@ api.userUnlock = {
url: '/user/unlock',
async handler (req, res) {
const { user } = res.locals;
const unlockRes = await common.ops.unlock(user, req);
const unlockRes = await common.ops.unlock(user, req, res.analytics);
await user.save();
res.respond(200, ...unlockRes);
},
@@ -1383,7 +1382,7 @@ api.userRevive = {
url: '/user/revive',
async handler (req, res) {
const { user } = res.locals;
const reviveRes = common.ops.revive(user, req);
const reviveRes = common.ops.revive(user, req, res.analytics);
await user.save();
res.respond(200, ...reviveRes);
},
+3 -2
View File
@@ -1,4 +1,4 @@
import { sendJob } from '../../libs/worker';
import worker from '../../libs/worker';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { TransactionModel as Transaction } from '../../models/transaction';
@@ -48,7 +48,8 @@ api.deleteMember = {
req.checkQuery('deleteAmplitude').optional().isIn(['true', 'false']);
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
sendJob('delete-user', {
await worker.sendJob('deleteUser', {
identifier: req.params.memberId,
data: {
userId: req.params.memberId,
deleteAccount: req.query.deleteAccount === 'true',
+4 -1
View File
@@ -24,7 +24,10 @@ api.getReady = {
middlewares: [disableCache],
async handler (req, res) {
// This allows kubernetes to determine if the server is ready to receive traffic
if (!SERVER_STATUS.MONGODB || !SERVER_STATUS.REDIS || !SERVER_STATUS.EXPRESS) {
if (!SERVER_STATUS.MONGODB
|| !SERVER_STATUS.RATE_LIMITER
|| !SERVER_STATUS.WORKER
|| !SERVER_STATUS.EXPRESS) {
res.respond(503, {
status: 'not ready',
});
@@ -0,0 +1,47 @@
import pick from 'lodash/pick';
import {
NotAuthorized,
} from '../../libs/errors';
import {
authWithHeaders,
} from '../../middlewares/auth';
const api = {};
/**
* @apiIgnore Analytics are considered part of the private API
* @api {post} /analytics/track/:eventName Track a generic analytics event
* @apiName AnalyticsTrack
* @apiGroup Analytics
*
* @apiSuccess {Object} data An empty object
* */
api.trackEvent = {
method: 'POST',
url: '/analytics/track/:eventName',
// we authenticate these requests to make sure they actually came from a real user
middlewares: [authWithHeaders()],
async handler (req, res) {
// As of now only web can track events using this route
if (req.headers['x-client'] !== 'habitica-web') {
throw new NotAuthorized('Only habitica.com is allowed to track analytics events.');
}
const { user } = res.locals;
const eventProperties = req.body;
res.analytics.track(req.params.eventName, {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
headers: req.headers,
category: 'behavior',
...eventProperties,
});
// not using res.respond
// because we don't want to send back notifications and other user-related data
res.status(200).send({});
},
};
export default api;
+270
View File
@@ -0,0 +1,270 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import useragent from 'useragent';
import {
omit,
toArray,
} from 'lodash';
import common from '../../common';
import logger from './logger';
const LOG_AMPLITUDE_EVENTS = nconf.get('LOG_AMPLITUDE_EVENTS') === 'true';
const AMPLITUDE_TOKEN = nconf.get('AMPLITUDE_KEY');
const AMPLITUDE_PROPERTIES_TO_SCRUB = [
'uuid', 'user', 'purchaseValue',
'headers', 'registeredThrough',
];
const PLATFORM_MAP = Object.freeze({
'habitica-web': 'Web',
'habitica-ios': 'iOS',
'habitica-android': 'Android',
});
let amplitude;
if (AMPLITUDE_TOKEN) amplitude = new Amplitude(AMPLITUDE_TOKEN);
const Content = common.content;
function _lookUpItemName (itemKey) {
if (!itemKey) return null;
const gear = Content.gear.flat[itemKey];
const egg = Content.eggs[itemKey];
const food = Content.food[itemKey];
const hatchingPotion = Content.hatchingPotions[itemKey];
const quest = Content.quests[itemKey];
const spell = Content.special[itemKey];
let itemName;
if (gear) {
itemName = gear.text();
} else if (egg) {
itemName = `${egg.text()} Egg`;
} else if (food) {
itemName = food.text();
} else if (hatchingPotion) {
itemName = `${hatchingPotion.text()} Hatching Potion`;
} else if (quest) {
itemName = quest.text();
} else if (spell) {
itemName = spell.text();
}
return itemName;
}
function _formatUserData (user) {
const properties = {};
if (user.stats) {
properties.Class = user.stats.class;
properties.Experience = Math.floor(user.stats.exp);
properties.Gold = Math.floor(user.stats.gp);
properties.Health = Math.ceil(user.stats.hp);
properties.Level = user.stats.lvl;
properties.Mana = Math.floor(user.stats.mp);
}
properties.balance = user.balance;
properties.balanceGemAmount = properties.balance * 4;
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
properties.verifiedUsername = user.flags && user.flags.verifiedUsername;
if (properties.verifiedUsername && user.auth && user.auth.local) {
properties.username = user.auth.local.lowerCaseUsername;
}
if (user.habits && user.dailys && user.todos && user.rewards) {
properties['Number Of Tasks'] = {
habits: user.habits.length,
dailys: user.dailys.length,
todos: user.todos.length,
rewards: user.rewards.length,
};
}
if (user.contributor && user.contributor.level) {
properties.contributorLevel = user.contributor.level;
}
if (user.purchased && user.purchased.plan.planId) {
properties.subscription = user.purchased.plan.planId;
} else {
properties.subscription = null;
}
if (user._ABtests) {
properties.ABtests = toArray(user._ABtests);
}
if (user.loginIncentives) {
properties.loginIncentives = user.loginIncentives;
}
return properties;
}
function _formatPlatformForAmplitude (platform) {
if (!platform) {
return 'Unknown';
}
if (platform in PLATFORM_MAP) {
return PLATFORM_MAP[platform];
}
return '3rd Party';
}
function _formatUserAgentForAmplitude (platform, agentString) {
if (!agentString) {
return 'Unknown';
}
const agent = useragent.lookup(agentString).toJSON();
const formattedAgent = {};
if (platform === 'iOS' || platform === 'Android') {
formattedAgent.name = agent.os.family;
formattedAgent.version = `${agent.os.major}.${agent.os.minor}.${agent.os.patch}`;
if (platform === 'Android' && formattedAgent.name === 'Other') {
formattedAgent.name = 'Android';
}
} else {
formattedAgent.name = agent.family;
formattedAgent.version = agent.major;
}
return formattedAgent;
}
function _formatUUIDForAmplitude (uuid) {
return uuid || 'no-user-id-was-provided';
}
function _formatDataForAmplitude (data) {
const event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB);
const platform = _formatPlatformForAmplitude(data.headers && data.headers['x-client']);
const agent = _formatUserAgentForAmplitude(platform, data.headers && data.headers['user-agent']);
const ampData = {
user_id: _formatUUIDForAmplitude(data.uuid),
platform,
os_name: agent.name,
os_version: agent.version,
event_properties,
};
if (data.user) {
ampData.user_properties = _formatUserData(data.user);
}
const itemName = _lookUpItemName(data.itemKey);
if (itemName) {
ampData.event_properties.itemName = itemName;
}
return ampData;
}
function _sendDataToAmplitude (eventType, data, loggerOnly) {
const amplitudeData = _formatDataForAmplitude(data);
amplitudeData.event_type = eventType;
if (LOG_AMPLITUDE_EVENTS) {
logger.info('Amplitude Event', amplitudeData);
}
if (loggerOnly) return Promise.resolve(null);
return amplitude
.track(amplitudeData)
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _sendPurchaseDataToAmplitude (data) {
const amplitudeData = _formatDataForAmplitude(data);
// Stripe transactions come via webhook. We can log these as Web events
if (data.paymentMethod === 'Stripe' && amplitudeData.platform === 'Unknown') {
amplitudeData.platform = 'Web';
}
amplitudeData.event_type = 'purchase';
amplitudeData.revenue = data.purchaseValue;
amplitudeData.productId = data.itemPurchased;
if (LOG_AMPLITUDE_EVENTS) {
logger.info('Amplitude Purchase Event', amplitudeData);
}
return amplitude
.track(amplitudeData)
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _setOnce (dataToSetOnce, uuid) {
return amplitude
.identify({
user_id: _formatUUIDForAmplitude(uuid),
user_properties: {
$setOnce: dataToSetOnce,
},
})
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
// There's no error handling directly here because it's handled inside _sendDataTo{Amplitude|Google}
async function track (eventType, data, loggerOnly = false) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
const promises = [
_sendDataToAmplitude(eventType, data, loggerOnly),
];
if (user.registeredThrough) {
promises.push(_setOnce({
registeredPlatform: user.registeredThrough,
}, data.uuid || user._id));
}
return Promise.all(promises);
}
// There's no error handling directly here because
// it's handled inside _sendPurchaseDataTo{Amplitude|Google}
async function trackPurchase (data) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
return Promise.all([
_sendPurchaseDataToAmplitude(data),
]);
}
// Stub for non-prod environments
const mockAnalyticsService = {
track: () => { },
trackPurchase: () => { },
};
// Return the production or mock service based on the current environment
function getServiceByEnvironment () {
if (nconf.get('IS_PROD') || (nconf.get('DEBUG_ENABLED') && !nconf.get('BASE_URL').includes('localhost'))) {
return {
track,
trackPurchase,
};
}
return mockAnalyticsService;
}
export {
track,
trackPurchase,
mockAnalyticsService,
getServiceByEnvironment as getAnalyticsServiceByEnvironment,
};
+11 -2
View File
@@ -1,4 +1,5 @@
import moment from 'moment';
import pick from 'lodash/pick';
import {
BadRequest,
NotAuthorized,
@@ -18,7 +19,6 @@ import {
} from './social';
import { loginRes } from './utils';
import { verifyUsername } from '../user/validation';
import { trackRegistrationEvent } from '../localAnalytics';
const USERNAME_LENGTH_MIN = 1;
const USERNAME_LENGTH_MAX = 20;
@@ -180,7 +180,6 @@ async function registerLocal (req, res, { isV3 = false }) {
} else {
newUser = new User(newUser);
newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used
trackRegistrationEvent({ user: newUser, method: 'local', ipAddress: req.ip });
}
// we check for partyInvite for backward compatibility
@@ -218,6 +217,16 @@ async function registerLocal (req, res, { isV3 = false }) {
})
.catch(err => logger.error(err));
if (!existingUser) {
res.analytics.track('register', {
user: pick(savedUser, ['preferences', 'registeredThrough']),
category: 'acquisition',
type: 'local',
uuid: savedUser._id,
headers: req.headers,
});
}
return null;
}
+11 -2
View File
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import passport from 'passport';
import common from '../../../common';
import { verifyUsername } from '../user/validation';
@@ -12,7 +13,6 @@ import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../email';
import { apiError } from '../apiError';
import { trackRegistrationEvent } from '../localAnalytics';
function _passportProfile (network, accessToken) {
return new Promise((resolve, reject) => {
@@ -144,7 +144,6 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
};
user = new User(user);
user.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used
trackRegistrationEvent({ user, method: network, ipAddress: req.ip });
}
const savedUser = await user.save();
@@ -172,5 +171,15 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
.catch(err => logger.error(err)); // eslint-disable-line max-nested-callbacks
}
if (!existingUser) {
res.analytics.track('register', {
user: pick(savedUser, ['preferences', 'registeredThrough']),
uuid: savedUser._id,
category: 'acquisition',
type: network,
headers: req.headers,
});
}
return response;
}
+20 -1
View File
@@ -1,5 +1,6 @@
import moment from 'moment';
import mongoose from 'mongoose';
import pick from 'lodash/pick';
import nconf from 'nconf';
import { model as User } from '../models/user';
import * as Tasks from '../models/task';
@@ -99,6 +100,20 @@ function processHabits (user, habits, now, daysMissed) {
});
}
function trackCronAnalytics (analytics, user, _progress, options) {
analytics.track('Cron', {
category: 'behavior',
uuid: user._id,
user: pick(user, ['preferences', 'registeredThrough']),
resting: user.preferences.sleep,
cronCount: user.flags.cronCount,
progressUp: Math.min(_progress.up, 900),
progressDown: _progress.down,
headers: options.headers,
loginIncentives: user.loginIncentives,
});
}
function awardLoginIncentives (user) {
if (user.loginIncentives > MAX_INCENTIVES) return;
@@ -150,7 +165,7 @@ function awardLoginIncentives (user) {
// Perform various beginning-of-day reset actions.
export async function cron (options = {}) {
const {
user, tasksByType, now = new Date(), daysMissed, timezoneUtcOffsetFromUserPrefs,
user, tasksByType, analytics, now = new Date(), daysMissed, timezoneUtcOffsetFromUserPrefs,
} = options;
let _progress = { down: 0, up: 0, collectedItems: 0 };
@@ -377,7 +392,9 @@ export async function cron (options = {}) {
user.pinnedItems = common.cleanupPinnedItems(user);
}
// Analytics
user.flags.cronCount += 1;
trackCronAnalytics(analytics, user, _progress, options);
await UserHistory.beginUserHistoryUpdate(user._id, options.headers)
.withCron(user.flags.cronCount)
@@ -421,6 +438,7 @@ export async function cronWrapper (req, res) {
const { user } = res.locals;
if (!user) return null; // User might not be available when authentication is not mandatory
const { analytics } = res;
const now = new Date();
let session;
@@ -470,6 +488,7 @@ export async function cronWrapper (req, res) {
tasksByType,
now,
daysMissed,
analytics,
timezoneUtcOffsetFromUserPrefs,
headers: req.headers,
});
+3 -2
View File
@@ -2,7 +2,7 @@ import nconf from 'nconf';
import { TAVERN_ID } from '../models/group'; // eslint-disable-line import/no-cycle
import { encrypt } from './encryption';
import common from '../../common';
import { sendJob } from './worker';
import worker from './worker';
const IS_PROD = nconf.get('IS_PROD');
const BASE_URL = nconf.get('BASE_URL');
@@ -148,7 +148,8 @@ export async function sendTxn (mailingInfoArray, emailType, variables, personalV
}
if (IS_PROD && mailingInfoArray.length > 0) {
return sendJob('email', {
return worker.sendJob('email', {
identifier: emailType,
data: {
emailType,
to: mailingInfoArray,
+49
View File
@@ -1,5 +1,6 @@
import find from 'lodash/find';
import includes from 'lodash/includes';
import pick from 'lodash/pick';
import { encrypt } from '../encryption';
import { sendNotification as sendPushNotification } from '../pushNotifications';
@@ -142,6 +143,23 @@ async function inviteByUUID (uuid, group, inviter, req, res) {
));
}
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
invitee: uuid,
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(userToInvite.party.seeking);
}
res.analytics.track('group invite', analyticsObject);
return addInvitationToUser(userToInvite, group, inviter, res);
}
@@ -189,6 +207,19 @@ async function inviteByEmail (invite, group, inviter, req, res) {
const userIsUnsubscribed = await EmailUnsubscription.findOne({ email: invite.email }).exec();
const groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
invitee: 'email',
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
res.analytics.track('group invite', analyticsObject);
}
return userReturnInfo;
@@ -214,6 +245,24 @@ async function inviteByUserName (username, group, inviter, req, res) {
{ userId: userToInvite._id, username: userToInvite.profile.name },
));
}
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
invitee: userToInvite._id,
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(userToInvite.party.seeking);
}
res.analytics.track('group invite', analyticsObject);
return addInvitationToUser(userToInvite, group, inviter, res);
}
-50
View File
@@ -1,50 +0,0 @@
import nconf from 'nconf';
import { RegistrationEventModel } from '../models/analytics/registrationEvent';
import { SubscriptionEventModel } from '../models/analytics/subscriptionEvent';
const LOCAL_ANALYTICS = !nconf.get('DISABLE_LOCAL_ANALYTICS');
function getAuthenticationMethod (user) {
if (user.auth.google && user.auth.google.id) return 'google';
if (user.auth.facebook && user.auth.facebook.id) return 'facebook';
if (user.auth.apple && user.auth.apple.id) return 'apple';
return 'local';
}
export async function trackRegistrationEvent (eventData) {
if (!LOCAL_ANALYTICS) return null;
const { user, ipAddress, method } = eventData;
const registrationEvent = new RegistrationEventModel({
userId: user._id,
ipAddress,
authenticationMethod: method || getAuthenticationMethod(user),
platform: user.registeredThrough,
language: user.preferences.language,
});
return registrationEvent.save();
}
export async function trackSubscriptionEvent (eventData) {
if (!LOCAL_ANALYTICS) return null;
const {
eventType,
user,
paymentMethod,
customerId,
planId,
cancellationReason,
} = eventData;
const subscriptionEvent = new SubscriptionEventModel({
userId: user._id,
eventType,
paymentMethod,
customerId,
planId,
cancellationReason,
});
return subscriptionEvent.save();
}
-14
View File
@@ -38,17 +38,3 @@ export default async function connectToMongoDB () {
}
return null;
}
let analyticsDb;
export function getAnalyticsDatabase () {
if (!analyticsDb) {
const analyticsDbName = nconf.get('ANALYTICS_DB');
const analyticsDbUri = nconf.get('ANALYTICS_DB_URI') || connectionUrl;
analyticsDb = mongoose.createConnection(analyticsDbUri, {
...mongooseOptions,
dbName: analyticsDbName,
});
}
return analyticsDb;
}
+18
View File
@@ -1,4 +1,6 @@
import find from 'lodash/find';
import pick from 'lodash/pick';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import { getCurrentEventList } from '../worldState'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
getUserInfo,
@@ -11,6 +13,8 @@ import {
} from '../errors';
import { apiError } from '../apiError';
const analytics = getAnalyticsServiceByEnvironment();
function getGiftMessage (data, byUsername, gemAmount, language) {
const senderMsg = shared.i18n.t('giftedGemsFull', {
username: data.gift.member.profile.name,
@@ -110,6 +114,20 @@ export async function buyGems (data) {
if (!data.gift) txnEmail(data.user, 'donation');
analytics.trackPurchase({
user: pick(data.user, ['preferences', 'registeredThrough']),
uuid: data.user._id,
itemPurchased: 'Gems',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: amt,
headers: data.headers,
firstPurchase: data.user.purchased.txnCount === 1,
});
if (data.gift) await buyGemGift(data);
await data.user.save();
+17
View File
@@ -1,10 +1,14 @@
import pick from 'lodash/pick';
import moment from 'moment';
import {
BadRequest,
} from '../errors';
import shared from '../../../common';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import { getGemsBlock, buyGems } from './gems'; // eslint-disable-line import/no-cycle
const analytics = getAnalyticsServiceByEnvironment();
const RESPONSE_INVALID_ITEM = 'INVALID_ITEM_PURCHASED';
const EVENTS = {
@@ -27,6 +31,19 @@ async function buyGryphatrice (data) {
data.user.items.pets[key] = 5;
data.user.purchased.txnCount += 1;
analytics.trackPurchase({
user: pick(data.user, ['preferences', 'registeredThrough']),
uuid: data.user._id,
itemPurchased: 'Gryphatrice',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: 10,
headers: data.headers,
firstPurchase: data.user.purchased.txnCount === 1,
});
if (data.user.markModified) data.user.markModified('items.pets');
await data.user.save();
}
+42 -28
View File
@@ -3,7 +3,10 @@
import defaults from 'lodash/defaults';
import each from 'lodash/each';
import find from 'lodash/find';
import pick from 'lodash/pick';
import moment from 'moment';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import * as slack from '../slack'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
getUserInfo,
@@ -23,10 +26,10 @@ import calculateSubscriptionTerminationDate from './calculateSubscriptionTermina
import { getCurrentEventList } from '../worldState'; // eslint-disable-line import/no-cycle
import { paymentConstants } from './constants';
import { addSubscriptionToGroupUsers, cancelGroupUsersSubscription } from './groupPayments'; // eslint-disable-line import/no-cycle
import { trackSubscriptionEvent } from '../localAnalytics';
// @TODO: Abstract to shared/constant
const JOINED_GROUP_PLAN = 'joined group plan';
const analytics = getAnalyticsServiceByEnvironment();
function _findMysteryItems (user, dateMoment) {
const pushedItems = [];
@@ -78,14 +81,6 @@ async function prepareSubscriptionValues (data) {
? shared.content.subscriptionBlocks[data.updatedFrom.key]
: undefined;
let months;
let subscriptionEventType = 'subscribed';
if (updatedFrom) {
if (Number(updatedFrom.months) > Number(block.months)) {
subscriptionEventType = 'downgraded';
} else {
subscriptionEventType = 'upgraded';
}
}
if (updatedFrom && Number(updatedFrom.months) !== 1) {
if (Number(updatedFrom.months) > Number(block.months)) {
months = 0;
@@ -131,6 +126,13 @@ async function prepareSubscriptionValues (data) {
user: data.user, groupId: data.groupId, populateLeader: false, groupFields,
});
if (group) {
analytics.track(
data.groupID,
data.demographics,
);
}
if (!group) {
throw new NotFound(shared.i18n.t('groupNotFound'));
}
@@ -228,7 +230,6 @@ async function prepareSubscriptionValues (data) {
purchaseType,
emailType,
isNewSubscription,
subscriptionEventType,
};
}
@@ -241,9 +242,10 @@ async function createSubscription (data) {
autoRenews,
group,
groupId,
itemPurchased,
purchaseType,
emailType,
isNewSubscription,
subscriptionEventType,
} = await prepareSubscriptionValues(data);
if (recipient !== group) {
recipient.items.pets['Jackalope-RoyalPurple'] = 5;
@@ -275,6 +277,22 @@ async function createSubscription (data) {
if (!group && !data.promo) data.user.purchased.txnCount += 1;
if (!data.promo) {
analytics.trackPurchase({
uuid: data.user._id,
groupId,
itemPurchased,
sku: `${data.paymentMethod.toLowerCase()}-subscription`,
purchaseType,
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: block.price,
headers: data.headers || { 'x-client': 'habitica-web' },
firstPurchase: !group && data.user.purchased.txnCount === 1,
});
}
if (data.gift) {
const byUserName = getUserInfo(data.user, ['name']).name;
@@ -363,16 +381,6 @@ async function createSubscription (data) {
if (data.user && data.user.isModified()) await data.user.save();
if (data.gift) await data.gift.member.save();
await trackSubscriptionEvent({
eventType: subscriptionEventType,
user: data.gift ? data.gift.member : data.user,
gifted: data.gift !== undefined,
autoRenews,
paymentMethod: data.paymentMethod,
planId: block.key,
customerId: plan.customerId,
});
slack.sendSubscriptionNotification({
buyer: {
id: data.user._id,
@@ -395,6 +403,8 @@ async function createSubscription (data) {
async function cancelSubscription (data) {
let plan;
let group;
let cancelType = 'unsubscribe';
let groupId;
let emailType;
const emailMergeData = [];
let sendEmail = true;
@@ -452,13 +462,17 @@ async function cancelSubscription (data) {
txnEmail(data.user, emailType, emailMergeData);
}
await trackSubscriptionEvent({
eventType: 'cancelled',
user: data.user,
cancellationReason: data.cancellationReason,
paymentMethod: plan.paymentMethod,
planId: plan.planId,
customerId: plan.customerId,
if (group) {
cancelType = 'group-unsubscribe';
groupId = group._id;
}
analytics.track(cancelType, {
uuid: data.user._id,
user: pick(data.user, ['preferences', 'registeredThrough']),
groupId,
paymentMethod: data.paymentMethod,
headers: data.headers,
});
}
+22
View File
@@ -0,0 +1,22 @@
import IORedis from 'ioredis';
export default function setupRedis (connectionOptions, config) {
const redisConfig = { ...config };
if (connectionOptions.username) {
redisConfig.username = connectionOptions.username;
}
if (connectionOptions.password) {
redisConfig.password = connectionOptions.password;
}
if (connectionOptions.db) {
redisConfig.db = connectionOptions.db;
}
let connection;
const redisUrl = connectionOptions.url;
if (redisUrl) {
connection = new IORedis(redisUrl, redisConfig);
} else {
connection = new IORedis(connectionOptions.port, connectionOptions.host, redisConfig);
}
return connection;
}
+2 -1
View File
@@ -1,6 +1,7 @@
const SERVER_STATUS = {
MONGODB: false,
REDIS: false,
RATE_LIMITER: false,
WORKER: false,
EXPRESS: false,
};
+27 -4
View File
@@ -3,6 +3,7 @@ import cloneDeep from 'lodash/cloneDeep';
import compact from 'lodash/compact';
import forEach from 'lodash/forEach';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import remove from 'lodash/remove';
import validator from 'validator';
import {
@@ -76,7 +77,7 @@ async function createTasks (req, res, options = {}) {
// are the onboarding ones
if (!user.achievements.createdTask && user.flags.welcomed) {
user.addAchievement('createdTask');
shared.onboarding.checkOnboardingStatus(user, req);
shared.onboarding.checkOnboardingStatus(user, req, res.analytics);
}
}
@@ -461,14 +462,14 @@ async function scoreTask (user, task, direction, req, res) {
task,
user: rollbackUser,
direction,
}, req);
}, req, res.analytics);
await rollbackUser.save();
} else {
delta = shared.ops.scoreTask({ task, user, direction }, req);
delta = shared.ops.scoreTask({ task, user, direction }, req, res.analytics);
}
// Drop system (don't run on the client,
// as it would only be discarded since ops are sent to the API, not the results)
if (direction === 'up' && !firstTask) shared.fns.randomDrop(user, { task, delta }, req);
if (direction === 'up' && !firstTask) shared.fns.randomDrop(user, { task, delta }, req, res.analytics);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code?
@@ -505,6 +506,28 @@ async function scoreTask (user, task, direction, req, res) {
user,
});
if (group) {
let role;
if (group.leader === user._id) {
role = 'leader';
} else if (group.managers[user._id]) {
role = 'manager';
} else {
role = 'member';
}
res.analytics.track('team task scored', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
direction,
headers: req.headers,
groupID: group._id,
role,
});
}
return {
task,
delta,
+24 -2
View File
@@ -116,6 +116,13 @@ export async function update (req, res, { isV3 = false }) {
if (req.body['party.seeking'] !== undefined && req.body['party.seeking'] !== null) {
user.invitations.party = {};
user.invitations.parties = [];
res.analytics.track('Starts Looking for Party', {
user: _.pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
}
let slurWasUsed = false;
@@ -193,6 +200,13 @@ export async function update (req, res, { isV3 = false }) {
if (key === 'party.seeking' && val === null) {
user.party.seeking = undefined;
res.analytics.track('Leaves Looking for Party', {
user: _.pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
} else if (key === 'tags') {
if (!Array.isArray(val)) throw new BadRequest('Tag list must be an array.');
@@ -277,6 +291,14 @@ export async function reset (req, res, { isV3 = false }) {
user.save(),
]);
res.analytics.track('account reset', {
user: _.pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
res.respond(200, ...resetRes);
}
@@ -288,7 +310,7 @@ export async function reroll (req, res, { isV3 = false }) {
...Tasks.taskIsGroupOrChallengeQuery,
};
const tasks = await Tasks.Task.find(query).exec();
const rerollRes = await common.ops.reroll(user, tasks, req);
const rerollRes = await common.ops.reroll(user, tasks, req, res.analytics);
if (isV3) {
rerollRes[0].user = await rerollRes[0].user.toJSONWithInbox();
}
@@ -309,7 +331,7 @@ export async function rebirth (req, res, { isV3 = false }) {
...Tasks.taskIsGroupOrChallengeQuery,
}).exec();
const rebirthRes = await common.ops.rebirth(user, tasks, req);
const rebirthRes = await common.ops.rebirth(user, tasks, req, res.analytics);
if (isV3) {
rebirthRes[0].user = await rebirthRes[0].user.toJSONWithInbox();
}
+43 -27
View File
@@ -1,33 +1,49 @@
import got from 'got';
import nconf from 'nconf';
import logger from './logger';
import { Queue } from 'bullmq';
import setupRedis from './redis';
import SERVER_STATUS from './serverStatus';
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER_URL'),
auth: {
user: nconf.get('EMAIL_SERVER_AUTH_USER'),
password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'),
},
};
let redisClient;
const queues = {};
export function sendJob (type, config) {
const { data, options } = config;
const usedOptions = {
backoff: { delay: 10 * 60 * 1000, type: 'exponential' },
...options,
if (nconf.get('WORKER_REDIS_URL')) {
redisClient = setupRedis({
url: nconf.get('WORKER_REDIS_URL'),
username: nconf.get('WORKER_REDIS_USERNAME'),
password: nconf.get('WORKER_REDIS_PASSWORD'),
});
redisClient.on('ready', () => {
SERVER_STATUS.WORKER = true;
});
redisClient.on('reconnecting', () => {
SERVER_STATUS.WORKER = false;
});
const queueConfig = {
connection: redisClient,
};
if (nconf.get('WORKER_REDIS_KEY_PREFIX')) {
queueConfig.prefix = nconf.get('WORKER_REDIS_KEY_PREFIX');
}
return got.post(`${EMAIL_SERVER.url}/job`, {
retry: 5, // retry the http request to the email server 5 times
timeout: 60000, // wait up to 60s before timing out
username: EMAIL_SERVER.auth.user,
password: EMAIL_SERVER.auth.password,
json: {
type,
data,
options: usedOptions,
},
}).json().catch(err => logger.error(err, {
extraMessage: 'Error while sending an email.',
}));
queues.email = new Queue('emails', queueConfig);
queues.deleteUser = new Queue('DeleteUsers', queueConfig);
} else {
SERVER_STATUS.WORKER = true;
}
function sendJob (type, config) {
if (!queues[type]) {
return Promise.reject(new Error(`Queue ${type} does not exist`));
}
const { identifier, data } = config;
return queues[type].add(identifier, data);
}
export function getRedisClient () {
return redisClient;
}
export default { sendJob };
+11
View File
@@ -0,0 +1,11 @@
import {
getAnalyticsServiceByEnvironment,
} from '../libs/analyticsService';
const service = getAnalyticsServiceByEnvironment();
export default function attachAnalytics (req, res, next) {
res.analytics = service;
next();
}
+2
View File
@@ -1,6 +1,7 @@
import express from 'express';
import expressValidator from 'express-validator';
import path from 'path';
import analytics from './analytics';
import setupBody from './setupBody';
import rateLimiter from './rateLimiter';
import setupExpress from '../libs/setupExpress';
@@ -16,6 +17,7 @@ const app = express();
setupExpress(app);
app.use(expressValidator());
app.use(analytics);
app.use(setupBody);
const topLevelRouter = express.Router(); // eslint-disable-line new-cap
+15 -12
View File
@@ -1,5 +1,4 @@
import nconf from 'nconf';
import redis from 'redis';
import {
RateLimiterRedis,
RateLimiterMemory,
@@ -11,6 +10,7 @@ import {
import logger from '../libs/logger';
import { apiError } from '../libs/apiError';
import SERVER_STATUS from '../libs/serverStatus';
import setupRedis from '../libs/redis';
// Middleware to rate limit requests to the API
@@ -19,9 +19,6 @@ import SERVER_STATUS from '../libs/serverStatus';
const IS_TEST = nconf.get('IS_TEST');
const RATE_LIMITER_ENABLED = nconf.get('RATE_LIMITER_ENABLED') === 'true';
const REDIS_HOST = nconf.get('REDIS_HOST');
const REDIS_PASSWORD = nconf.get('REDIS_PASSWORD');
const REDIS_PORT = nconf.get('REDIS_PORT');
const LIVELINESS_PROBE_KEY = nconf.get('LIVELINESS_PROBE_KEY');
const REGISTRATION_COST = nconf.get('RATE_LIMITER_REGISTRATION_COST') || 5;
const IP_RATE_LIMIT_COST = nconf.get('RATE_LIMITER_IP_COST') || 5;
@@ -41,19 +38,21 @@ if (RATE_LIMITER_ENABLED) {
...rateLimiterOpts,
});
} else {
redisClient = redis.createClient({
host: REDIS_HOST,
password: REDIS_PASSWORD,
port: REDIS_PORT,
enable_offline_queue: false,
redisClient = setupRedis({
host: nconf.get('REDIS_HOST'),
username: nconf.get('REDIS_USERNAME'),
password: nconf.get('REDIS_PASSWORD'),
port: nconf.get('REDIS_PORT'),
}, {
enableOfflineQueue: false,
});
redisClient.on('ready', () => {
SERVER_STATUS.REDIS = true;
SERVER_STATUS.RATE_LIMITER = true;
});
redisClient.on('reconnecting', () => {
SERVER_STATUS.REDIS = false;
SERVER_STATUS.RATE_LIMITER = false;
});
redisClient.on('error', error => {
@@ -66,7 +65,7 @@ if (RATE_LIMITER_ENABLED) {
});
}
} else {
SERVER_STATUS.REDIS = true;
SERVER_STATUS.RATE_LIMITER = true;
}
function setResponseHeaders (res, rateLimiterRes) {
@@ -83,6 +82,10 @@ function setResponseHeaders (res, rateLimiterRes) {
res.set(headers);
}
export function getRedisClient () {
return redisClient;
}
export default function rateLimiterMiddleware (req, res, next) {
if (!RATE_LIMITER_ENABLED) return next();
if (LIVELINESS_PROBE_KEY && req.query.liveliness === LIVELINESS_PROBE_KEY) return next();
@@ -1,32 +0,0 @@
import mongoose from 'mongoose';
import validator from 'validator';
import baseModel from '../../libs/baseModel';
import { getAnalyticsDatabase } from '../../libs/mongoose';
const { Schema } = mongoose;
export const schema = new Schema({
userId: {
$type: String, ref: 'User', required: true, validate: [v => validator.isUUID(v), 'Invalid uuid for user.'],
},
ipAddress: { $type: String },
platform: { $type: String },
authenticationMethod: { $type: String },
language: { $type: String },
}, {
strict: true,
typeKey: '$type',
});
schema.plugin(baseModel, {
noSet: [
'id',
'_id',
'userId',
'platform',
'authenticationMethod',
], // Nothing can be set from the client
timestamps: true,
});
export const RegistrationEventModel = getAnalyticsDatabase().model('RegistrationEvent', schema);

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