diff --git a/package-lock.json b/package-lock.json index 1a8fb4645e..0a7c6ea81f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "method-override": "^3.0.0", "moment": "^2.29.4", "moment-recur": "^1.0.7", - "mongoose": "^7.8.3", + "mongoose": "^8.9.5", "morgan": "^1.10.0", "nconf": "^0.12.1", "node-gcm": "^1.0.5", @@ -3047,7 +3047,7 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", - "optional": true, + "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -3677,14 +3677,15 @@ "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", "dependencies": { - "@types/node": "*", "@types/webidl-conversions": "*" } }, @@ -6401,10 +6402,10 @@ } }, "node_modules/bson": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.3.0.tgz", - "integrity": "sha512-balJfqwwTBddxfnidJZagCBPP/f48zj9Sdp3OJswREOgsJzHiQSaOIAtApSgDQFYgHqAvFkp53AFSqjMDZoTFw==", - "dev": true, + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.2.tgz", + "integrity": "sha512-5afhLTjqDSA3akH56E+/2J6kTDuSIlBxyXPdQslj9hcIgOUE378xdOfZvC/9q3LifJNI6KR/juZ+d0NRNYBwXg==", + "license": "Apache-2.0", "engines": { "node": ">=16.20.1" } @@ -13360,28 +13361,6 @@ "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==" }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ip-address/node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" - }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -14296,9 +14275,10 @@ } }, "node_modules/kareem": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } @@ -14307,7 +14287,7 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-1.1.7.tgz", "integrity": "sha512-1zXg4rARjsh/VMz2jjZeTfRHbJTVNR6f2DYHbLvtUSOW1satj33Fvc7vOJ0YVWB9+/9ITJWd1QKp4w217SsiFA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "dependencies": { "bindings": "^1.5.0", @@ -14965,8 +14945,7 @@ "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" }, "node_modules/meow": { "version": "3.7.0", @@ -15475,43 +15454,47 @@ } }, "node_modules/mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "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": "^8.2.1", - "whatwg-url": "^11.0.0" + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" } }, "node_modules/mongodb-connection-string-url/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "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": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "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": "^3.0.0", + "tr46": "^5.0.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/mongodb-core": { @@ -15596,55 +15579,64 @@ } }, "node_modules/mongoose": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.3.tgz", - "integrity": "sha512-eFnbkKgyVrICoHB6tVJ4uLanS7d5AIo/xHkEbQeOv6g2sD7gh/1biRwvFifsmbtkIddQVNr3ROqHik6gkknN3g==", + "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": "^5.5.0", - "kareem": "2.5.1", - "mongodb": "5.9.2", + "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": "16.0.1" + "sift": "17.1.3" }, "engines": { - "node": ">=14.20.1" + "node": ">=16.20.1" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/bson": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", - "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", + "node_modules/mongoose/node_modules/kerberos": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-2.2.1.tgz", + "integrity": "sha512-Vlyv1tjAPb0y2VIJ03dKkUjsneGIBuTkH24uGRx6/DrKpFlVuGPmct3m5aEotljVUlw7PAGWABwR5aNeW7y8Zw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.2" + }, "engines": { - "node": ">=14.20.1" + "node": ">=12.9.0" } }, "node_modules/mongoose/node_modules/mongodb": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", - "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "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": { - "bson": "^5.5.0", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.1", + "mongodb-connection-string-url": "^3.0.0" }, "engines": { - "node": ">=14.20.1" - }, - "optionalDependencies": { - "@mongodb-js/saslprep": "^1.1.0" + "node": ">=16.20.1" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.0.0", - "kerberos": "^1.0.0 || ^2.0.0", - "mongodb-client-encryption": ">=2.3.0 <3", - "snappy": "^7.2.2" + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" }, "peerDependenciesMeta": { "@aws-sdk/credential-providers": { @@ -15653,6 +15645,9 @@ "@mongodb-js/zstd": { "optional": true }, + "gcp-metadata": { + "optional": true + }, "kerberos": { "optional": true }, @@ -15661,6 +15656,9 @@ }, "snappy": { "optional": true + }, + "socks": { + "optional": true } } }, @@ -15669,6 +15667,105 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/mongoose/node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/mongoose/node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongoose/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/mongoose/node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongoose/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongoose/node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/monk": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/monk/-/monk-7.3.4.tgz", @@ -16082,7 +16179,7 @@ "version": "2.30.1", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.1.tgz", "integrity": "sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w==", - "devOptional": true, + "dev": true, "dependencies": { "semver": "^5.4.1" } @@ -16091,7 +16188,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "devOptional": true, + "dev": true, "bin": { "semver": "bin/semver" } @@ -16281,7 +16378,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", "integrity": "sha512-6kM8CLXvuW5crTxsAtva2YLrRrDaiTIkIePWs9moLHqbFWT94WpNFjwS/5dfLfECg5i/lkmw3aoqVidxt23TEQ==", - "devOptional": true + "dev": true }, "node_modules/nopt": { "version": "1.0.10", @@ -17896,7 +17993,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.2.tgz", "integrity": "sha512-PzYWIKZeP+967WuKYXlTOhYBgGOvTRSfaKI89XnfJ0ansRAH7hDU45X+K+FZeI1Wb/7p/NnuctPH3g0IqKUuSQ==", - "devOptional": true, + "dev": true, "dependencies": { "detect-libc": "^1.0.3", "expand-template": "^2.0.3", @@ -17924,7 +18021,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -17933,13 +18030,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "devOptional": true + "dev": true }, "node_modules/prebuild-install/node_modules/are-we-there-yet": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", - "devOptional": true, + "dev": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -17949,7 +18046,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "devOptional": true, + "dev": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -17961,7 +18058,7 @@ "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", - "devOptional": true, + "dev": true, "dependencies": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -17977,7 +18074,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", - "devOptional": true, + "dev": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -17989,13 +18086,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "devOptional": true + "dev": true }, "node_modules/prebuild-install/node_modules/npmlog": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", - "devOptional": true, + "dev": true, "dependencies": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -18007,7 +18104,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "devOptional": true, + "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -18022,7 +18119,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "devOptional": true, + "dev": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -18031,7 +18128,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", - "devOptional": true, + "dev": true, "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -18045,7 +18142,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "devOptional": true, + "dev": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -19576,9 +19673,10 @@ } }, "node_modules/sift": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" }, "node_modules/signal-exit": { "version": "3.0.7", @@ -19608,7 +19706,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", - "devOptional": true, + "dev": true, "dependencies": { "decompress-response": "^4.2.0", "once": "^1.3.1", @@ -19619,7 +19717,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", - "devOptional": true, + "dev": true, "dependencies": { "mimic-response": "^2.0.0" }, @@ -19631,7 +19729,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8" }, @@ -19772,15 +19870,6 @@ "node": ">=4" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", @@ -19900,19 +19989,6 @@ "node": ">=0.10.0" } }, - "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -19990,7 +20066,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "optional": true, "dependencies": { "memory-pager": "^1.0.2" } diff --git a/package.json b/package.json index c99cdfd5ff..e2c46ad21b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "method-override": "^3.0.0", "moment": "^2.29.4", "moment-recur": "^1.0.7", - "mongoose": "^7.8.3", + "mongoose": "^8.9.5", "morgan": "^1.10.0", "nconf": "^0.12.1", "node-gcm": "^1.0.5", diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index 2863269ff2..31d3b6488c 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -2,13 +2,22 @@ import moment from 'moment'; import nconf from 'nconf'; import requireAgain from 'require-again'; -import { recoverCron, cron } from '../../../../website/server/libs/cron'; +import { v4 as generateUUID } from 'uuid'; +import { + generateRes, + generateReq, + generateTodo, + generateDaily, +} from '../../../helpers/api-unit.helper'; +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 scoreTask = common.ops.scoreTask; +const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime(); +const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime(); const pathToCronLib = '../../../../website/server/libs/cron'; @@ -1200,7 +1209,7 @@ describe('cron', async () => { it('increments perfect day achievement if all (at least 1) due dailies were completed', async () => { daysMissed = 1; tasksByType.dailys[0].completed = true; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 }); + tasksByType.dailys[0].isDue = true; await cron({ user, tasksByType, daysMissed, analytics, @@ -1212,7 +1221,7 @@ describe('cron', async () => { it('does not increment perfect day achievement if no due dailies', async () => { daysMissed = 1; tasksByType.dailys[0].completed = true; - tasksByType.dailys[0].startDate = moment(new Date()).add({ days: 1 }); + tasksByType.dailys[0].isDue = false; await cron({ user, tasksByType, daysMissed, analytics, @@ -1224,7 +1233,7 @@ describe('cron', async () => { it('gives perfect day buff if all (at least 1) due dailies were completed', async () => { daysMissed = 1; tasksByType.dailys[0].completed = true; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 }); + tasksByType.dailys[0].isDue = true; const previousBuffs = user.stats.buffs.toObject(); @@ -1242,7 +1251,7 @@ describe('cron', async () => { user.preferences.sleep = true; daysMissed = 1; tasksByType.dailys[0].completed = true; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 }); + tasksByType.dailys[0].isDue = true; const previousBuffs = user.stats.buffs.toObject(); @@ -1259,7 +1268,7 @@ describe('cron', async () => { it('clears buffs if user does not have a perfect day (no due dailys)', async () => { daysMissed = 1; tasksByType.dailys[0].completed = true; - tasksByType.dailys[0].startDate = moment(new Date()).add({ days: 1 }); + tasksByType.dailys[0].isDue = false; user.stats.buffs = { str: 1, @@ -1488,78 +1497,6 @@ describe('cron', async () => { }); }); - describe('notifications', async () => { - it('adds a user notification', async () => { - const mpBefore = user.stats.mp; - tasksByType.dailys[0].completed = true; - - const statsComputedRes = common.statsComputed(user); - const stubbedStatsComputed = sinon.stub(common, 'statsComputed'); - stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 })); - - daysMissed = 1; - const hpBefore = user.stats.hp; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 }); - - await cron({ - user, tasksByType, daysMissed, analytics, - }); - - expect(user.notifications.length).to.be.greaterThan(0); - expect(user.notifications[1].type).to.equal('CRON'); - expect(user.notifications[1].data).to.eql({ - hp: user.stats.hp - hpBefore, - mp: user.stats.mp - mpBefore, - }); - - common.statsComputed.restore(); - }); - - it('condenses multiple notifications into one', async () => { - const mpBefore1 = user.stats.mp; - tasksByType.dailys[0].completed = true; - - const statsComputedRes = common.statsComputed(user); - const stubbedStatsComputed = sinon.stub(common, 'statsComputed'); - stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 })); - - daysMissed = 1; - const hpBefore1 = user.stats.hp; - tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 }); - - await cron({ - user, tasksByType, daysMissed, analytics, - }); - - expect(user.notifications.length).to.be.greaterThan(0); - expect(user.notifications[1].type).to.equal('CRON'); - expect(user.notifications[1].data).to.eql({ - hp: user.stats.hp - hpBefore1, - mp: user.stats.mp - mpBefore1, - }); - - const notifsBefore2 = user.notifications.length; - const hpBefore2 = user.stats.hp; - const mpBefore2 = user.stats.mp; - - user.lastCron = moment(new Date()).subtract({ days: 2 }); - - await cron({ - user, tasksByType, daysMissed, analytics, - }); - - expect(user.notifications.length - notifsBefore2).to.equal(0); - expect(user.notifications[0].type).to.not.equal('CRON'); - expect(user.notifications[1].type).to.equal('CRON'); - expect(user.notifications[1].data).to.eql({ - hp: user.stats.hp - hpBefore2 - (hpBefore2 - hpBefore1), - mp: user.stats.mp - mpBefore2 - (mpBefore2 - mpBefore1), - }); - expect(user.notifications[0].type).to.not.equal('CRON'); - common.statsComputed.restore(); - }); - }); - describe('private messages', async () => { let lastMessageId; @@ -1606,7 +1543,7 @@ describe('cron', async () => { await cron({ user, tasksByType, daysMissed, analytics, }); - expect(user.notifications.length).to.be.greaterThan(1); + expect(user.notifications.length).to.eql(1); expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE'); }); @@ -1820,64 +1757,258 @@ describe('cron', async () => { }); }); -describe('recoverCron', async () => { - let locals; let status; let - execStub; +describe('cron wrapper', () => { + let res; let + req; + let user; beforeEach(async () => { - execStub = sandbox.stub(); - sandbox.stub(User, 'findOne').returns({ exec: execStub }); - - status = { times: 0 }; - locals = { - user: new User({ - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'email@example.com', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase - }, - }, - }), - }; + res = generateRes(); + req = generateReq(); + user = await res.locals.user.save(); + res.analytics = analytics; }); - afterEach(async () => { + afterEach(() => { sandbox.restore(); }); - it('throws an error if user cannot be found', async () => { - execStub.returns(Promise.resolve(null)); + it('calls next when user is not attached', async () => { + res.locals.user = null; + await cronWrapper(req, res); + }); + + it('calls next when days have not been missed', async () => { + await cronWrapper(req, res); + }); + + it('should clear todos older than 30 days for free users', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({ days: 31 }); + task.completed = true; + await task.save(); + await user.save(); + + await cronWrapper(req, res); + const taskRes = await Tasks.Task.findOne({ _id: task._id }); + expect(taskRes).to.not.exist; + }); + + it('should not clear todos older than 30 days for subscribed users', async () => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({ days: 31 }); + task.completed = true; + await Promise.all([task.save(), user.save()]); + + await cronWrapper(req, res); + const taskRes = await Tasks.Task.findOne({ _id: task._id }); + expect(taskRes).to.exist; + }); + + it('should clear todos older than 90 days for subscribed users', async () => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + user.lastCron = moment(new Date()).subtract({ days: 2 }); + + const task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({ days: 91 }); + task.completed = true; + await task.save(); + await user.save(); + + await cronWrapper(req, res); + const taskRes = await Tasks.Task.findOne({ _id: task._id }); + expect(taskRes).to.not.exist; + }); + + it('should call next if user was not modified after cron', async () => { + const hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + + await cronWrapper(req, res); + expect(hpBefore).to.equal(user.stats.hp); + }); + + it('runs cron if previous cron was incomplete', async () => { + user.lastCron = moment(new Date()).subtract({ days: 1 }); + user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 }); + const now = new Date(); + await user.save(); + + await cronWrapper(req, res); + expect(moment(now).isSame(user.lastCron, 'day')); + expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day')); + }); + + it('updates user.auth.timestamps.loggedin and lastCron', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const now = new Date(); + await user.save(); + + await cronWrapper(req, res); + expect(moment(now).isSame(user.lastCron, 'day')); + expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day')); + }); + + it('does damage for missing dailies', async () => { + const hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const daily = generateDaily(user); + daily.startDate = moment(new Date()).subtract({ days: 2 }); + await daily.save(); + await user.save(); + + await cronWrapper(req, res); + const updatedUser = await User.findOne({ _id: user._id }); + expect(updatedUser.stats.hp).to.be.lessThan(hpBefore); + }); + + it('updates tasks', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const todo = generateTodo(user); + const todoValueBefore = todo.value; + await Promise.all([todo.save(), user.save()]); + + await cronWrapper(req, res); + const todoFound = await Tasks.Task.findOne({ _id: todo._id }); + expect(todoFound.value).to.be.lessThan(todoValueBefore); + }); + + it('updates large number of tasks', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const todo = generateTodo(user); + const todoValueBefore = todo.value; + const start = new Date(); + const saves = [todo.save(), user.save()]; + for (let i = 0; i < 200; i += 1) { + const newTodo = generateTodo(user); + newTodo.value = i; + saves.push(newTodo.save()); + } + await Promise.all(saves); + + await cronWrapper(req, res); + const duration = new Date() - start; + expect(duration).to.be.lessThan(1000); + const todoFound = await Tasks.Task.findOne({ _id: todo._id }); + expect(moment(start).isSame(user.lastCron, 'day')); + expect(moment(start).isSame(user.auth.timestamps.loggedin, 'day')); + expect(todoFound.value).to.be.lessThan(todoValueBefore); + }); + + it('fails entire cron if one task is failing', async () => { + const lastCron = moment(new Date()).subtract({ days: 2 }); + user.lastCron = lastCron; + const todo = generateTodo(user); + const todoValueBefore = todo.value; + const badTodo = generateTodo(user); + badTodo.text = 'bad todo'; + badTodo.attribute = 'bad'; + await Promise.all([badTodo.save({ validateBeforeSave: false }), todo.save(), user.save()]); try { - await recoverCron(status, locals); - throw new Error('no exception when user cannot be found'); + await cronWrapper(req, res); } catch (err) { - expect(err.message).to.eql(`User ${locals.user._id} not found while recovering.`); + expect(err).to.exist; + } + const todoFound = await Tasks.Task.findOne({ _id: todo._id }); + expect(moment(lastCron).isSame(user.lastCron, 'day')); + expect(todoFound.value).to.be.equal(todoValueBefore); + }); + + it('applies quest progress', async () => { + const hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const daily = generateDaily(user); + daily.startDate = moment(new Date()).subtract({ days: 2 }); + await daily.save(); + + const questKey = 'dilatory'; + user.party.quest.key = questKey; + + const party = new Group({ + type: 'party', + name: generateUUID(), + leader: user._id, + }); + party.quest.members[user._id] = true; + party.quest.key = questKey; + await party.save(); + + user.party._id = party._id; + await user.save(); + + party.startQuest(user); + + await cronWrapper(req, res); + const updatedUser = await User.findOne({ _id: user._id }); + expect(updatedUser.stats.hp).to.be.lessThan(hpBefore); + }); + + it('cronSignature less than 5 minutes ago should error', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const now = new Date(); + await User.updateOne({ + _id: user._id, + }, { + $set: { + _cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT, + }, + }).exec(); + await user.save(); + try { + await cronWrapper(req, res); + } catch (err) { + expect(err).to.exist; } }); - it('increases status.times count and reruns up to 4 times', async () => { - execStub.returns(Promise.resolve({ _cronSignature: 'RUNNING_CRON' })); - execStub.onCall(4).returns(Promise.resolve({ _cronSignature: 'NOT_RUNNING' })); + it('cronSignature longer than an hour ago should allow cron', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + const now = new Date(); + await User.updateOne({ + _id: user._id, + }, { + $set: { + _cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT, + }, + }).exec(); + await user.save(); - await recoverCron(status, locals); - - expect(status.times).to.eql(4); - expect(locals.user).to.eql({ _cronSignature: 'NOT_RUNNING' }); + await cronWrapper(req, res); + expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day')); + expect(user._cronSignature).to.be.equal('NOT_RUNNING'); }); - it('throws an error if recoverCron runs 5 times', async () => { - execStub.returns(Promise.resolve({ _cronSignature: 'RUNNING_CRON' })); + it('cron should not run more than once', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); - try { - await recoverCron(status, locals); - throw new Error('no exception when recoverCron runs 5 times'); - } catch (err) { - expect(status.times).to.eql(5); - expect(err.message).to.eql(`Impossible to recover from cron for user ${locals.user._id}.`); - } + const result = await Promise.allSettled([ + cronWrapper(req, res), + cronWrapper(req, res), + new Promise((resolve, reject) => { + setTimeout(async () => { + try { + const runResult = await cronWrapper(req, res); + if (runResult !== null) { + reject(new Error('cron ran more than once')); + } else { + resolve(); + } + } catch (err) { + reject(err); + } + }, 200); + }), + ]); + + expect(result.filter(r => r.status === 'fulfilled')).to.have.lengthOf(2); + expect(result.filter(r => r.status === 'rejected')).to.have.lengthOf(1); }); }); diff --git a/test/api/unit/libs/mongodb.js b/test/api/unit/libs/mongodb.js index 494dfb8a08..57a01b759a 100644 --- a/test/api/unit/libs/mongodb.js +++ b/test/api/unit/libs/mongodb.js @@ -1,5 +1,4 @@ import os from 'os'; -import nconf from 'nconf'; import requireAgain from 'require-again'; const pathToMongoLib = '../../../../website/server/libs/mongodb'; @@ -29,22 +28,4 @@ describe('mongodb', () => { expect(string).to.equal('mongodb://hostname:3030'); }); }); - - describe('getDefaultConnectionOptions', () => { - it('returns development config when IS_PROD is false', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); - const mongoLibOverride = requireAgain(pathToMongoLib); - - const options = mongoLibOverride.getDefaultConnectionOptions(); - expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']); - }); - - it('returns production config when IS_PROD is true', () => { - sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); - const mongoLibOverride = requireAgain(pathToMongoLib); - - const options = mongoLibOverride.getDefaultConnectionOptions(); - expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']); - }); - }); }); diff --git a/test/api/unit/middlewares/cronMiddleware.js b/test/api/unit/middlewares/cronMiddleware.js deleted file mode 100644 index ccc6ef2e50..0000000000 --- a/test/api/unit/middlewares/cronMiddleware.js +++ /dev/null @@ -1,332 +0,0 @@ -import moment from 'moment'; -import { v4 as generateUUID } from 'uuid'; -import { - generateRes, - generateReq, - generateTodo, - generateDaily, -} from '../../../helpers/api-unit.helper'; -import cronMiddleware from '../../../../website/server/middlewares/cron'; -import { model as User } from '../../../../website/server/models/user'; -import { model as Group } from '../../../../website/server/models/group'; -import * as Tasks from '../../../../website/server/models/task'; -import * as analyticsService from '../../../../website/server/libs/analyticsService'; -import * as cronLib from '../../../../website/server/libs/cron'; - -const CRON_TIMEOUT_WAIT = new Date(60 * 60 * 1000).getTime(); -const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime(); - -describe('cron middleware', () => { - let res; let - req; - let user; - - beforeEach(async () => { - res = generateRes(); - req = generateReq(); - user = await res.locals.user.save(); - res.analytics = analyticsService; - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('calls next when user is not attached', done => { - res.locals.user = null; - cronMiddleware(req, res, done); - }); - - it('calls next when days have not been missed', done => { - cronMiddleware(req, res, done); - }); - - it('should clear todos older than 30 days for free users', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const task = generateTodo(user); - task.dateCompleted = moment(new Date()).subtract({ days: 31 }); - task.completed = true; - await task.save(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - - Tasks.Task.findOne({ _id: task }).then(foundTask => { - expect(foundTask).to.not.exist; - resolve(); - }); - - return null; - }); - }); - }); - - it('should not clear todos older than 30 days for subscribed users', async () => { - user.purchased.plan.customerId = 'subscribedId'; - user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const task = generateTodo(user); - task.dateCompleted = moment(new Date()).subtract({ days: 31 }); - task.completed = true; - await task.save(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - Tasks.Task.findOne({ _id: task }).then(foundTask => { - expect(foundTask).to.exist; - return resolve(); - }); - return null; - }); - }); - }); - - it('should clear todos older than 90 days for subscribed users', async () => { - user.purchased.plan.customerId = 'subscribedId'; - user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); - user.lastCron = moment(new Date()).subtract({ days: 2 }); - - const task = generateTodo(user); - task.dateCompleted = moment(new Date()).subtract({ days: 91 }); - task.completed = true; - await task.save(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - Tasks.Task.findOne({ _id: task }).then(foundTask => { - expect(foundTask).to.not.exist; - return resolve(); - }); - return null; - }); - }); - }); - - it('should call next if user was not modified after cron', async () => { - const hpBefore = user.stats.hp; - user.lastCron = moment(new Date()).subtract({ days: 2 }); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - expect(hpBefore).to.equal(user.stats.hp); - return resolve(); - }); - }); - }); - - it('runs cron if previous cron was incomplete', async () => { - user.lastCron = moment(new Date()).subtract({ days: 1 }); - user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 }); - const now = new Date(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - expect(moment(now).isSame(user.lastCron, 'day')); - expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day')); - return resolve(); - }); - }); - }); - - it('updates user.auth.timestamps.loggedin and lastCron', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const now = new Date(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - expect(moment(now).isSame(user.lastCron, 'day')); - expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day')); - return resolve(); - }); - }); - }); - - it('does damage for missing dailies', async () => { - const hpBefore = user.stats.hp; - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const daily = generateDaily(user); - daily.startDate = moment(new Date()).subtract({ days: 2 }); - await daily.save(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - return User.findOne({ _id: user._id }).then(updatedUser => { - expect(updatedUser.stats.hp).to.be.lessThan(hpBefore); - return resolve(); - }); - }); - }); - }); - - it('updates tasks', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const todo = generateTodo(user); - const todoValueBefore = todo.value; - await Promise.all([todo.save(), user.save()]); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - return Tasks.Task.findOne({ _id: todo._id }).then(todoFound => { - expect(todoFound.value).to.be.lessThan(todoValueBefore); - return resolve(); - }); - }); - }); - }); - - it('applies quest progress', async () => { - const hpBefore = user.stats.hp; - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const daily = generateDaily(user); - daily.startDate = moment(new Date()).subtract({ days: 2 }); - await daily.save(); - - const questKey = 'dilatory'; - user.party.quest.key = questKey; - - const party = new Group({ - type: 'party', - name: generateUUID(), - leader: user._id, - }); - party.quest.members[user._id] = true; - party.quest.key = questKey; - await party.save(); - - user.party._id = party._id; - await user.save(); - - party.startQuest(user); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - return User.findOne({ _id: user._id }).then(updatedUser => { - expect(updatedUser.stats.hp).to.be.lessThan(hpBefore); - return resolve(); - }); - }); - }); - }); - - it('recovers from failed cron and does not error when user is already cronning', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - await user.save(); - - const updatedUser = user.toObject(); - updatedUser.matchedCount = 0; - - sandbox.spy(cronLib, 'recoverCron'); - - sandbox.stub(User, 'updateOne') - .withArgs({ - _id: user._id, - $or: [ - { _cronSignature: 'NOT_RUNNING' }, - { _cronSignature: { $lt: sinon.match.number } }, - ], - }) - .returns({ - exec () { - return Promise.resolve(updatedUser); - }, - }); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - expect(cronLib.recoverCron).to.be.calledOnce; - - return resolve(); - }); - }); - }); - - it('cronSignature less than an hour ago should error', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const now = new Date(); - await User.updateOne({ - _id: user._id, - }, { - $set: { - _cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT, - }, - }).exec(); - await user.save(); - const expectedErrMessage = `Impossible to recover from cron for user ${user._id}.`; - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (!err) return reject(new Error('Cron should have failed.')); - expect(err.message).to.be.equal(expectedErrMessage); - return resolve(); - }); - }); - }); - - it('cronSignature longer than an hour ago should allow cron', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - const now = new Date(); - await User.updateOne({ - _id: user._id, - }, { - $set: { - _cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT, - }, - }).exec(); - await user.save(); - - await new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day')); - expect(user._cronSignature).to.be.equal('NOT_RUNNING'); - return resolve(); - }); - }); - }); - - it('cron should not run more than once', async () => { - user.lastCron = moment(new Date()).subtract({ days: 2 }); - await user.save(); - - sandbox.spy(cronLib, 'cron'); - - await Promise.all([new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - return resolve(); - }); - }), new Promise((resolve, reject) => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - return resolve(); - }); - }), new Promise((resolve, reject) => { - setTimeout(() => { - cronMiddleware(req, res, err => { - if (err) return reject(err); - return resolve(); - }); - }, 400); - }), - ]); - - expect(cronLib.cron).to.be.calledOnce; - }); -}); diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index c67a00cb57..f0224d7477 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -74,15 +74,10 @@ export async function getDocument (collectionName, doc) { } before(done => { - mongoose.connection.on('open', err => { - if (err) return done(err); - return resetHabiticaDB() - .then(() => { - done(); - }) - .catch(error => { - throw error; - }); + mongoose.connection.once('open', async err => { + if (err) throw err; + await resetHabiticaDB(); + done(); }); }); diff --git a/website/client/src/components/appFooter.vue b/website/client/src/components/appFooter.vue index f7efd3be92..ae91f5d97c 100644 --- a/website/client/src/components/appFooter.vue +++ b/website/client/src/components/appFooter.vue @@ -944,24 +944,28 @@ export default { }, async jumpTime (amount) { const response = await axios.post('/api/v4/debug/jump-time', { offsetDays: amount }); - if (amount > 0) { - Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000); - } else { - Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate()); - } - this.lastTimeJump = response.data.data.time; - this.triggerGetWorldState(true); + setTimeout(() => { + if (amount > 0) { + Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000); + } else { + Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate()); + } + this.lastTimeJump = response.data.data.time; + this.triggerGetWorldState(true); + }, 1000); }, async resetTime () { const response = await axios.post('/api/v4/debug/jump-time', { reset: true }); const time = new Date(response.data.data.time); - Vue.config.clock.restore(); - Vue.config.clock = sinon.useFakeTimers({ - now: time, - shouldAdvanceTime: true, - }); - this.lastTimeJump = response.data.data.time; - this.triggerGetWorldState(true); + setTimeout(() => { + Vue.config.clock.restore(); + Vue.config.clock = sinon.useFakeTimers({ + now: time, + shouldAdvanceTime: true, + }); + this.lastTimeJump = response.data.data.time; + this.triggerGetWorldState(true); + }, 1000); }, addExp () { // @TODO: Name these variables better diff --git a/website/client/src/components/avatarModal/customize-options.vue b/website/client/src/components/avatarModal/customize-options.vue index 77153f945b..2a76bd505d 100644 --- a/website/client/src/components/avatarModal/customize-options.vue +++ b/website/client/src/components/avatarModal/customize-options.vue @@ -5,8 +5,8 @@ >
-
-
-
+
+
+
diff --git a/website/server/controllers/api-v3/cron.js b/website/server/controllers/api-v3/cron.js index bea3e41201..0b0902cdf1 100644 --- a/website/server/controllers/api-v3/cron.js +++ b/website/server/controllers/api-v3/cron.js @@ -1,5 +1,5 @@ import { authWithHeaders } from '../../middlewares/auth'; -import cron from '../../middlewares/cron'; +import { cronWrapper } from '../../libs/cron'; const api = {}; @@ -16,8 +16,9 @@ const api = {}; api.cron = { method: 'POST', url: '/cron', - middlewares: [authWithHeaders(), cron], + middlewares: [authWithHeaders()], async handler (req, res) { + await cronWrapper(req, res); res.respond(200, {}); }, }; diff --git a/website/server/controllers/api-v3/debug.js b/website/server/controllers/api-v3/debug.js index 96f4fc4b9e..108cf2dbaa 100644 --- a/website/server/controllers/api-v3/debug.js +++ b/website/server/controllers/api-v3/debug.js @@ -1,4 +1,5 @@ -import _ from 'lodash'; +import mongoose from 'mongoose'; +import get from 'lodash/get'; import sinon from 'sinon'; import moment from 'moment'; import { authWithHeaders } from '../../middlewares/auth'; @@ -10,6 +11,7 @@ import { model as Group, // basicFields as basicGroupFields, } from '../../models/group'; +import connectToMongoDB from '../../libs/mongoose'; const { content } = common; @@ -183,7 +185,7 @@ api.questProgress = { middlewares: [ensureDevelopmentMode, authWithHeaders()], async handler (req, res) { const { user } = res.locals; - const key = _.get(user, 'party.quest.key'); + const key = get(user, 'party.quest.key'); const quest = content.quests[key]; if (!quest) { @@ -286,7 +288,10 @@ api.timeTravelAdjust = { } else if (disable) { clock.restore(); clock = undefined; - } else if (clock !== undefined) { + } else if (offsetDays) { + if (clock === undefined) { + fakeClock(); + } try { clock.setSystemTime(moment().add(offsetDays, 'days').toDate()); } catch (e) { @@ -296,6 +301,10 @@ api.timeTravelAdjust = { throw new BadRequest('Invalid command'); } + if (mongoose.connection.readyState === 0) { + await connectToMongoDB(); + } + res.respond(200, { time: new Date(), }); diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 0c68778090..f5b286ccd4 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -642,7 +642,7 @@ api.joinGroup = { if (group.type === 'party') { // For parties we count the number of members from the database to get the correct value. // See #12275 on why this is necessary and only done for parties. - const currentMembers = await group.getMemberCount(); + const currentMembers = await group.getMemberCount({ excludeUserId: user._id }); // Load the inviter if (inviter) inviter = await User.findById(inviter).exec(); diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 61e79b8e0b..6ac166a2ec 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -1,11 +1,11 @@ import moment from 'moment'; -import _ from 'lodash'; -import cloneDeep from 'lodash/cloneDeep'; +import mongoose from 'mongoose'; import nconf from 'nconf'; import { model as User } from '../models/user'; +import * as Tasks from '../models/task'; +import { model as Group } from '../models/group'; import common from '../../common'; import { preenUserHistory } from './preening'; -import { sleep } from './sleep'; import { revealMysteryItems } from './payments/subscriptions'; import { model as UserHistory } from '../models/userHistory'; @@ -19,10 +19,12 @@ const { } = common; const { scoreTask } = common.ops; const { loginIncentives } = common.content; -// const maxPMs = 200; function setIsDueNextDue (task, user, now) { - const optionsForShouldDo = cloneDeep(user.preferences.toObject()); + const optionsForShouldDo = { + dayStart: user.preferences.dayStart, + timezoneOffset: user.preferences.timezoneOffset, + }; task.isDue = common.shouldDo(now, task, optionsForShouldDo); optionsForShouldDo.nextDue = true; const nextDue = common.shouldDo(now, task, optionsForShouldDo); @@ -31,37 +33,14 @@ function setIsDueNextDue (task, user, now) { } } -export async function recoverCron (status, locals) { - const { user } = locals; - - await sleep(0.3); - - const reloadedUser = await User.findOne({ _id: user._id }).exec(); - - if (!reloadedUser) { - throw new Error(`User ${user._id} not found while recovering.`); - } else if (reloadedUser._cronSignature !== 'NOT_RUNNING') { - status.times += 1; - - if (status.times < 5) { - await recoverCron(status, locals); - } else { - throw new Error(`Impossible to recover from cron for user ${user._id}.`); - } - } else { - locals.user = reloadedUser; - } +async function unlockUser (user) { + await User.updateOne({ + _id: user._id, + }, { + _cronSignature: 'NOT_RUNNING', + }).exec(); } -const CLEAR_BUFFS = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - async function grantEndOfTheMonthPerks (user, now) { const { plan, elapsedMonths } = getPlanContext(user, now); @@ -78,41 +57,25 @@ async function grantEndOfTheMonthPerks (user, now) { function removeTerminatedSubscription (user) { const { plan } = user.purchased; - - _.merge(plan, { - planId: null, - customerId: null, - subscriptionId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - }); + plan.planId = null; + plan.customerId = null; + plan.subscriptionId = null; + plan.paymentMethod = null; + plan.consecutive.count = 0; user.markModified('purchased.plan'); } -function resetHabitCounters (user, tasksByType, now, daysMissed) { +function processHabits (user, habits, now, daysMissed) { // check if we've passed a day on which we should reset the habit counters, including today - let resetWeekly = false; - let resetMonthly = false; - for (let i = 0; i < daysMissed; i += 1) { - if (resetWeekly === true && resetMonthly === true) { - break; - } - const thatDay = moment(now) - .utcOffset(user.getUtcOffset() - user.preferences.dayStart * 60) - .subtract({ days: i }); - if (thatDay.day() === 1) { - resetWeekly = true; - } - if (thatDay.date() === 1) { - resetMonthly = true; - } - } + const nowMoment = moment(now) + .utcOffset(user.getUtcOffset() - user.preferences.dayStart * 60); + const thatDay = nowMoment.clone() + .subtract({ days: daysMissed }); + const resetWeekly = nowMoment.isoWeek() !== thatDay.isoWeek(); + const resetMonthly = nowMoment.month() !== thatDay.month(); - tasksByType.habits.forEach(task => { + habits.forEach(task => { // reset counters if appropriate let reset = false; @@ -127,6 +90,12 @@ function resetHabitCounters (user, tasksByType, now, daysMissed) { task.counterUp = 0; task.counterDown = 0; } + + // slowly reset value to 0 for "onlies" (Habits with + or - but not both) + // move singleton Habits towards yellow. + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value /= 2; + } }); } @@ -139,7 +108,7 @@ function trackCronAnalytics (analytics, user, _progress, options) { user, resting: user.preferences.sleep, cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), + progressUp: Math.min(_progress.up, 900), progressDown: _progress.down, headers: options.headers, loginIncentives: user.loginIncentives, @@ -214,9 +183,6 @@ export async function cron (options = {}) { } = options; let _progress = { down: 0, up: 0, collectedItems: 0 }; - // Record pre-cron values of HP and MP to show notifications later - const beforeCronStats = _.pick(user.stats, ['hp', 'mp']); - user.preferences.timezoneOffsetAtLastCron = -timezoneUtcOffsetFromUserPrefs; // User is only allowed a certain number of drops a day. This resets the count. if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; @@ -250,9 +216,7 @@ export async function cron (options = {}) { // cron (mostly) acts as if it were only one day. // When site-wide difficulty settings are introduced, this can be a user preference option. - // Tally each task let todoTally = 0; - // make uncompleted To Do's redder (further incentive to complete them) tasksByType.todos.forEach(task => { if ( @@ -270,6 +234,7 @@ export async function cron (options = {}) { todoTally += task.value; }); + user.history.todos.push({ date: now.toISOString(), value: todoTally }); // For incomplete Dailys, add value (further incentive), // deduct health, keep records for later decreasing the nightly mana gain. @@ -288,18 +253,15 @@ export async function cron (options = {}) { const { completed } = task; // Deduct points for missed Daily tasks let evadeTask = 0; - let scheduleMisses = daysMissed; + let scheduleMisses = 0; if (completed) { if (!isTeamBoardTask) dailyChecked += 1; if (!atLeastOneDailyDue) { // only bother checking until the first thing is found - const thatDay = moment(now).subtract({ days: daysMissed }); - atLeastOneDailyDue = shouldDo(thatDay.toDate(), task, user.preferences); + atLeastOneDailyDue = task.isDue; } } else { // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - for (let i = 0; i < daysMissed; i += 1) { const thatDay = moment(now).subtract({ days: i + 1 }); @@ -324,11 +286,8 @@ export async function cron (options = {}) { // Partially completed checklists dock fewer mana points if (task.checklist && task.checklist.length > 0) { - const fractionChecked = _.reduce( - task.checklist, - (m, i) => m + (i.completed ? 1 : 0), - 0, - ) / task.checklist.length; + const completedItems = task.checklist.filter(i => i.completed).length; + const fractionChecked = completedItems / task.checklist.length; dailyDueUnchecked += 1 - fractionChecked; dailyChecked += fractionChecked; } else { @@ -377,18 +336,7 @@ export async function cron (options = {}) { } }); - resetHabitCounters(user, tasksByType, now, daysMissed); - - tasksByType.habits.forEach(task => { - // slowly reset value to 0 for "onlies" (Habits with + or - but not both) - // move singleton Habits towards yellow. - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value /= 2; - } - }); - - // Finished tallying - user.history.todos.push({ date: now.toISOString(), value: todoTally }); + processHabits(user, tasksByType.habits, now, daysMissed); // tally experience let expTally = user.stats.exp; @@ -401,11 +349,9 @@ export async function cron (options = {}) { user.history.exp.push({ date: now.toISOString(), value: expTally }); // Remove any remaining completed todos from the list of active todos + const incompleteTodoIds = tasksByType.todos.filter(task => !task.completed).map(task => task._id); user.tasksOrder.todos = user.tasksOrder.todos - .filter(taskOrderId => _.some( - tasksByType.todos, - taskType => taskType._id === taskOrderId && taskType.completed === false, - )); + .filter(taskOrderId => incompleteTodoIds.includes(taskOrderId)); // TODO also adjust tasksOrder arrays to remove deleted tasks of any kind (including rewards), ensure that all existing tasks are in the arrays, no tasks IDs are duplicated -- https://github.com/HabitRPG/habitica/issues/7645 // preen user history so that it doesn't become a performance problem @@ -424,7 +370,14 @@ export async function cron (options = {}) { streaks: false, }; } else { - user.stats.buffs = _.cloneDeep(CLEAR_BUFFS); + user.stats.buffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, + }; } common.setDebuffPotionItems(user); @@ -434,40 +387,25 @@ export async function cron (options = {}) { // Adjust for fraction of dailies completed if (!user.preferences.sleep) { if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += (_.max([10, 0.1 * common.statsComputed(user).maxMP]) * dailyChecked) / (dailyDueUnchecked + dailyChecked); // eslint-disable-line max-len - if (user.stats.mp > common.statsComputed(user).maxMP) { - user.stats.mp = common.statsComputed(user).maxMP; + const { maxMP } = common.statsComputed(user); + user.stats.mp += (Math.max(10, 0.1 * maxMP) * dailyChecked) / (dailyDueUnchecked + dailyChecked); // eslint-disable-line max-len + if (user.stats.mp > maxMP) { + user.stats.mp = maxMP; } - } - // After all is said and done, - // progress up user's effect on quest, return those values & reset the user's - if (!user.preferences.sleep) { + // After all is said and done, + // progress up user's effect on quest, return those values & reset the user's const { progress } = user.party.quest; _progress = progress.toObject(); // clone the old progress object - _.merge(progress, { down: 0, up: 0, collectedItems: 0 }); + progress.down = 0; + progress.up = 0; + progress.collectedItems = 0; } if (user.pinnedItems && user.pinnedItems.length > 0) { user.pinnedItems = common.cleanupPinnedItems(user); } - // Send notification for changes in HP and MP. - // First remove a possible previous cron notification because - // we don't want to flood the users with many cron notifications at once. - const oldCronNotif = user.notifications.find((notif, index) => { - if (notif && notif.type === 'CRON') { - user.notifications.splice(index, 1); - return true; - } - return false; - }); - - user.addNotification('CRON', { - hp: user.stats.hp - beforeCronStats.hp - (oldCronNotif ? oldCronNotif.data.hp : 0), - mp: user.stats.mp - beforeCronStats.mp - (oldCronNotif ? oldCronNotif.data.mp : 0), - }); - // Analytics user.flags.cronCount += 1; trackCronAnalytics(analytics, user, _progress, options); @@ -478,3 +416,134 @@ export async function cron (options = {}) { return _progress; } + +// Wait 5 minutes before attempting another cron +const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime(); + +async function checkForActiveCron (user, now, session) { + // set _cronSignature to current time in ms since epoch time + // so we can make sure to wait at least CRONT_TIMEOUT_WAIT before attempting another cron + const _cronSignature = now.getTime(); + // Calculate how long ago cron must have been attempted to try again + const cronRetryTime = _cronSignature - CRON_TIMEOUT_WAIT; + + // To avoid double cron we first set _cronSignature + // and then check that it's not changed while processing + const userUpdateResult = await User.updateOne({ + _id: user._id, + $or: [ // Make sure last cron was successful or failed before cronRetryTime + { _cronSignature: 'NOT_RUNNING' }, + { _cronSignature: { $lt: cronRetryTime } }, + ], + }, { + $set: { + _cronSignature, + }, + }, { session }).exec(); + + // If the cron signature is already set, cron is running in another request + // throw an error and recover later, + if (userUpdateResult.matchedCount === 0 || userUpdateResult.modifiedCount === 0) { + throw new Error('CRON_ALREADY_RUNNING'); + } +} + +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; + + try { + await checkForActiveCron(user, now); + const { daysMissed, timezoneUtcOffsetFromUserPrefs } = user.daysUserHasMissed(now, req); + + if (daysMissed <= 0) { + if (user.isModified()) { + user._cronSignature = 'NOT_RUNNING'; + await user.save(); + } else { + await unlockUser(user); + } + return null; + } + + // Clear old completed todos - 30 days for free users, 90 for subscribers + // Do not delete challenges completed todos TODO unless the task is broken? + // Do not delete group completed todos + await Tasks.Task.deleteMany({ + userId: user._id, + type: 'todo', + completed: true, + dateCompleted: { + $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), + }, + 'challenge.id': { $exists: false }, + 'group.id': { $exists: false }, + }).exec(); + + const tasks = await Tasks.Task.find({ + userId: user._id, + $or: [ // Exclude completed todos + { type: 'todo', completed: false }, + { type: { $in: ['habit', 'daily'] } }, + ], + }, null).exec(); + const tasksByType = { + habits: [], dailys: [], todos: [], rewards: [], + }; + tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); + + // Run cron + const progress = await cron({ + user, + tasksByType, + now, + daysMissed, + analytics, + timezoneUtcOffsetFromUserPrefs, + headers: req.headers, + }); + + // await Group.tavernBoss(user, progress); + + // Save user and tasks + user._cronSignature = 'NOT_RUNNING'; + user.markModified('_cronSignature'); + user.auth.timestamps.loggedin = now; + user.lastCron = now; + + session = await mongoose.startSession(); + await session.withTransaction(async () => { + await user.save({ session }); + for (const index in tasks) { + if (Object.prototype.hasOwnProperty.call(tasks, index)) { + const task = tasks[index]; + // eslint-disable-next-line no-await-in-loop + if (task.isModified()) await task.save({ session }); + } + } + }); + + await Group.processQuestProgress(user, progress); + + // Reload user + res.locals.user = await User.findOne({ _id: user._id }).exec(); + return null; + } catch (err) { + if (err.message !== 'CRON_ALREADY_RUNNING') { + // For any other error make sure to reset _cronSignature + // so that it doesn't prevent cron from running + // at the next request + await unlockUser(user); + } + + throw err; // re-throw the original error + } finally { + if (session) { + await session.endSession(); + } + } +} diff --git a/website/server/libs/mongodb.js b/website/server/libs/mongodb.js index b62a993a6b..d58de6ab8c 100644 --- a/website/server/libs/mongodb.js +++ b/website/server/libs/mongodb.js @@ -26,8 +26,6 @@ export function getDefaultConnectionOptions () { // with keepAlive deprecated, we don't need a separate set of production options // Keeping the structure here in case the distinction is useful later const commonOptions = { - useNewUrlParser: true, - useUnifiedTopology: true, }; return !IS_PROD ? commonOptions : { diff --git a/website/server/libs/mongoose.js b/website/server/libs/mongoose.js new file mode 100644 index 0000000000..14d1f2aa5a --- /dev/null +++ b/website/server/libs/mongoose.js @@ -0,0 +1,30 @@ +import nconf from 'nconf'; +import mongoose from 'mongoose'; +import logger from './logger'; +import { + getDevelopmentConnectionUrl, + getDefaultConnectionOptions, +} from './mongodb'; + +const IS_PROD = nconf.get('IS_PROD'); +const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE'); +const POOL_SIZE = nconf.get('MONGODB_POOL_SIZE'); +const SOCKET_TIMEOUT = nconf.get('MONGODB_SOCKET_TIMEOUT'); + +const mongooseOptions = getDefaultConnectionOptions(); + +if (POOL_SIZE) mongooseOptions.maxPoolSize = Number(POOL_SIZE); +if (SOCKET_TIMEOUT) mongooseOptions.socketTimeoutMS = Number(SOCKET_TIMEOUT); + +const DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI'); +const connectionUrl = IS_PROD ? DB_URI : getDevelopmentConnectionUrl(DB_URI); + +export default async function connectToMongoDB () { + // Do not connect to MongoDB when in maintenance mode + if (MAINTENANCE_MODE !== 'true') { + return mongoose.connect(connectionUrl, mongooseOptions).then(() => { + logger.info('Connected with Mongoose.'); + }); + } + return null; +} diff --git a/website/server/libs/setupMongoose.js b/website/server/libs/setupMongoose.js deleted file mode 100644 index 042b6307db..0000000000 --- a/website/server/libs/setupMongoose.js +++ /dev/null @@ -1,30 +0,0 @@ -import nconf from 'nconf'; -import mongoose from 'mongoose'; -import logger from './logger'; -import { - getDevelopmentConnectionUrl, - getDefaultConnectionOptions, -} from './mongodb'; - -const IS_PROD = nconf.get('IS_PROD'); -const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE'); -const POOL_SIZE = nconf.get('MONGODB_POOL_SIZE'); -const SOCKET_TIMEOUT = nconf.get('MONGODB_SOCKET_TIMEOUT'); - -// Do not connect to MongoDB when in maintenance mode -if (MAINTENANCE_MODE !== 'true') { - const mongooseOptions = getDefaultConnectionOptions(); - - if (POOL_SIZE) mongooseOptions.maxPoolSize = Number(POOL_SIZE); - if (SOCKET_TIMEOUT) mongooseOptions.socketTimeoutMS = Number(SOCKET_TIMEOUT); - - const DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI'); - const connectionUrl = IS_PROD ? DB_URI : getDevelopmentConnectionUrl(DB_URI); - - mongoose.connect(connectionUrl, mongooseOptions).then(() => { - logger.info('Connected with Mongoose.'); - }).catch(err => { - logger.error(err); - throw err; - }); -} diff --git a/website/server/middlewares/cron.js b/website/server/middlewares/cron.js deleted file mode 100644 index e9922dc762..0000000000 --- a/website/server/middlewares/cron.js +++ /dev/null @@ -1,174 +0,0 @@ -import moment from 'moment'; -import * as Tasks from '../models/task'; -import { model as Group } from '../models/group'; -import { model as User } from '../models/user'; -import { recoverCron, cron } from '../libs/cron'; - -// Wait this length of time in ms before attempting another cron -const CRON_TIMEOUT_WAIT = new Date(60 * 60 * 1000).getTime(); - -async function checkForActiveCron (user, now) { - // set _cronSignature to current time in ms since epoch time - // so we can make sure to wait at least CRONT_TIMEOUT_WAIT before attempting another cron - const _cronSignature = now.getTime(); - // Calculate how long ago cron must have been attempted to try again - const cronRetryTime = _cronSignature - CRON_TIMEOUT_WAIT; - - // To avoid double cron we first set _cronSignature - // and then check that it's not changed while processing - const userUpdateResult = await User.updateOne({ - _id: user._id, - $or: [ // Make sure last cron was successful or failed before cronRetryTime - { _cronSignature: 'NOT_RUNNING' }, - { _cronSignature: { $lt: cronRetryTime } }, - ], - }, { - $set: { - _cronSignature, - }, - }).exec(); - - // If the cron signature is already set, cron is running in another request - // throw an error and recover later, - if (userUpdateResult.matchedCount === 0 || userUpdateResult.modifiedCount === 0) { - throw new Error('CRON_ALREADY_RUNNING'); - } -} - -async function updateLastCron (user, now) { - await User.updateOne({ - _id: user._id, - }, { - lastCron: now, // setting lastCron now so we don't risk re-running parts of cron if it fails - }).exec(); -} - -async function unlockUser (user) { - await User.updateOne({ - _id: user._id, - }, { - _cronSignature: 'NOT_RUNNING', - }).exec(); -} - -async function cronAsync (req, res) { - let { user } = res.locals; - if (!user) return null; // User might not be available when authentication is not mandatory - - const { analytics } = res; - const now = new Date(); - - try { - await checkForActiveCron(user, now); - - user = await User.findOne({ _id: user._id }).exec(); - res.locals.user = user; - const { daysMissed, timezoneUtcOffsetFromUserPrefs } = user.daysUserHasMissed(now, req); - - await updateLastCron(user, now); - - if (daysMissed <= 0) { - if (user.isModified()) await user.save(); - await unlockUser(user); - return null; - } - const tasks = await Tasks.Task.find({ - userId: user._id, - $or: [ // Exclude completed todos - { type: 'todo', completed: false }, - { type: { $in: ['habit', 'daily'] } }, - ], - }).exec(); - - const tasksByType = { - habits: [], dailys: [], todos: [], rewards: [], - }; - tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); - - // Run cron - const progress = await cron({ - user, - tasksByType, - now, - daysMissed, - analytics, - timezoneUtcOffsetFromUserPrefs, - headers: req.headers, - }); - - // Clear old completed todos - 30 days for free users, 90 for subscribers - // Do not delete challenges completed todos TODO unless the task is broken? - // Do not delete group completed todos - Tasks.Task.deleteMany({ - userId: user._id, - type: 'todo', - completed: true, - dateCompleted: { - $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), - }, - 'challenge.id': { $exists: false }, - 'group.id': { $exists: false }, - }).exec(); - - res.locals.wasModified = true; // TODO remove after v2 is retired - - Group.tavernBoss(user, progress); - - // Save user and tasks - const toSave = [user.save()]; - tasks.forEach(task => { - if (task.isModified()) toSave.push(task.save()); - }); - await Promise.all(toSave); - - await Group.processQuestProgress(user, progress); - - // Set _cronSignature, lastCron and auth.timestamps.loggedin to signal end of cron - await User.updateOne({ - _id: user._id, - }, { - $set: { - _cronSignature: 'NOT_RUNNING', - 'auth.timestamps.loggedin': now, - }, - }).exec(); - - // Reload user - res.locals.user = await User.findOne({ _id: user._id }).exec(); - return null; - } catch (err) { - // If cron was aborted for a race condition try to recover from it - if (err.message === 'CRON_ALREADY_RUNNING') { - // Recovering after abort, wait 300ms and reload user - // do it for max 5 times then reset _cronSignature - // so that it doesn't prevent cron from running - // at the next request - const recoveryStatus = { - times: 0, - }; - - await recoverCron(recoveryStatus, res.locals); - } else { - // For any other error make sure to reset _cronSignature - // so that it doesn't prevent cron from running - // at the next request - await User.updateOne({ - _id: user._id, - }, { - _cronSignature: 'NOT_RUNNING', - }).exec(); - - throw err; // re-throw the original error - } - - return null; - } -} - -export default function cronMiddleware (req, res, next) { - cronAsync(req, res) - .then(() => { - next(); - }) - .catch(next); -} diff --git a/website/server/models/group.js b/website/server/models/group.js index 63a7a45bbf..fc4d259f8f 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -521,13 +521,21 @@ schema.methods.isMember = function isGroupMember (user) { return user.guilds.indexOf(this._id) !== -1; }; -schema.methods.getMemberCount = async function getMemberCount () { +schema.methods.getMemberCount = async function getMemberCount (options) { + let excludeUserId = null; + if (options && options.excludeUserId) { + excludeUserId = options.excludeUserId; + } let query = { guilds: this._id }; if (this.type === 'party') { query = { 'party._id': this._id }; } + if (excludeUserId) { + query._id = { $ne: excludeUserId }; + } + return User.countDocuments(query).exec(); }; @@ -1354,6 +1362,10 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC throw new NotAuthorized(shared.i18n.t('leaderCannotLeaveGroupWithActiveGroup')); } + if (group.purchased.plan.customerId) { + await payments.cancelGroupSubscriptionForUser(user, this); + } + // only remove user from challenges if it's set to leave-challenges if (keepChallenges === 'leave-challenges') { const challenges = await Challenge.find({ @@ -1393,10 +1405,6 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC update.$unset = { [`quest.members.${user._id}`]: 1 }; } - if (group.purchased.plan.customerId) { - promises.push(payments.cancelGroupSubscriptionForUser(user, this)); - } - // If user is the last one in group and group is private, delete it if (group.memberCount <= 1 && group.privacy === 'private') { // double check the member count is correct diff --git a/website/server/server.js b/website/server/server.js index 59f3125ea1..24aec935bd 100644 --- a/website/server/server.js +++ b/website/server/server.js @@ -10,7 +10,7 @@ import './libs/i18n'; import attachMiddlewares from './middlewares/index'; // Load config files -import './libs/setupMongoose'; +import connectToMongoDB from './libs/mongoose'; import './libs/setupPassport'; import './libs/setupFirebase'; @@ -19,6 +19,8 @@ import './models/challenge'; import './models/group'; import './models/user'; +connectToMongoDB(); + const server = http.createServer(); const app = express();